<?php
/**
 * PSCP_Oracle_Client — plugin-side HMAC client for the PSCP Oracle service
 * (pscp-oracle on VPS `:8110`).
 *
 * Wraps four Oracle endpoints:
 *   POST /api/v1/enroll                  (no auth, used ONCE at plugin activation)
 *   POST /api/v1/orders                  (HMAC, registers a pending payment intent)
 *   GET  /api/v1/orders/<pi_id>/status   (HMAC, polls pending/paid/expired + cached payload)
 *   POST /api/v1/orders/<pi_id>/check-now (HMAC, force on-chain re-verify)
 *
 * Auth scheme matches Oracle `require_auth()` in oracle.py:
 *   X-PSCP-Merchant:  <api_key_id (public, 20 url-safe chars)>
 *   X-PSCP-Timestamp: <unix seconds, ±5min tolerance>
 *   X-PSCP-Nonce:     <random per-request (server enforces uniqueness via DB)>
 *   X-PSCP-Signature: <hex HMAC-SHA256(sha256(api_secret), "METHOD|PATH|ts|nonce|sha256(body)")>
 *
 * Response signature (Oracle signs full body with sha256(api_secret)):
 *   JSON body contains `signature`, `signature_method`, `signature_version`.
 *   verify_payload_signature() reconstructs canonical JSON (sort_keys, no whitespace)
 *   and compares. Mismatch = reject (possible MITM).
 *
 * Stores/reads these wp_options (created by enroll()):
 *   parcselect_label_oracle_url        — base URL, default https://map.parcelselector.com
 *                                       (can also be set to a custom host, e.g. http://127.0.0.1:8110)
 *   parcselect_label_merchant_id       — UUID from enroll response
 *   parcselect_label_api_key_id        — public key (X-PSCP-Merchant header)
 *   parcselect_label_api_secret        — plaintext, shown once, autoload=false
 *   parcselect_label_installation_id   — alias = merchant_id (used by webhook verify)
 *   parcselect_label_enrolled_at       — ISO timestamp
 *
 * @package ParcelSelector
 * @since   4.1.0
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

// NS-83 2026-04-30: class_exists guard — ps-cryptopay plugin definiuje tę samą
// klasę PSCP_Oracle_Client. Bez guardu PHP Fatal: "Cannot declare class ... already in use"
// (Local Sites debug.log 30-Apr 15:27:21+24+25). Defer to whoever loads first
// (typowo: ps-cryptopay, jeśli aktywny). Idempotent — bezpieczne dla każdej kolejności.
if ( ! class_exists( 'Parcselect_Oracle_Client' ) ) :

class Parcselect_Oracle_Client {

	/* ─── B5 (2026-04-25) Circuit breaker ─────────────────────────────────
	 * After 3 consecutive Oracle failures, show an admin-only warning in
	 * wp-admin. Reset on first successful call.
	 */
	const B5_FAIL_OPT     = 'parcselect_label_oracle_fail_count';
	const B5_LAST_ERR_OPT = 'parcselect_label_oracle_last_error';
	const B5_LAST_OK_OPT  = 'parcselect_label_oracle_last_ok_at';
	const B5_THRESHOLD    = 3;

	public static function b5_record_failure( $error_msg = '' ) {
		$cnt = (int) get_option( self::B5_FAIL_OPT, 0 ) + 1;
		update_option( self::B5_FAIL_OPT, $cnt, false );
		if ( $error_msg ) {
			update_option( self::B5_LAST_ERR_OPT, array(
				'msg' => substr( (string) $error_msg, 0, 300 ),
				'at'  => time(),
			), false );
		}
	}

	public static function b5_record_success() {
		if ( (int) get_option( self::B5_FAIL_OPT, 0 ) > 0 ) {
			delete_option( self::B5_FAIL_OPT );
			delete_option( self::B5_LAST_ERR_OPT );
		}
		update_option( self::B5_LAST_OK_OPT, time(), false );
	}

	public static function b5_admin_notice() {
		if ( ! function_exists( 'current_user_can' ) || ! current_user_can( 'manage_woocommerce' ) ) return;
		$cnt = (int) get_option( self::B5_FAIL_OPT, 0 );
		if ( $cnt < self::B5_THRESHOLD ) return;
		$last = get_option( self::B5_LAST_ERR_OPT, array() );
		$msg  = is_array( $last ) && isset( $last['msg'] ) ? $last['msg'] : '';
		$when = is_array( $last ) && isset( $last['at'] )  ? sprintf( /* translators: %s = human-readable time difference */ __( '%s ago', 'parcel-selector' ), human_time_diff( (int) $last['at'] ) ) : '';
		?>
		<div class="notice notice-error is-dismissible">
			<p><strong>⚠ <?php esc_html_e( 'ParcelSelector FREE — Label tool:', 'parcel-selector' ); ?></strong>
			   <?php /* translators: %d = number of consecutive errors */ printf( esc_html__( 'Oracle API unreachable — %d consecutive errors', 'parcel-selector' ), (int) $cnt ); ?>
			   <?php if ( $when ) : ?>(<?php printf( /* translators: %s = time of last error */ esc_html__( 'last %s', 'parcel-selector' ), esc_html( $when ) ); ?>)<?php endif; ?>.
			   <?php if ( $msg ) : ?>
			   <br /><code style="font-size:11px"><?php echo esc_html( $msg ); ?></code>
			   <?php endif; ?>
			   <br /><?php esc_html_e( 'Generation of new crypto labels is temporarily blocked.', 'parcel-selector' ); ?>
			</p>
		</div>
		<?php
	}
	/* ─── end B5 ─────────────────────────────────────────────────────────── */


	/** Default Oracle base URL. Overridable via `parcselect_label_oracle_url` option. */
	const DEFAULT_BASE = 'https://api.parcelselector.com';

	/** Fallback direct-IP base if DNS fails. */
	const FALLBACK_BASE = 'https://api.parcelselector.com';

	/** Request timeout in seconds. */
	const TIMEOUT = 30; // 2026-05-14: bumped from 15s after first-enroll observation

	/**
	 * Base URL helper.
	 */
	public static function base() {
		$b = (string) get_option( 'parcselect_label_oracle_url', self::DEFAULT_BASE );
		return rtrim( $b, '/' );
	}

	/**
	 * NS-83 v3 — compatibility aliases for ps-cryptopay plugin.
	 * When both parcel-selector + ps-cryptopay aktywne, NASZA klasa wygrywa
	 * (alphabetical load order). Ps-cryptopay code expects url() i url_is_locked()
	 * methods. Dodajemy je jako proxy do base() żeby ich cron/admin nie padał.
	 */
	public static function url() {
		return self::base();
	}
	public static function url_is_locked() {
		return defined( 'PARCSELECT_ORACLE_URL' ) && PARCSELECT_ORACLE_URL;
	}

	/**
	 * Generate nonce + timestamp + signature for a request.
	 *
	 * @param string $method 'POST' or 'GET'
	 * @param string $path   e.g. '/api/v1/orders'
	 * @param string $body   raw JSON body (empty string for GET)
	 * @return array{headers:array<string,string>}
	 */
	protected static function sign_request( $method, $path, $body ) {
		$api_key_id = (string) get_option( 'parcselect_label_api_key_id', '' );
		$api_secret = (string) get_option( 'parcselect_label_api_secret', '' );

		if ( '' === $api_key_id || '' === $api_secret ) {
			return array(
				'error' => new WP_Error(
					'not_enrolled',
					'Plugin not enrolled with Oracle. Call Parcselect_Oracle_Client::enroll() first.'
				),
			);
		}

		$ts      = (string) time();
		$nonce   = function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : bin2hex( random_bytes( 16 ) );
		$body_sha = hash( 'sha256', $body );
		$msg     = $method . '|' . $path . '|' . $ts . '|' . $nonce . '|' . $body_sha;
		$hmac_key = hash( 'sha256', $api_secret );
		$sig     = hash_hmac( 'sha256', $msg, $hmac_key );

		return array(
			'headers' => array(
				'X-PSCP-Merchant'  => $api_key_id,
				'X-PSCP-Timestamp' => $ts,
				'X-PSCP-Nonce'     => $nonce,
				'X-PSCP-Signature' => $sig,
				'Content-Type'     => 'application/json',
				'Accept'           => 'application/json',
				'User-Agent'       => 'parcel-selector/4.1 PSCP_Oracle_Client',
			),
		);
	}

	/**
	 * Verify that Oracle response body signature matches our api_secret.
	 * Canonical JSON: sort_keys recursive, no whitespace, JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE.
	 *
	 * @return true|WP_Error
	 */
	public static function verify_payload_signature( array $payload ) {
		$api_secret = (string) get_option( 'parcselect_label_api_secret', '' );
		if ( '' === $api_secret ) {
			return new WP_Error( 'not_enrolled', 'No api_secret stored' );
		}
		if ( ! isset( $payload['signature'] ) ) {
			return new WP_Error( 'no_signature', 'Response missing signature' );
		}
		$received = (string) $payload['signature'];
		unset( $payload['signature'], $payload['signature_method'], $payload['signature_version'] );

		$canonical = self::canonical_json( $payload );
		$hmac_key  = hash( 'sha256', $api_secret );
		$expected  = hash_hmac( 'sha256', $canonical, $hmac_key );

		if ( ! hash_equals( $expected, $received ) ) {
			return new WP_Error(
				'signature_mismatch',
				'Oracle response signature invalid — possible MITM or secret rotation'
			);
		}
		return true;
	}

	/**
	 * Build canonical JSON matching Python json.dumps(sort_keys=True, separators=(',', ':'), ensure_ascii=False).
	 */
	protected static function canonical_json( $data ) {
		if ( is_array( $data ) ) {
			// Recursively sort + re-encode
			$sorted = self::sort_recursive( $data );
			return wp_json_encode(
				$sorted,
				JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
			);
		}
		return wp_json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
	}

	protected static function sort_recursive( $arr ) {
		if ( ! is_array( $arr ) ) {
			return $arr;
		}
		// Only sort if associative (string keys present); numeric arrays keep order
		$is_assoc = array_keys( $arr ) !== range( 0, count( $arr ) - 1 );
		if ( $is_assoc ) {
			ksort( $arr );
		}
		foreach ( $arr as $k => $v ) {
			if ( is_array( $v ) ) {
				$arr[ $k ] = self::sort_recursive( $v );
			}
		}
		return $arr;
	}

	/**
	 * Enroll this plugin installation with the Oracle (no auth, one-shot).
	 *
	 * Saves merchant_id, api_key_id, api_secret (plaintext, once), installation_id
	 * into wp_options (autoload=false).
	 *
	 * @return array|WP_Error  enroll response dict, or WP_Error on failure.
	 */
	public static function enroll() {
		$url = self::base() . '/api/v1/enroll';
		$body = wp_json_encode( array(
			'site_url'      => home_url(),
			'contact_email' => get_option( 'admin_email' ),
		) );
		$resp = wp_remote_post( $url, array(
			'timeout'   => 45, // 2026-05-14: enroll specialnie 45s (jednorazowy)
			'headers'   => array( 'Content-Type' => 'application/json' ),
			'body'      => $body,
			'sslverify' => true,
		) );
		if ( is_wp_error( $resp ) ) {
			return $resp;
		}
		$code = (int) wp_remote_retrieve_response_code( $resp );
		$data = json_decode( wp_remote_retrieve_body( $resp ), true );
		if ( $code === 409 ) {
			return new WP_Error( 'already_enrolled', 'site_url already enrolled — Oracle admin must reset' );
		}
		if ( $code !== 201 || ! is_array( $data ) || empty( $data['api_secret'] ) ) {
			self::b5_record_failure( 'enroll_failed' ); return new WP_Error( 'enroll_failed', sprintf( 'HTTP %d: %s', $code, substr( wp_remote_retrieve_body( $resp ), 0, 200 ) ) );
		}

		// Save (autoload=false so api_secret never bleeds to other options calls)
		update_option( 'parcselect_label_merchant_id',     $data['merchant_id'],    false );
		update_option( 'parcselect_label_api_key_id',      $data['api_key_id'],     false );
		update_option( 'parcselect_label_api_secret',      $data['api_secret'],     false );
		update_option( 'parcselect_label_installation_id', $data['merchant_id'],    false );
		update_option( 'parcselect_label_enrolled_at',     gmdate( 'c' ),           false );

		return $data;
	}

	/**
	 * Is this plugin already enrolled?
	 */
	public static function is_enrolled() {
		return '' !== (string) get_option( 'parcselect_label_api_key_id', '' )
		    && '' !== (string) get_option( 'parcselect_label_api_secret', '' );
	}

	/**
	 * Register a new payment intent (order) with Oracle.
	 *
	 * Oracle will:
	 *   1. INSERT pscp_orders row
	 *   2. Compute dust_id = crc32(pi_id) % 1_000_000
	 *   3. Delegate to ps-cryptopay /wp-json/pscp/v1/build-intent for full payload
	 *   4. Cache payload in pscp_orders.payload_cache (JSONB)
	 *   5. Sign response with merchant.api_secret_hash (HMAC-SHA256)
	 *   6. Return full payload (40+ fields: wallet, amount, qr_uri, rate_sources, i18n, ...)
	 *
	 * @param array $payload  request body:
	 *   wc_order_id, wc_order_key, coin, address, fiat_total, currency,
	 *   ttl_minutes, purpose ('embed_label'|'wc_order'|...), locale
	 * @return array|WP_Error  full payload with verified signature, or WP_Error
	 */
	public static function register_order( array $payload ) {
		if ( ! self::is_enrolled() ) {
			return new WP_Error( 'not_enrolled', 'Plugin not enrolled — call enroll() first' );
		}

		$path = '/api/v1/orders';
		$body = wp_json_encode( $payload );
		if ( false === $body ) {
			return new WP_Error( 'json_encode_fail', 'Payload not JSON-encodable' );
		}

		$sig = self::sign_request( 'POST', $path, $body );
		if ( isset( $sig['error'] ) ) {
			return $sig['error'];
		}

		$resp = wp_remote_post( self::base() . $path, array(
			'timeout'   => self::TIMEOUT,
			'headers'   => $sig['headers'],
			'body'      => $body,
			'sslverify' => true,
		) );
		if ( is_wp_error( $resp ) ) {
			return $resp;
		}
		$code = (int) wp_remote_retrieve_response_code( $resp );
		$body_raw = wp_remote_retrieve_body( $resp );
		$data = json_decode( $body_raw, true );

		if ( $code !== 201 || ! is_array( $data ) ) {
			$err_code = is_array( $data ) && isset( $data['error'] ) ? (string) $data['error'] : 'http_' . $code;
			return new WP_Error( $err_code, sprintf( 'Oracle HTTP %d: %s', $code, substr( $body_raw, 0, 300 ) ) );
		}

		// Must have at minimum pi_id + wallet_address + amount for a valid payload
		if ( ! isset( $data['pi_id'], $data['wallet_address'], $data['amount'] ) ) {
			return new WP_Error( 'incomplete_payload', 'Oracle returned response without required fields' );
		}

		// If response has signature (post-A2 full payload), verify it.
		if ( isset( $data['signature'] ) ) {
			$verify = self::verify_payload_signature( $data );
			if ( is_wp_error( $verify ) ) {
				return $verify;
			}
		}

		return $data;
	}

	/**
	 * Poll order status. Returns cached payload + live status overlay (fresh signature).
	 */
	public static function get_status( $pi_id ) {
		if ( ! self::is_enrolled() ) {
			return new WP_Error( 'not_enrolled' );
		}
		$pi_id = preg_replace( '/[^a-zA-Z0-9-]/', '', (string) $pi_id );
		if ( '' === $pi_id ) {
			return new WP_Error( 'bad_pi_id' );
		}

		$path = '/api/v1/orders/' . $pi_id . '/status';
		$sig  = self::sign_request( 'GET', $path, '' );
		if ( isset( $sig['error'] ) ) {
			return $sig['error'];
		}
		$resp = wp_remote_get( self::base() . $path, array(
			'timeout'   => self::TIMEOUT,
			'headers'   => $sig['headers'],
			'sslverify' => true,
		) );
		if ( is_wp_error( $resp ) ) {
			return $resp;
		}
		$code = (int) wp_remote_retrieve_response_code( $resp );
		$data = json_decode( wp_remote_retrieve_body( $resp ), true );
		if ( $code !== 200 || ! is_array( $data ) ) {
			return new WP_Error( 'status_fail', sprintf( 'HTTP %d', $code ) );
		}

		// Verify signature if present (post-A2 cached payload response)
		if ( isset( $data['signature'] ) ) {
			$v = self::verify_payload_signature( $data );
			if ( is_wp_error( $v ) ) {
				return $v;
			}
		}
		return $data;
	}

	/**
	 * Force immediate chain re-verification (admin "Check payment now" button).
	 */
	public static function check_now( $pi_id ) {
		if ( ! self::is_enrolled() ) {
			return new WP_Error( 'not_enrolled' );
		}
		$pi_id = preg_replace( '/[^a-zA-Z0-9-]/', '', (string) $pi_id );
		if ( '' === $pi_id ) {
			return new WP_Error( 'bad_pi_id' );
		}

		$path = '/api/v1/orders/' . $pi_id . '/check-now';
		$body = '{}';
		$sig  = self::sign_request( 'POST', $path, $body );
		if ( isset( $sig['error'] ) ) {
			return $sig['error'];
		}
		$resp = wp_remote_post( self::base() . $path, array(
			'timeout'   => 30,   // chain RPC can be slow
			'headers'   => $sig['headers'],
			'body'      => $body,
			'sslverify' => true,
		) );
		if ( is_wp_error( $resp ) ) {
			return $resp;
		}
		$code = (int) wp_remote_retrieve_response_code( $resp );
		$data = json_decode( wp_remote_retrieve_body( $resp ), true );
		if ( $code !== 200 || ! is_array( $data ) ) {
			return new WP_Error( 'check_now_fail', sprintf( 'HTTP %d', $code ) );
		}
		return $data;
	}

	/**
	 * Health check (unauth — safe to call from public code). For circuit breaker.
	 */
	public static function health() {
		$resp = wp_remote_get( self::base() . '/api/v1/health', array(
			'timeout' => 5,
			'sslverify' => true,
		) );
		if ( is_wp_error( $resp ) ) {
			return $resp;
		}
		$code = (int) wp_remote_retrieve_response_code( $resp );
		$data = json_decode( wp_remote_retrieve_body( $resp ), true );
		if ( $code !== 200 || ! is_array( $data ) ) {
			return new WP_Error( 'oracle_unavailable', sprintf( 'HTTP %d', $code ) );
		}
		return $data;
	}
}

endif; // NS-83 class_exists guard

if ( class_exists( 'Parcselect_Oracle_Client' ) && method_exists( 'Parcselect_Oracle_Client', 'b5_admin_notice' ) ) {
	add_action( 'admin_notices', array( 'Parcselect_Oracle_Client', 'b5_admin_notice' ) );
}
