<?php
/**
 * Plugin RSA registration (completion side).
 *
 * One-time pairing per API credential: the merchant starts the challenge in
 * the Merchant Panel (merchant-only route) and hands the resulting
 * `credentialId` + `challengeBase64` to the shop. This class finishes the
 * handshake from the shop side — generate a key pair, prove possession by
 * signing the challenge, and register the public key with the backend.
 *
 * @package Simplyin\Simplyin_W_Plugin\Lib\Remote\Sin_Core_Api
 */

declare( strict_types=1 );

namespace Simplyin\Simplyin_W_Plugin\Lib\Remote\Sin_Core_Api;

use GuzzleHttp\Client;

if ( ! defined( 'ABSPATH' ) ) {
	exit; // Exit if accessed directly.
}

class Plugin_Rsa_Registrar {

	/**
	 * Backend route (relative to the configured /api base) that finalizes
	 * registration. Authenticated with the legacy API key header — last time
	 * the JWT secret is required for this credential.
	 */
	const COMPLETE_ENDPOINT = 'plugin-sync/completePluginPublicKeyRegistration';

	/**
	 * API-key-authenticated start-challenge route. Lets the plugin obtain the
	 * challenge without a Merchant Panel session; returns { credentialId, challengeBase64 }.
	 */
	const START_ENDPOINT = 'plugin-sync/startPluginPublicKeyRegistration';

	/**
	 * Persisted flag: the last auto_register() start attempt failed because the backend
	 * already has a public key on file for this merchant (a stale pairing from a previous
	 * session), not because the API key is invalid. Read by the admin surface so it can show
	 * the correct guidance instead of implying a bad API key. Reset at the top of every
	 * auto_register() attempt, so it always reflects the outcome of the most recent one.
	 */
	const OPTION_ALREADY_REGISTERED = 'simplyin_plugin_rsa_already_registered';

	/**
	 * Backend route that revokes ("un-registers") this credential's currently registered
	 * public key. Unlike the two routes above, this one only accepts an RSA-signed request
	 * (no legacy-key fallback) — see {@see revoke()}.
	 */
	const REVOKE_ENDPOINT = 'plugin-sync/revokePluginPublicKeyRegistration';

	private Plugin_Rsa_Signer $signer;
	private Client $http_client;

	public function __construct( ?Plugin_Rsa_Signer $signer = null, ?Client $http_client = null ) {
		$this->signer      = $signer ?? new Plugin_Rsa_Signer();
		$this->http_client = $http_client ?? new Client( [ 'timeout' => 15, 'connect_timeout' => 5 ] );
	}

	/**
	 * Whether the last registration start attempt failed because the backend already has a
	 * public key registered for this merchant — as opposed to any other failure (including an
	 * invalid API key).
	 */
	public function was_already_registered(): bool {
		return '1' === (string) get_option( self::OPTION_ALREADY_REGISTERED, '' );
	}

