<?php

declare(strict_types=1);

namespace Numinix\SeekmodoWordPress\Admin;

use Numinix\SeekmodoWordPress\Config\Options;
use Numinix\SeekmodoWordPress\Connect\Pairing;
use Numinix\SeekmodoWordPress\Rest\Routes;

/**
 * install_token round-trip with `https://seekmodo.com/connect`.
 *
 * Mirrors the Zen Cart connector's pairing protocol
 * ({@see \Numinix\Seekmodo\Pairing} in zc_plugins/Seekmodo/v1.0.18)
 * because that's the protocol seekmodo.com's `/connect` page +
 * `/api/connect/complete` route actually implement. v0.3.0 and
 * earlier WP releases shipped a `nonce`/`return_to` browser-redirect
 * flow that was an aspirational design — seekmodo.com never
 * implemented the redirect-back side, so every "Connect" click ended
 * up dropping the operator on a "This link is incomplete" error
 * page. v0.3.1 fixed the wp_safe_redirect host-allowlist drop, but
 * the underlying protocol mismatch remained. v0.3.2 ports the WP
 * connector onto the canonical install_token + POST-JWT flow.
 *
 * Flow:
 *
 *   1. Operator clicks "Connect to Seekmodo" in WP admin.
 *   2. {@see self::initiate()} mints a one-shot 32-hex install_token,
 *      stores it (with TTL) in wp_options, and 302s the operator's
 *      browser to:
 *        seekmodo.com/connect?install_token=<t>&callback=<rest-url>
 *      where <rest-url> is the public REST endpoint registered at
 *      `wp-json/seekmodo/v1/connect/callback`.
 *   3. The operator authenticates on seekmodo.com (existing session)
 *      and confirms which tenant to pair this install with.
 *   4. seekmodo.com mints an Ed25519-signed JWT (claims include
 *      `sub` = tenant_id, `shared_secret`, `mcp_url`, `install_token`,
 *      `iss=https://seekmodo.com`, `aud=seekmodo-plugin`) and POSTs
 *      `{token, install_token}` to <rest-url> with the
 *      `X-Seekmodo-Install-Token: <t>` header.
 *   5. {@see self::handleCallback()} verifies the install_token
 *      header against the stored value, verifies the JWT signature
 *      against /.well-known/jwks.json, cross-checks the claims, and
 *      on success calls {@see Options::persistPair()} + writes the
 *      mcp_url. Returns `{ok: true, platform: "wordpress",
 *      tenant_id: ...}` so the marketing site can stamp
 *      `tenants.connector_platform = 'wordpress'`.
 *   6. The browser, still on seekmodo.com, sees the success
 *      response and shows "Pairing complete — you can close this
 *      tab and head back to your CMS".
 *
 * The CLI flow ({@see \Numinix\SeekmodoWordPress\Cli\Commands::connect()})
 * accepts `--tenant=` / `--secret=` directly — used for headless / CI
 * installs that pre-provision credentials. The legacy
 * `--token=<claim_token>` CLI option was removed in v0.3.2 because
 * it relied on a `/v1/connect/verify` round-trip the gateway never
 * implemented.
 *
 * Security notes:
 *
 *   - The install_token is stored as a wp_option (not a transient)
 *     so it survives the WP object cache being flushed between the
 *     redirect and the callback POST. It's burned (cleared)
 *     immediately on successful pair to prevent replay.
 *   - The callback endpoint is `permission_callback => __return_true`
 *     because seekmodo.com is an unauthenticated server — the JWT
 *     signature + install_token header are the auth.
 *   - The JWT verifier requires ext-sodium; absent that, the verify
 *     surfaces a cPanel-specific install hint instead of a generic
 *     error.
 */
final class ConnectFlow
{
    private const TOKEN_TTL_SECONDS = 600; // 10 min — matches seekmodo.com claim TTL.

    public static function register(): void
    {
        if (!function_exists('add_action')) {
            return;
        }
        add_action('admin_post_seekmodo_wp_connect', [self::class, 'initiate']);
        add_action('rest_api_init', [self::class, 'registerCallback']);
    }

    public static function initiate(): void
    {
        if (!current_user_can('manage_options')) {
            wp_die(esc_html__('Insufficient permissions.', 'seekmodo'), '', ['response' => 403]);
        }
        if (!function_exists('check_admin_referer')) {
            return;
        }
        check_admin_referer('seekmodo_wp_connect');

        // Mint and persist a fresh install_token. wp_option (not
        // transient) so the value survives an object-cache flush
        // between the redirect and the callback POST. Stored with
        // autoload=false to keep it out of the hot
        // wp_load_alloptions() path.
        $token = bin2hex(random_bytes(16));
        $exp   = time() + self::TOKEN_TTL_SECONDS;
        update_option(Options::OPT_INSTALL_TOKEN, $token, false);
        update_option(Options::OPT_INSTALL_TOKEN_EXP, (string) $exp, false);

        $callback = function_exists('rest_url')
            ? rest_url(Routes::NAMESPACE_NAME . '/connect/callback')
            : home_url('/wp-json/' . Routes::NAMESPACE_NAME . '/connect/callback');

        $base = self::connectBaseUrl();
        $location = add_query_arg(
            [
                'install_token' => $token,
                'callback'      => $callback,
                // platform + plugin_version are informational only;
                // the marketing-site /connect page ignores unknown
                // query params. Useful debugging breadcrumbs when
                // a merchant reports a stuck pair.
                'platform'       => 'wordpress',
                'plugin_version' => SEEKMODO_WP_VERSION,
            ],
            $base . '/connect'
        );

        // wp_safe_redirect() validates the destination host against
        // allowed_redirect_hosts and silently rewrites unknown hosts
        // back to the site root. Whitelist the connect host
        // (seekmodo.com by default; overridable via
        // `seekmodo_connect_base_url`) for THIS request only.
        $connect_host = (string) wp_parse_url($base, PHP_URL_HOST);
        $allow_filter = static function (array $hosts) use ($connect_host): array {
            if ($connect_host !== '' && !in_array($connect_host, $hosts, true)) {
                $hosts[] = $connect_host;
            }
            return $hosts;
        };
        if (function_exists('add_filter')) {
            add_filter('allowed_redirect_hosts', $allow_filter);
        }
        wp_safe_redirect($location, 302);
        if (function_exists('remove_filter')) {
            remove_filter('allowed_redirect_hosts', $allow_filter);
        }
        exit;
    }

