<?php
namespace Pay4All\Pro\License;

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

/**
 * HTTP client for the Pay4All SaaS REST API.
 *
 * Every authenticated call signs its body with `installation_id` +
 * `installation_secret` — the server validates against its per-install
 * hash. No local HMAC : the plaintext secret is transmitted over TLS.
 * The update-check endpoint is unauthenticated so security fixes stay
 * reachable even for expired subscriptions.
 */
final class ApiClient {

	/**
	 * GET /api/v1/plugins/{update_product_code}/update-check — public.
	 */
	public function update_check() : array {
		return $this->get_json( '/plugins/' . Constants::UPDATE_PRODUCT_CODE . '/update-check' );
	}

	/**
	 * POST /api/v1/installations/register — first-time handshake. Server
	 * returns the `installation_secret` used to sign every later call.
	 */
	public function register( string $normalized_domain ) : array {
		global $wp_version;
		$wc_version = defined( 'WC_VERSION' ) ? WC_VERSION : null;
		return $this->post_json( '/installations/register', [
			'product_code'      => Constants::PRODUCT_CODE,
			'installation_id'   => Store::installation_uuid(),
			'normalized_domain' => $normalized_domain,
			'plugin_version'    => self::plugin_version(),
			'wp_version'        => $wp_version ?? null,
			'wc_version'        => $wc_version,
			'php_version'       => PHP_VERSION,
		] );
	}

	/**
	 * POST /api/v1/domains/activation-token — signed. Returns a
	 * `activation_url` the plugin redirects the merchant to.
	 */
	public function activation_token( string $normalized_domain ) : array {
		return $this->post_json( '/domains/activation-token', $this->signed_body( [
			'normalized_domain' => $normalized_domain,
		] ) );
	}

	/**
	 * POST /api/v1/domains/check — signed. Called by the 48 h cron.
	 * Response `data.status` drives the local Store cache.
	 *
	 * `admin_email` + `site_locale` are transmitted so pay4all.net can
	 * send the merchant a localised trial-ending reminder BEFORE they
	 * create a pay4all.net account (see domain_trials.admin_email /
	 * .site_locale on the SaaS side).
	 */
	public function check( string $normalized_domain ) : array {
		return $this->post_json( '/domains/check', $this->signed_body( [
			'normalized_domain' => $normalized_domain,
			'plugin_version'    => self::plugin_version(),
			'admin_email'       => (string) get_option( 'admin_email', '' ),
			'site_locale'       => (string) ( function_exists( 'determine_locale' ) ? determine_locale() : get_locale() ),
		] ) );
	}

	/**
	 * POST /api/v1/domains/cancel-subscription — signed. Schedules a
	 * Stripe cancellation at period end. The merchant keeps their
	 * access until the current invoice runs out.
	 */
	public function cancel_subscription( string $normalized_domain ) : array {
		return $this->post_json( '/domains/cancel-subscription', $this->signed_body( [
			'normalized_domain' => $normalized_domain,
		] ) );
	}

	/**
	 * POST /api/v1/domains/reactivate-subscription — signed. Undoes a
	 * previous « annuler mon abonnement » : Stripe resumes billing on
	 * the next period. Idempotent when already active.
	 */
	public function reactivate_subscription( string $normalized_domain ) : array {
		return $this->post_json( '/domains/reactivate-subscription', $this->signed_body( [
			'normalized_domain' => $normalized_domain,
		] ) );
	}

	private function signed_body( array $extra ) : array {
		$body = array_merge( [
			'product_code'        => Constants::PRODUCT_CODE,
			'installation_id'     => Store::installation_uuid(),
			'installation_secret' => Store::installation_secret(),
			'timestamp'           => time(),
			'nonce'               => bin2hex( random_bytes( 16 ) ),
		], $extra );

		// Compute the HMAC signature over the canonicalised payload
		// (no `signature` field, keys sorted alphabetically, JSON-encoded
		// with the same flags the SaaS uses in ApiSignatureService::canonical).
		// The server now REQUIRES this field — an empty/missing signature
		// returns 401 auth. Anti-replay is still enforced via the nonce
		// + timestamp check, but the signature blocks tampering with any
		// field between the plugin and the SaaS.
		$to_sign = $body;
		ksort( $to_sign );
		$secret = (string) $body['installation_secret'];
		$canonical = (string) wp_json_encode( $to_sign, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES );
		$body['signature'] = hash_hmac( 'sha256', $canonical, $secret );

		return $body;
	}