	/**
	 * Complete registration for the given credential.
	 *
	 * Generates a fresh key pair (so the stored private key always matches the
	 * public key just registered), signs the challenge and calls the backend
	 * completion route. The credential id — needed for runtime signed requests
	 * — is persisted only after the backend confirms.
	 *
	 * @param string $credential_id    API key record id (UUID) from the panel.
	 * @param string $challenge_base64 Challenge issued by startPluginPublicKeyRegistration.
	 *
	 * @return bool True when the backend stored the public key.
	 */
	public function register( string $credential_id, string $challenge_base64 ): bool {
		$credential_id    = trim( $credential_id );
		$challenge_base64 = trim( $challenge_base64 );

		if ( '' === $credential_id || '' === $challenge_base64 ) {
			$this->log( 'register: missing credentialId or challenge' );

			return false;
		}

		// Reject an obviously malformed credential id (e.g. a mis-pasted value in the manual
		// admin flow). The backend binds the key by API key and ignores a body credentialId, so a
		// bad id would be stored locally and 401 every runtime RSA request. apiKey._id is a compact
		// token — no whitespace, sane length. (The 412/401 resilience self-heals a wrong id, but
		// reject the clearly-invalid up front.)
		if ( strlen( $credential_id ) > 128 || 1 === preg_match( '/\s/', $credential_id ) ) {
			$this->log( 'register: credentialId looks malformed (whitespace or too long)' );

			return false;
		}

		// Staging: generate the pair in memory and DO NOT persist it yet, so a
		// failed completion can never overwrite a working key (e.g. during a rotate attempt).
		$pair = $this->signer->generate_key_pair();
		if ( empty( $pair ) ) {
			return false;
		}

		// Prove possession by signing the challenge with the freshly generated (unsaved) key.
		$signature_base64 = $this->signer->sign_challenge_base64_with_key( $challenge_base64, $pair['private_pem'] );
		if ( '' === $signature_base64 ) {
			return false;
		}

		// The backend's `publicKey` field expects raw base64 DER, not PEM armor.
		$public_key_der_b64 = $this->pem_to_base64_der( $pair['public_pem'] );
		if ( '' === $public_key_der_b64 ) {
			$this->log( 'register: failed to convert generated public key to base64 DER' );

			return false;
		}

		// RSA is not active yet (no credential id stored), so this call goes out
		// over the legacy API key path — exactly what the completion route expects.
		//
		// The completion route binds the public key to the API key used to call it and
		// verifies the signature against the challenge stored on that credential. A
		// credentialId in the body would be ignored, so we intentionally send only
		// publicKey + signature. The authoritative credentialId is the one
		// startPluginPublicKeyRegistration returned (persisted below); storing a
		// mismatched id would make every runtime RSA request 401.
		$response = ( new Simplyin_Api_Legacy_Client() )->sendRequest(
			self::COMPLETE_ENDPOINT,
			'POST',
			[
				'publicKey' => $public_key_der_b64,
				'signature' => $signature_base64,
			]
		);

		// The completion route may answer flat ({ ok: true }) or wrapped ({ result: { data: { ok } } })
		// depending on whether it is served via the REST layer or the OpenAPI wrapper. Accept both,
		// exactly like the reference implementation.
		$confirm = $this->unwrap( $response );
		if ( ! is_object( $confirm ) || true !== ( $confirm->ok ?? null ) ) {
			$this->log( 'register: backend did not confirm. Response: ' . wp_json_encode( $response ) );

			return false; // Staging: nothing persisted — any existing working key is untouched.
		}

		// Backend accepted — commit the new pair as the active key, then the credential id.
		$this->signer->store_key_pair( $pair['private_pem'], $pair['public_pem'] );
		$this->signer->set_credential_id( $credential_id );
		// A successful (re-)registration re-enables RSA if it had been disabled.
		$this->signer->clear_disabled();

		return true;
	}