    /**
     * Public REST endpoint hit by seekmodo.com after the operator
     * confirms the pairing. Body shape: `{token: <jwt>, install_token}`.
     * Header: `X-Seekmodo-Install-Token: <t>`.
     */
    public static function registerCallback(): void
    {
        if (!function_exists('register_rest_route')) {
            return;
        }
        register_rest_route(Routes::NAMESPACE_NAME, '/connect/callback', [
            'methods'             => 'POST',
            'callback'            => [self::class, 'handleCallback'],
            // No auth -- seekmodo.com is an unauthenticated server.
            // The JWT signature + install_token header are the auth.
            'permission_callback' => '__return_true',
        ]);
    }

    /**
     * @param mixed $request \WP_REST_Request
     * @return mixed \WP_Error|array<string,mixed>
     */
    public static function handleCallback($request)
    {
        if (!is_object($request) || !method_exists($request, 'get_header')) {
            return new \WP_Error('bad_request', 'invalid request', ['status' => 400]);
        }

        // 1. Validate install_token header against persisted value.
        $installHeader = (string) $request->get_header('x-seekmodo-install-token');
        if ($installHeader === '' || !preg_match('~^[a-f0-9]{16,64}$~', $installHeader)) {
            return new \WP_Error('install_header_missing', 'missing or malformed install token header', ['status' => 400]);
        }
        $stored = (string) get_option(Options::OPT_INSTALL_TOKEN, '');
        $exp    = (int) get_option(Options::OPT_INSTALL_TOKEN_EXP, '0');
        if ($stored === '' || !hash_equals($stored, $installHeader)) {
            return new \WP_Error('install_token_mismatch', 'install token does not match this site', ['status' => 400]);
        }
        if ($exp > 0 && $exp < time()) {
            return new \WP_Error('install_token_expired', 'install token expired; click Connect again from WP admin', ['status' => 400]);
        }

        // 2. Parse + verify the JWT.
        $body = $request->get_json_params();
        if (!is_array($body) || empty($body['token']) || !is_string($body['token'])) {
            return new \WP_Error('missing_token', 'body missing `token`', ['status' => 400]);
        }
        try {
            $claims = Pairing::verifyJwt($body['token'], self::jwksUrl());
        } catch (\Throwable $e) {
            return new \WP_Error('verify_failed', $e->getMessage(), ['status' => 400]);
        }

        // 3. Cross-check claims.
        if (($claims['install_token'] ?? '') !== $installHeader) {
            return new \WP_Error('install_claim_mismatch', 'install_token claim mismatch', ['status' => 400]);
        }
        $now = time();
        if ((int) ($claims['exp'] ?? 0) < $now) {
            return new \WP_Error('jwt_expired', 'jwt expired', ['status' => 400]);
        }
        if (($claims['iss'] ?? '') !== 'https://seekmodo.com') {
            return new \WP_Error('jwt_iss', 'jwt has wrong iss', ['status' => 400]);
        }
        if (($claims['aud'] ?? '') !== 'seekmodo-plugin') {
            return new \WP_Error('jwt_aud', 'jwt has wrong aud', ['status' => 400]);
        }
        if (empty($claims['sub']) || empty($claims['shared_secret']) || empty($claims['mcp_url'])) {
            return new \WP_Error('missing_claims', 'jwt missing required claims', ['status' => 400]);
        }

        // 4. Persist credentials + gateway URL.
        Options::persistPair((string) $claims['sub'], (string) $claims['shared_secret']);
        update_option(Options::OPT_GATEWAY_URL, esc_url_raw((string) $claims['mcp_url']));

        // 5. Burn the install_token so a replay can't reuse it.
        update_option(Options::OPT_INSTALL_TOKEN, '', false);
        update_option(Options::OPT_INSTALL_TOKEN_EXP, '0', false);

        // Initial indexing is intentionally NOT kicked here.
        // - Content vertical: the existing 15-min seekmodo_wp_index_tick
        //   cron picks up dirty posts; a fresh pair has none yet, so
        //   `wp seekmodo index --full` is the right path for a full
        //   crawl on large sites (operator decides timing).
        // - Commerce vertical: the next remote-config snapshot pull
        //   detects schema-version drift and auto-promotes a one-shot
        //   full reindex.
        // The runbook documents the post-pair `wp seekmodo index --full`
        // step.

        return [
            'ok'        => true,
            'platform'  => 'wordpress',
            'tenant_id' => (string) $claims['sub'],
        ];
    }

    private static function connectBaseUrl(): string
    {
        $base = apply_filters('seekmodo_connect_base_url', 'https://seekmodo.com');
        return rtrim(is_string($base) ? $base : 'https://seekmodo.com', '/');
    }

    private static function jwksUrl(): string
    {
        // The JWKS lives on the marketing-site root, not on mcp.*.
        // Strip any /v1 suffix in case the filter mistakenly returned
        // a gateway URL.
        $base = self::connectBaseUrl();
        $base = preg_replace('~/v1.*$~', '', $base) ?? $base;
        return rtrim($base, '/') . '/.well-known/jwks.json';
    }
}