	private function post_json( string $path, array $body ) : array {
		$res = wp_remote_post( Constants::API_BASE . $path, [
			'timeout' => Constants::HTTP_TIMEOUT_SECS,
			'headers' => [
				'Content-Type' => 'application/json',
				'Accept'       => 'application/json',
				'User-Agent'   => 'Pay4AllPro/' . self::plugin_version(),
			],
			'body'    => wp_json_encode( $body ),
		] );
		return $this->parse( $res );
	}

	private function get_json( string $path ) : array {
		$res = wp_remote_get( Constants::API_BASE . $path, [
			'timeout' => Constants::HTTP_TIMEOUT_SECS,
			'headers' => [
				'Accept'     => 'application/json',
				'User-Agent' => 'Pay4AllPro/' . self::plugin_version(),
			],
		] );
		return $this->parse( $res );
	}

	private function parse( $res ) : array {
		if ( is_wp_error( $res ) ) {
			return [ 'ok' => false, 'status' => 0, 'body' => [], 'error' => $res->get_error_message() ];
		}
		$status = (int) wp_remote_retrieve_response_code( $res );
		$raw    = (string) wp_remote_retrieve_body( $res );
		$body   = json_decode( $raw, true );
		if ( ! is_array( $body ) ) { $body = []; }
		// Non-JSON error bodies (Cloudflare / ModSecurity / hoster HTML pages)
		// are the interesting signal for a 503 or 4xx — strip tags and keep a
		// short excerpt so `Impossible d'enregistrer : HTTP 503` becomes
		// something the merchant can actually diagnose from the Licences page.
		$error = (string) ( $body['error'] ?? '' );
		if ( $error === '' && ! ( $status >= 200 && $status < 300 ) && $raw !== '' ) {
			$excerpt = trim( wp_strip_all_tags( $raw ) );
			$excerpt = preg_replace( '/\s+/', ' ', (string) $excerpt );
			if ( strlen( (string) $excerpt ) > 240 ) { $excerpt = substr( (string) $excerpt, 0, 240 ) . '…'; }
			$error = (string) $excerpt;
		}
		// Verify the response HMAC when the SaaS included one. Skips on
		// pure error payloads (no signature emitted) or when no
		// installation_secret is stored yet (register/first-boot flow).
		// A signature mismatch on a 2xx response is treated as a network
		// error : we ignore the body and refuse to write anything
		// derived from it into the local Store (see M1 audit finding).
		$ok = $status >= 200 && $status < 300;
		if ( $ok && isset( $body['signature'] ) ) {
			if ( ! self::verify_response_signature( $body ) ) {
				return [
					'ok'     => false,
					'status' => $status,
					'body'   => [],
					'error'  => 'signature-mismatch',
				];
			}
		}
		return [
			'ok'     => $ok,
			'status' => $status,
			'body'   => $body,
			'error'  => $error,
		];
	}

	/**
	 * Verifies the HMAC-SHA256 signature the SaaS attaches to every
	 * authenticated response. The signature key is this installation's
	 * own `installation_secret` (server calls signResponseForInstallation
	 * with the same secret), so a valid signature proves both integrity
	 * AND that the response was really destined for this install (no
	 * cross-installation replay). Returns true when nothing to verify
	 * (missing signature or missing secret) — the parse() caller only
	 * invokes this after confirming a signature is present.
	 */
	private static function verify_response_signature( array $body ) : bool {
		if ( ! isset( $body['signature'] ) || ! is_string( $body['signature'] ) ) {
			return false;
		}
		$secret = Store::installation_secret();
		if ( $secret === '' ) {
			// No secret yet ⇒ we can't verify. First-boot flows
			// (installations/register) hit this branch ; treat as pass
			// so the plugin can still bootstrap.
			return true;
		}
		$provided = (string) $body['signature'];
		$to_sign  = $body;
		unset( $to_sign['signature'] );
		ksort( $to_sign );
		$canonical = (string) wp_json_encode( $to_sign, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES );
		$expected  = hash_hmac( 'sha256', $canonical, $secret );
		return hash_equals( $expected, $provided );
	}

	private static function plugin_version() : string {
		$headers = get_file_data( P4ALL_PRO_FILE, [ 'Version' => 'Version' ] );
		return (string) ( $headers['Version'] ?? '0.0.0' );
	}
}