	/**
	 * Ask the backend to revoke the public key currently registered for this credential —
	 * the counterpart to {@see register()}, called (best-effort) right before the caller
	 * clears the local key material, so the backend never keeps an orphaned, still-active
	 * registration for a shop that removed the plugin.
	 *
	 * Deliberately does NOT go through the runtime HTTP client used elsewhere in this class:
	 * that client depends on the full plugin bootstrap (`simplyin()`, `$simplyin_config`),
	 * which WordPress does not load for this call's only real caller — the plugin's
	 * `uninstall.php`, which WordPress executes standalone and resolves the base URL itself
	 * (passed in as `$backend_base_url`). This method just signs with the same
	 * {@see Plugin_Rsa_Signer} used for every other authenticated request, so it works with
	 * or without the bootstrap having run.
	 *
	 * Gates on is_registered(), not is_active(): every other signing call site checks
	 * is_active() (which also requires the kill switch to be off) because THOSE calls are
	 * live runtime traffic that must respect a support-engaged switch-off. This call is a
	 * one-time, terminal cleanup — a shop with the kill switch engaged is exactly the case
	 * where an orphaned registration matters most, so it still attempts the revoke as long
	 * as key material to sign with exists. If signing turns out to be impossible anyway
	 * (e.g. OpenSSL unavailable), build_headers() below returns [] and this still no-ops.
	 *
	 * @param string $backend_base_url Backend API base URL (e.g. from Simplyin_Env::resolve_backend_url()).
	 *
	 * @return bool True when the backend confirmed the revoke.
	 */
	public function revoke( string $backend_base_url ): bool {
		if ( ! $this->signer->is_registered() ) {
			return false;
		}

		$backend_base_url = rtrim( trim( $backend_base_url ), '/' );
		if ( '' === $backend_base_url ) {
			$this->log( 'revoke: no backend base URL resolved' );

			return false;
		}

		$url   = $backend_base_url . '/' . self::REVOKE_ENDPOINT;
		$parts = wp_parse_url( $url );
		$parts = is_array( $parts ) ? $parts : [];
		$path_with_query = ( $parts['path'] ?? '' ) . ( isset( $parts['query'] ) ? '?' . $parts['query'] : '' );

		if ( '' === $path_with_query ) {
			$this->log( 'revoke: could not resolve a request path from the backend base URL' );

			return false;
		}

		// Empty body — the request is identified by the credential id in the signed headers.
		$headers = $this->signer->build_headers( 'POST', $path_with_query, '' );
		if ( empty( $headers ) ) {
			$this->log( 'revoke: could not build RSA signature headers' );

			return false;
		}

		$headers['Content-Type'] = 'application/json';
		$headers['Origin']       = home_url();

		// http_errors => false only suppresses exceptions for non-2xx/3xx HTTP responses — a
		// transport-level failure (host unreachable, timeout, TLS error) still throws. Catch it
		// here so this method's own `bool` contract holds regardless of caller discipline,
		// matching how Simplyin_Api_Legacy_Client::sendRequest() absorbs the same failures.
		try {
			$response = $this->http_client->request(
				'POST',
				$url,
				[
					'headers'     => $headers,
					'http_errors' => false,
				]
			);
		} catch ( \Throwable $e ) {
			$this->log( 'revoke: request failed — ' . $e->getMessage() );

			return false;
		}

		$data = $this->unwrap( json_decode( (string) $response->getBody()->getContents() ) );

		return is_object( $data ) && true === ( $data->ok ?? null );
	}

	/**
	 * Zero-touch migration on plugin upgrade: if the shop has a legacy API key
	 * but no RSA key yet, obtain the challenge over the API-key-authenticated start route
	 * (credentialId is derived server-side from the API key and returned here), then register.
	 * No Merchant Panel step and nothing to paste. Idempotent — a no-op once registered.
	 *
	 * @return bool True when RSA is registered (already, or just now).
	 */
	public function auto_register(): bool {
		$this->set_already_registered_failure( false );

		if ( $this->signer->is_registered() ) {
			return true;
		}
		if ( '' === (string) get_option( 'simplyin_api_key', '' ) ) {
			return false; // nothing to bootstrap from
		}

		// Goes out over the legacy API key path (RSA not active yet) — what the route expects.
		$response = ( new Simplyin_Api_Legacy_Client() )->sendRequest(
			self::START_ENDPOINT,
			'POST',
			[]
		);

		if ( $this->response_indicates_already_registered( $response ) ) {
			// The backend already holds a public key for this merchant (a stale pairing from a
			// previous session) and refuses to issue a fresh challenge. The API key itself is
			// fine — nothing about it should be touched or re-sent. Flip the flag the admin
			// surface reads so the merchant gets the correct guidance (ask an admin to Revoke
			// the key in the Merchant Panel, then Save again) instead of a generic failure that
			// would send them chasing a perfectly valid API key.
			$this->log( 'auto_register: backend reports an RSA public key already exists for this merchant. Response: ' . wp_json_encode( $response ) );
			$this->set_already_registered_failure( true );

			return false;
		}

		// Accept both the flat and the { result: { data } } wrapped shapes (see unwrap()).
		$data          = $this->unwrap( $response );
		$challenge     = is_object( $data ) ? trim( (string) ( $data->challengeBase64 ?? '' ) ) : '';
		$credential_id = is_object( $data ) ? trim( (string) ( $data->credentialId ?? '' ) ) : '';

		if ( '' === $challenge || '' === $credential_id ) {
			$this->log( 'auto_register: start did not return credentialId/challenge. Response: ' . wp_json_encode( $response ) );

			return false;
		}

		return $this->register( $credential_id, $challenge );
	}

	/**
	 * Convert a PEM-armored public key (SPKI, `-----BEGIN PUBLIC KEY-----` armor) to the
	 * raw base64 DER the backend's `publicKey` field expects. PEM is exactly base64(DER)
	 * wrapped in an armor header/footer with line breaks, so stripping both recovers it —
	 * no openssl round-trip required.
	 *
	 * @param string $public_key_pem PEM-encoded public key.
	 *
	 * @return string Base64 DER, or '' if the armor could not be stripped cleanly.
	 */
	public function pem_to_base64_der( string $public_key_pem ): string {
		$stripped = preg_replace( '/-----[^-]+-----|\s+/', '', $public_key_pem );

		// A non-empty result that still contains armor dashes means the regex didn't strip
		// cleanly (unexpected input shape) — better to fail registration than send a payload
		// that PEM armor could reintroduce (the very thing this migration removes from the wire).
		if ( null === $stripped || '' === $stripped || false !== strpos( $stripped, '-----' ) ) {
			return '';
		}

		// Strict decode confirms the stripped text really is base64 (not just armor-free) before
		// it is trusted as the value sent for `publicKey` — malformed input yields '', same as above.
		if ( false === base64_decode( $stripped, true ) ) {
			return '';
		}

		return $stripped;
	}

	/**
	 * Whether a decoded backend response indicates the merchant already has a registered RSA
	 * public key, as opposed to any other failure (an invalid API key, a network error, etc.).
	 * Matched by message substring, case-insensitively — the backend's exact wording is not a
	 * frozen contract, but it always contains this phrase for this specific rejection. Matching
	 * on the status code is not an option here: the start route answers this rejection with the
	 * same generic failure status as any other bad request.
	 *
	 * @param mixed $response json_decode()'d response (stdClass) or null.
	 *
	 * @return bool
	 */
	public function response_indicates_already_registered( $response ): bool {
		$message = $this->extract_message( $response );

		return '' !== $message && false !== stripos( $message, 'already exists' );
	}

	/**
	 * Pull the `message` field out of a decoded backend response, on the same unwrapped
	 * (flat or { result: { data } }) shape as challengeBase64/credentialId are read from
	 * elsewhere in this class.
	 *
	 * @param mixed $response json_decode()'d response (stdClass) or null.
	 *
	 * @return string
	 */
	private function extract_message( $response ): string {
		$data = $this->unwrap( $response );

		return is_object( $data ) && isset( $data->message ) && is_string( $data->message ) ? $data->message : '';
	}

	/**
	 * Persist (or clear) the already-registered failure flag read by {@see was_already_registered()}.
	 */
	private function set_already_registered_failure( bool $already_registered ): void {
		if ( $already_registered ) {
			update_option( self::OPTION_ALREADY_REGISTERED, '1', false );
		} else {
			delete_option( self::OPTION_ALREADY_REGISTERED );
		}
	}

	/**
	 * Normalize a decoded backend response to the object that actually carries the fields.
	 * REST endpoints answer flat (e.g. { credentialId, challengeBase64 } / { ok:true }); the same
	 * routes served through the OpenAPI wrapper nest the payload under { result: { data: {...} } }.
	 * Return the inner data object when present, otherwise the response itself (1:1 with the JS
	 * reference: `$body['result']['data'] ?? $body`).
	 *
	 * @param mixed $response json_decode()'d response (stdClass) or null.
	 *
	 * @return object|null
	 */
	private function unwrap( $response ) {
		if ( ! is_object( $response ) ) {
			return null;
		}
		if ( isset( $response->result ) && is_object( $response->result )
			&& isset( $response->result->data ) && is_object( $response->result->data ) ) {
			return $response->result->data;
		}

		return $response;
	}

	/**
	 * Best-effort debug log. Swallows a missing plugin bootstrap (`simplyin()`) rather than
	 * throwing: {@see revoke()} is called from `uninstall.php`, which WordPress executes
	 * standalone without the plugin's main file, so `simplyin()` may not be defined there.
	 */
	private function log( string $message ): void {
		try {
			simplyin()
				->get_simplyin_logger()
				->log_if_wpdebug_on( '[Plugin_Rsa_Registrar] ' . $message, 'api-call' );
		} catch ( \Throwable $e ) {
			// No logger available outside normal plugin execution — nothing to do.
		}
	}
}
