<?php

declare(strict_types=1);

namespace SiteFort\Modules\Cloud\License;

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

use SiteFort\Modules\Cloud\SecureApi;
use SiteFort\Modules\Cloud\Service\CloudSettings;
use SiteFort\Modules\Cloud\Service\KeyBackupService;
use SiteFort\Modules\Cloud\Services\StatePush;
use SiteFort\Modules\WhiteLabel\WhiteLabel;
use SiteFort\Includes\SiteFortKeyManager;

class LicenseManager {

    private $settings;
    private $api;
    private ?string $last_runtime_error_message = null;
    private const ENTITLEMENT_ALLOWLIST = [
        'scan.enabled',
        'scan.on_demand.enabled',
        'scan.scheduled.enabled',
        'scan.monthly_credit',
        'repair.enabled',
    ];

    public function __construct( array $settings ) {
        $this->settings = $settings;
        $license_key = sanitize_text_field( (string) ( $this->settings['license_key'] ?? '' ) );
        if ( $license_key === '' ) {
            $license_key = $this->get_last_license_key();
            if ( $license_key !== '' ) {
                $this->settings['license_key'] = $license_key;
                CloudSettings::update_stored_fields( [ 'license_key' => $license_key ] );
            }
        } else {
            $this->settings['license_key'] = $license_key;
        }
        $this->persist_last_license_key( $license_key );

        $this->api = new SecureApi( 
            $this->settings['license_key'], 
            $this->settings['site_token'] ?? '',
            $this->settings['site_secret'] ?? ''
        );
    }

    /**
     * Read the fallback license key stored in state settings.
     */
    private function get_last_license_key(): string {
        if ( ! class_exists( '\\SiteFort\\Includes\\SettingsStore' ) ) {
            return '';
        }
        return sanitize_text_field(
            (string) \SiteFort\Includes\SettingsStore::get_field(
                \SiteFort\Includes\SettingsStore::COL_STATE,
                'last_license_key',
                ''
            )
        );
    }

    /**
     * Persist fallback license key for recovery paths.
     */
    private function persist_last_license_key( string $license_key ): void {
        $license_key = sanitize_text_field( $license_key );
        if ( $license_key === '' || ! class_exists( '\\SiteFort\\Includes\\SettingsStore' ) ) {
            return;
        }
        if ( $this->get_last_license_key() === $license_key ) {
            return;
        }
        \SiteFort\Includes\SettingsStore::set_field(
            \SiteFort\Includes\SettingsStore::COL_STATE,
            'last_license_key',
            $license_key
        );
    }

    /**
     * Resolve current license key using API response first, then local fallback.
     */
    private function resolve_license_key( array $response = [] ): string {
        $license_key = sanitize_text_field( (string) ( $this->settings['license_key'] ?? '' ) );
        if ( ! empty( $response['license_key'] ) && is_string( $response['license_key'] ) ) {
            $license_key = sanitize_text_field( $response['license_key'] );
        }
        if ( $license_key === '' ) {
            $license_key = $this->get_last_license_key();
        }
        return $license_key;
    }

    /**
     * Preserve a detailed runtime failure message outside exception transport.
     */
    private function set_runtime_error_message( string $message ): void {
        $message = sanitize_text_field( $message );
        $this->last_runtime_error_message = $message !== '' ? $message : null;
    }

    /**
     * Recover the most specific runtime message for API/UI error propagation.
     */
    private function consume_runtime_error_message( \Throwable $e ): string {
        $message = $this->last_runtime_error_message;
        $this->last_runtime_error_message = null;

        if ( is_string( $message ) && $message !== '' ) {
            return $message;
        }

        return sanitize_text_field( $e->getMessage() );
    }

    /**
     * Fetch a full license snapshot using freshly-issued site credentials.
     *
     * Activation responses can be minimal. A follow-up license/check call lets
     * the plugin hydrate plan, entitlements, and scan credits immediately.
     *
     * @return array<string,mixed>
     */
    private function fetch_license_snapshot_from_check( string $site_token, string $site_secret ): array {
        $site_token = sanitize_text_field( $site_token );
        $site_secret = sanitize_text_field( $site_secret );
        if ( $site_token === '' || $site_secret === '' ) {
            return [];
        }

        $check_api = new SecureApi( '', $site_token, $site_secret );
        $check = $check_api->get(
            'services/license/check',
            [ 'preserve_plan' => '1' ]
        );
        if ( is_wp_error( $check ) || ! is_array( $check ) ) {
            return [];
        }

        return $check;
    }

    public function is_active(): bool {
        if ( $this->settings['license_status'] !== 'active' ) {
            return false;
        }
        if ( empty( $this->settings['site_token'] ) || empty( $this->settings['site_secret'] ) ) {
            return false;
        }
        // Check license expiration if set.
        if ( ! empty( $this->settings['license_expires_at'] ) ) {
            if ( (int) $this->settings['license_expires_at'] < time() ) {
                return false;
            }
        }
        return true;
    }

    public function get_plan(): string {
        return $this->settings['license_plan'];
    }

    private function get_site_payload( string $email = '' ): array {
        $settings = CloudSettings::get_all();
        $site_url = $this->canonical_site_url();
        $payload = [
            'site_url'  => $site_url,
            'site_uuid' => (string) ( $settings['site_uuid'] ?? '' ),
            'rest_url'  => (string) ( $settings['rest_url'] ?? '' ),
            'site_name' => (string) get_option( 'blogname', '' ),
        ];
        if ( $email !== '' ) {
            $payload['email'] = sanitize_email( $email );
        }
        return $payload;
    }

    /**
     * Normalize a site URL to match Console canonicalization.
     *
     * @param string $raw
     * @return string
     */
    private function normalize_site_url( string $raw ): string {
        $parts = wp_parse_url( $raw );
        if ( ! is_array( $parts ) ) {
            return strtolower( trim( $raw ) );
        }
        $scheme = strtolower( (string) ( $parts['scheme'] ?? '' ) );
        $host = strtolower( (string) ( $parts['host'] ?? '' ) );
        $port = isset( $parts['port'] ) ? (int) $parts['port'] : 0;
        $path = (string) ( $parts['path'] ?? '' );
        $path = rtrim( $path, '/' );
        $netloc = $host;
        if ( $port > 0 && $port !== 80 && $port !== 443 ) {
            $netloc .= ':' . $port;
        }
        if ( $scheme === '' || $host === '' ) {
            return strtolower( trim( $raw ) );
        }
        return strtolower( $scheme . '://' . $netloc . $path );
    }

    // Canonical site URL for the cloud record. home_url() can report http behind a proxy or in
    // cron even when the site runs on TLS, so upgrade to https when the site is served over TLS.
    private function canonical_site_url(): string {
        $url = $this->normalize_site_url( (string) home_url() );
        if ( strpos( $url, 'http://' ) === 0 && $this->site_is_https() ) {
            return 'https://' . substr( $url, 7 );
        }
        return $url;
    }

    // Non-spoofable signals that the site runs on TLS: WordPress's own is_ssl(), an https Site
    // Address in the database, or forced-SSL admin. A proxy site should make WordPress SSL-aware
    // (the standard wp-config snippet), which flips is_ssl() and fixes WordPress's own URLs too.
    private function site_is_https(): bool {
        return is_ssl()
            || strpos( (string) get_option( 'siteurl' ), 'https://' ) === 0
            || ( defined( 'FORCE_SSL_ADMIN' ) && FORCE_SSL_ADMIN );
    }

    /**
     * Canonical site identity (host + base path).
     *
     * @return array{host:string,path:string}
     */
    private function get_site_identity(): array {
        $site_url = $this->normalize_site_url( (string) home_url() );
        $parts = wp_parse_url( $site_url );
        $host = is_array( $parts ) ? strtolower( (string) ( $parts['host'] ?? '' ) ) : '';
        $path = is_array( $parts ) ? (string) ( $parts['path'] ?? '' ) : '';
        $path = rtrim( $path, '/' );
        if ( $path === '' ) {
            $path = '/';
        }
        if ( $path[0] !== '/' ) {
            $path = '/' . $path;
        }
        return [ 'host' => $host, 'path' => $path ];
    }

    private function get_site_public_key_b64(): string {
        $public = SiteFortKeyManager::get_site_public_key_b64();
        return is_string( $public ) ? sanitize_text_field( $public ) : '';
    }

    private function get_site_public_key_fingerprint( string $public_key_b64 ): string {
        $public_key_b64 = trim( $public_key_b64 );
        if ( $public_key_b64 === '' ) {
            return '';
        }
        $padded = strtr( $public_key_b64, '-_', '+/' );
        $pad_len = strlen( $padded ) % 4;
        if ( $pad_len > 0 ) {
            $padded .= str_repeat( '=', 4 - $pad_len );
        }
        $raw = base64_decode( $padded, true );
        if ( ! is_string( $raw ) || strlen( $raw ) !== 32 ) {
            return '';
        }
        return hash( 'sha256', $raw );
    }

    /**
     * Build a site-key proof used by Console to verify the request originates from this website.
     *
     * @param string      $purpose Purpose string expected by Console.
     * @param string|null $email Lowercased email (optional).
     * @param string|null $otp OTP code (optional).
     * @param string|null $activation_session_token Activation session token (optional).
     * @return array{site_key_timestamp:int,site_key_nonce:string,site_key_request_id:string,site_key_signature:string}
     */
    private function build_site_key_proof( string $purpose, ?string $email, ?string $otp, ?string $activation_session_token ): array {
        // Pre-check: surface a clear, actionable error if MasterKey is broken.
        try {
            \SiteFort\Includes\MasterKey::get();
        } catch ( \RuntimeException $e ) {
            $err  = \SiteFort\Includes\MasterKey::get_last_error();
            $code = $err['code'] ?? 'key_error';
            $msgs = [
                'key_corrupt'          => 'Your encryption key file is corrupt. Go to SiteFort Settings to restore or regenerate the key before connecting.',
                'key_unreadable'       => 'Your encryption key file cannot be read. Check file permissions and try again.',
                'key_write_failed'     => 'Cannot create the encryption key file. Check directory write permissions.',
                'key_constant_invalid' => 'The SITEFORT_MASTER_KEY constant in wp-config.php is invalid.',
            ];
            // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message is internal control flow, not rendered HTML output.
            throw new \RuntimeException( $msgs[ $code ] ?? 'Encryption key issue. Go to SiteFort Settings to resolve.' );
        }

        $id = $this->get_site_identity();
        $ts = time();
        try {
            $nonce = bin2hex( random_bytes( 16 ) );
        } catch ( \Throwable $e ) {
            $nonce = wp_generate_password( 32, false );
        }
        $request_id = function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : md5( $ts . '|' . $nonce );

        $msg = implode(
            "\n",
            [
                'sitefort-site-key-v1',
                $purpose,
                (string) $id['host'],
                (string) $id['path'],
                (string) ( $email ?? '' ),
                (string) ( $otp ?? '' ),
                (string) ( $activation_session_token ?? '' ),
                (string) $ts,
                (string) $nonce,
                (string) $request_id,
            ]
        );

        $sig = SiteFortKeyManager::sign_site_message( $msg );
        if ( $sig === '' ) {
            throw new \RuntimeException( 'Site key is unavailable on this server.' );
        }

        return [
            'site_key_timestamp' => (int) $ts,
            'site_key_nonce' => (string) $nonce,
            'site_key_request_id' => (string) $request_id,
            'site_key_signature' => (string) $sig,
        ];
    }

    private function clear_local_activation( bool $preserve_migration_context = false ): void {
        // Preserve license_key for key-backup recovery (survives deactivation).
        $lk = sanitize_text_field( (string) ( $this->settings['license_key'] ?? '' ) );
        $this->persist_last_license_key( $lk );

        $this->settings['license_key'] = '';
        $this->settings['site_token'] = '';
        $this->settings['site_secret'] = '';
        $this->settings['license_status'] = 'inactive';
        $this->settings['license_plan'] = 'free';
        $this->settings['license_expires_at'] = 0;
        $this->settings['entitlements'] = [];
        $this->settings['entitlements_checked_at'] = 0;
        $this->settings['linked_record_url'] = '';
        $this->settings['scan_credits_remaining'] = -1;
        $this->settings['scan_credits_total'] = -1;
        $this->settings['scan_credit_reset_at'] = null;
        $this->settings['scan_credit_limit_reached'] = false;
        $this->settings['connectivity'] = 'public';
        $this->settings['public_identity_verified'] = true;
        $this->settings['reachability_status'] = 'unknown';
        $this->settings['reachability_error_code'] = '';
        $this->settings['activation_channel'] = 'public_pull';
        $this->reset_migration_state( ! $preserve_migration_context );
        if ( CloudSettings::save( $this->settings ) ) {
            WhiteLabel::sync_for_license_state( $this->settings );
            // Disconnecting drops to the free entitlement set, so suspend anything paid that is
            // still running (scheduled scans in particular). No notice: the admin disconnected
            // deliberately, so "paid features paused, renew to restore" would be misleading.
            \SiteFort\Modules\Cloud\Service\Entitlements::reconcile( false );
        }
        $this->api = new SecureApi( '', '', '' );
    }

    private function reset_migration_state( bool $clear_context = true ): void {
        $this->settings['migration_replace_allowed'] = true;
        $this->settings['migration_replace_message'] = '';
        if ( $clear_context ) {
            $this->settings['migration_status'] = 'none';
            $this->settings['migration_pending_url'] = '';
        }
    }

    private function hydrate_migration_state_from_response( array $response ): void {
        if ( isset( $response['migration_status'] ) ) {
            $this->settings['migration_status'] = (string) $response['migration_status'];
        }
        if ( isset( $response['migration_pending_url'] ) ) {
            $this->settings['migration_pending_url'] = (string) $response['migration_pending_url'];
        } elseif ( (string) ( $response['migration_status'] ?? '' ) === 'none' ) {
            $this->settings['migration_pending_url'] = '';
        }
        if ( array_key_exists( 'migration_replace_allowed', $response ) ) {
            $this->settings['migration_replace_allowed'] = (bool) $response['migration_replace_allowed'];
        } else {
            $this->settings['migration_replace_allowed'] = true;
        }
        if ( isset( $response['migration_replace_message'] ) ) {
            $this->settings['migration_replace_message'] = (string) $response['migration_replace_message'];
        } else {
            $this->settings['migration_replace_message'] = '';
        }
    }

    /**
     * Keep only plugin-consumed entitlement keys.
     *
     * @param array<string,mixed> $entitlements
     * @return array<string,mixed>
     */
    private function filter_plugin_entitlements( array $entitlements ): array {
        $filtered = [];
        foreach ( self::ENTITLEMENT_ALLOWLIST as $key ) {
            if ( array_key_exists( $key, $entitlements ) ) {
                $filtered[ $key ] = $entitlements[ $key ];
            }
        }
        return $filtered;
    }

    /**
     * Determine whether a license-check API error is a definitive credential revoke.
     *
     * Only hard auth failures should trigger local fail-closed deactivation.
     * Transient upstream/network errors must not clear local activation state.
     */
    private function should_fail_closed_for_auth_error( \WP_Error $error ): bool {
        $data = $error->get_error_data();
        if ( ! is_array( $data ) ) {
            return false;
        }

        $status = isset( $data['status'] ) ? (int) $data['status'] : 0;
        if ( $status !== 403 ) {
            return false;
        }

        $hard_codes = [
            'site_token_missing',
            'site_token_invalid',
            'site_inactive',
        ];
        $hard_messages = [
            'missing site token',
            'invalid site token',
            'site is inactive',
        ];

        $codes = [];
        $messages = [];

        $messages[] = (string) $error->get_error_message();

        $collect = static function ( $detail ) use ( &$codes, &$messages ): void {
            if ( is_string( $detail ) ) {
                $messages[] = $detail;
                return;
            }
            if ( ! is_array( $detail ) ) {
                return;
            }
            if ( isset( $detail['error_code'] ) && is_string( $detail['error_code'] ) ) {
                $codes[] = $detail['error_code'];
            }
            if ( isset( $detail['message'] ) && is_string( $detail['message'] ) ) {
                $messages[] = $detail['message'];
            }
        };

        $collect( $data['detail'] ?? null );
        if ( isset( $data['response'] ) && is_array( $data['response'] ) ) {
            $collect( $data['response']['detail'] ?? null );
        }

        foreach ( $codes as $code ) {
            if ( in_array( strtolower( trim( $code ) ), $hard_codes, true ) ) {
                return true;
            }
        }

        foreach ( $messages as $message ) {
            $normalized = strtolower( trim( (string) $message ) );
            if ( $normalized !== '' && in_array( $normalized, $hard_messages, true ) ) {
                return true;
            }
        }

        return false;
    }

    /**
     * Console upgrade page URL used when Pro activation is not available.
     */
    private function get_upgrade_url(): string {
        return 'https://securewp.net/pricing/';
    }

    private function schedule_console_state_sync_if_needed( bool $previous_console_enabled ): void {
        $current_console_enabled = ! empty( $this->settings['console_enabled'] );
        if ( ! $current_console_enabled || $previous_console_enabled ) {
            return;
        }

        StatePush::schedule_full_push();
    }

    /**
     * Apply license/plan metadata returned by Console to local cloud settings.
     *
     * @param array<string,mixed> $response
     */
    private function hydrate_license_metadata_from_response( array $response, ?string $fallback_plan = null, ?string $fallback_status = null ): void {
        if ( $fallback_status !== null ) {
            $this->settings['license_status'] = (string) ( $response['status'] ?? $fallback_status );
        } elseif ( array_key_exists( 'status', $response ) ) {
            $this->settings['license_status'] = (string) $response['status'];
        }

        if ( $fallback_plan !== null ) {
            $this->settings['license_plan'] = (string) ( $response['plan'] ?? $fallback_plan );
        } elseif ( array_key_exists( 'plan', $response ) ) {
            $this->settings['license_plan'] = (string) $response['plan'];
        }

        $this->settings['license_key'] = $this->resolve_license_key( $response );

        if ( isset( $response['site_url'] ) ) {
            $this->settings['linked_record_url'] = (string) $response['site_url'];
        }
        if ( isset( $response['entitlements'] ) && is_array( $response['entitlements'] ) ) {
            $this->settings['entitlements'] = $this->filter_plugin_entitlements( $response['entitlements'] );
            // Console answered, so the cached set is fresh regardless of what changed.
            $this->settings['entitlements_checked_at'] = time();
        }
        if ( array_key_exists( 'expires_at', $response ) ) {
            // A response without an expiry means the grant is no longer time-bounded (for
            // example the site moved to the org's free license). Clear the old date rather
            // than leaving a stale one behind.
            $this->settings['license_expires_at'] = $response['expires_at'] === null
                ? 0
                : absint( $response['expires_at'] );
        }
        if ( isset( $response['console_enabled'] ) ) {
            $this->settings['console_enabled'] = (bool) $response['console_enabled'];
        }
        if ( isset( $response['connectivity'] ) ) {
            $this->settings['connectivity'] = sanitize_text_field( (string) $response['connectivity'] );
        }
        if ( array_key_exists( 'public_identity_verified', $response ) ) {
            $this->settings['public_identity_verified'] = (bool) $response['public_identity_verified'];
        }
        if ( isset( $response['reachability_status'] ) ) {
            $this->settings['reachability_status'] = sanitize_text_field( (string) $response['reachability_status'] );
        }
        if ( isset( $response['reachability_error_code'] ) ) {
            $this->settings['reachability_error_code'] = sanitize_text_field( (string) $response['reachability_error_code'] );
        }
        if ( isset( $response['activation_channel'] ) ) {
            $this->settings['activation_channel'] = sanitize_text_field( (string) $response['activation_channel'] );
        }
        if ( array_key_exists( 'credits_remaining', $response ) ) {
            $this->settings['scan_credits_remaining'] = (int) $response['credits_remaining'];
        }
        if ( array_key_exists( 'credits_total', $response ) ) {
            $this->settings['scan_credits_total'] = (int) $response['credits_total'];
        }
        if ( array_key_exists( 'credit_reset_at', $response ) ) {
            $this->settings['scan_credit_reset_at'] = is_string( $response['credit_reset_at'] ) ? $response['credit_reset_at'] : null;
        }
        if ( array_key_exists( 'credit_limit_reached', $response ) ) {
            $this->settings['scan_credit_limit_reached'] = (bool) $response['credit_limit_reached'];
        }
    }

    private function apply_activation_response( array $response, string $fallback_plan ): void {
        $this->last_runtime_error_message = null;
        $previous_console_enabled = ! empty( $this->settings['console_enabled'] );
        if ( empty( $response['site_token'] ) || empty( $response['site_secret'] ) ) {
            throw new \RuntimeException( 'Failed to obtain site credentials from cloud.' );
        }

        $site_token = (string) $response['site_token'];
        $site_secret = (string) $response['site_secret'];
        $snapshot = $this->fetch_license_snapshot_from_check( $site_token, $site_secret );
        $merged_response = ! empty( $snapshot ) ? array_merge( $response, $snapshot ) : $response;

        $license_key = $this->resolve_license_key( $merged_response );

        $this->settings['license_key'] = $license_key;
        $this->settings['site_token'] = $site_token;
        $this->settings['site_secret'] = $site_secret;
        $this->settings['linked_record_url'] = $this->get_current_site_url();
        $this->hydrate_license_metadata_from_response( $merged_response, $fallback_plan, 'active' );
        $this->reset_migration_state( true );
        $save_ok = CloudSettings::save( $this->settings );
        $this->persist_last_license_key( $license_key );

        if ( ! $save_ok ) {
            $persist_error = CloudSettings::get_last_persist_error();
            $this->set_runtime_error_message(
                'Credentials could not be saved: '
                . ( $persist_error ?: 'Cloud settings could not be persisted to the database.' )
            );
            throw new \RuntimeException( 'Credentials could not be saved.' );
        }

        // Verify the credentials were actually persisted. If encryption silently
        // failed (e.g. key file unreadable on this server), the save guard in
        // prepare_for_storage() would have cleared license_status before writing,
        // making it look like a success when it wasn't.
        $persisted = CloudSettings::get_all();
        if ( empty( $persisted['site_token'] ) || empty( $persisted['site_secret'] ) ) {
            $enc_error = CloudSettings::get_last_encrypt_error();
            if ( $enc_error ) {
                $this->set_runtime_error_message( 'Credentials could not be saved: ' . $enc_error );
                throw new \RuntimeException( 'Credentials could not be saved.' );
            }
            throw new \RuntimeException(
                'Credentials could not be saved. The encryption key may be missing or unreadable. Check the SiteFort Settings page for key status.'
            );
        }
        $this->settings = $persisted;

        // Attempt to send the master key backup to the Console.
        KeyBackupService::send_backup();

        WhiteLabel::sync_for_license_state(
            $this->settings,
            $merged_response['branding'] ?? null,
            array_key_exists( 'branding', $merged_response )
        );

        // A fresh activation can raise entitlements, so converge immediately: an upgrade should
        // restore scheduled scans without waiting for the next cron pass.
        \SiteFort\Modules\Cloud\Service\Entitlements::reconcile();

        $this->schedule_console_state_sync_if_needed( $previous_console_enabled );

        $this->api = new SecureApi(
            (string) ( $this->settings['license_key'] ?? '' ),
            (string) ( $this->settings['site_token'] ?? '' ),
            (string) ( $this->settings['site_secret'] ?? '' )
        );
    }

    public function retry_pro_activation( string $activation_session_token ) {
        $this->api = new SecureApi( '', '', '' );
        $token = sanitize_text_field( $activation_session_token );
        $payload = $this->get_site_payload();
        $payload['activation_session_token'] = $token;
        try {
            $payload['proof'] = $this->build_site_key_proof( 'retry_pro', null, null, $token );
        } catch ( \Throwable $e ) {
            return new \WP_Error( 'site_key_unavailable', $e->getMessage() );
        }

        $response = $this->api->post( 'services/connect/retry', $payload );
        if ( is_wp_error( $response ) ) {
            return $response;
        }

        try {
            $this->apply_activation_response( $response, 'pro' );
        } catch ( \Throwable $e ) {
            return new \WP_Error( 'activation_failed', $this->consume_runtime_error_message( $e ) );
        }
        return $response;
    }

    public function get_current_site_url(): string {
        return $this->canonical_site_url();
    }

    /**
     * Complete an approved replacement by requesting fresh credentials for the new URL.
     *
     * @return true|\WP_Error
     */
    private function complete_migration_replace() {
        $api = new SecureApi( '', '', '' );
        $payload = [
            'site_url' => $this->get_current_site_url(),
            'rest_url' => (string) ( $this->settings['rest_url'] ?? '' ),
            'site_uuid' => (string) ( $this->settings['site_uuid'] ?? '' ),
        ];

        try {
            $payload['proof'] = $this->build_site_key_proof( 'migration_reissue', null, null, null );
        } catch ( \Throwable $e ) {
            return new \WP_Error( 'migration_reissue_unavailable', $e->getMessage() );
        }

        $response = $api->post( 'services/connect/migration-reissue', $payload );
        if ( is_wp_error( $response ) ) {
            return $response;
        }

        try {
            $this->apply_activation_response( $response, (string) ( $response['plan'] ?? ( $this->settings['license_plan'] ?? 'free' ) ) );
        } catch ( \Throwable $e ) {
            return new \WP_Error( 'migration_reissue_failed', $this->consume_runtime_error_message( $e ) );
        }

        return true;
    }

    public function resolve_migration_replace() {
        $payload = $this->get_site_payload();

        try {
            $payload['proof'] = $this->build_site_key_proof( 'migration_resolve_replace', null, null, null );
        } catch ( \Throwable $e ) {
            return new \WP_Error( 'migration_resolve_unavailable', $e->getMessage() );
        }

        $payload['action'] = 'replace';

        $response = $this->api->post( 'services/connect/migration/resolve', $payload );
        if ( is_wp_error( $response ) ) {
            return $response;
        }

        try {
            $this->apply_activation_response( $response, (string) ( $response['plan'] ?? ( $this->settings['license_plan'] ?? 'free' ) ) );
        } catch ( \Throwable $e ) {
            return new \WP_Error( 'migration_replace_failed', $this->consume_runtime_error_message( $e ) );
        }

        return $response;
    }

    public function resolve_migration_connect_new() {
        $payload = $this->get_site_payload();

        try {
            $payload['proof'] = $this->build_site_key_proof( 'migration_resolve_connect_new', null, null, null );
        } catch ( \Throwable $e ) {
            return new \WP_Error( 'migration_resolve_unavailable', $e->getMessage() );
        }

        $payload['action'] = 'connect_new';

        $response = $this->api->post( 'services/connect/migration/resolve', $payload );
        if ( is_wp_error( $response ) ) {
            return $response;
        }

        $this->settings['migration_status'] = (string) ( $response['migration_status'] ?? 'approved_connect_new' );
        $this->settings['migration_pending_url'] = (string) ( $response['migration_pending_url'] ?? $this->get_current_site_url() );
        $this->clear_local_activation( true );

        return $response;
    }

    public function activate_free( string $activation_session_token ) {
        $this->api = new SecureApi( '', '', '' );
        $token = sanitize_text_field( $activation_session_token );
        $payload = $this->get_site_payload();
        $payload['activation_session_token'] = $token;
        try {
            $payload['proof'] = $this->build_site_key_proof( 'activate_free', null, null, $token );
        } catch ( \Throwable $e ) {
            return new \WP_Error( 'site_key_unavailable', $e->getMessage() );
        }

        $response = $this->api->post( 'services/connect/activate-free', $payload );
        if ( is_wp_error( $response ) ) {
            return $response;
        }

        try {
            $this->apply_activation_response( $response, 'free' );
        } catch ( \Throwable $e ) {
            return new \WP_Error( 'activation_failed', $this->consume_runtime_error_message( $e ) );
        }
        return $response;
    }

    public function request_otp( string $email ) {
        $this->api = new SecureApi( '', '', '' );
        $email_norm = sanitize_email( $email );
        if ( $email_norm === '' ) {
            return new \WP_Error( 'email_required', 'Email is required.' );
        }

        $payload = $this->get_site_payload( $email_norm );
        try {
            $payload['proof'] = $this->build_site_key_proof( 'request_otp', strtolower( $email_norm ), null, null );
        } catch ( \Throwable $e ) {
            return new \WP_Error( 'site_key_unavailable', $e->getMessage() );
        }

        return $this->api->post( 'services/connect/request-otp', $payload );
    }

    public function request_license_otp( string $license_key ) {
        $this->api = new SecureApi( '', '', '' );
        $key = trim( $license_key );
        if ( $key === '' ) {
            return new \WP_Error( 'license_key_required', 'License key is required.' );
        }

        $fingerprint = hash( 'sha256', $key );
        $payload = $this->get_site_payload();
        $payload['license_key'] = $key;
        try {
            $payload['proof'] = $this->build_site_key_proof( 'request_license_otp', null, $fingerprint, null );
        } catch ( \Throwable $e ) {
            return new \WP_Error( 'site_key_unavailable', $e->getMessage() );
        }

        return $this->api->post( 'services/connect/request-license-otp', $payload );
    }

    public function request_private_license_otp( string $license_key, string $activation_reason = 'manual' ) {
        $this->api = new SecureApi( '', '', '' );
        $key = trim( $license_key );
        if ( $key === '' ) {
            return new \WP_Error( 'license_key_required', 'License key is required.' );
        }

        $site_key_public = $this->get_site_public_key_b64();
        if ( $site_key_public === '' ) {
            return new \WP_Error( 'site_key_unavailable', 'Site key is unavailable on this server.' );
        }

        $fingerprint = hash( 'sha256', $key );
        $payload = $this->get_site_payload();
        $payload['license_key'] = $key;
        $payload['site_key_public'] = $site_key_public;
        $payload['activation_reason'] = sanitize_key( $activation_reason ) ?: 'manual';
        try {
            $payload['proof'] = $this->build_site_key_proof( 'private_request_license_otp', null, $fingerprint, null );
        } catch ( \Throwable $e ) {
            return new \WP_Error( 'site_key_unavailable', $e->getMessage() );
        }

        return $this->api->post( 'services/connect/private/request-license-otp', $payload );
    }

    public function request_private_otp( string $email, string $activation_reason = 'manual' ) {
        $this->api = new SecureApi( '', '', '' );
        $email_norm = sanitize_email( $email );
        if ( $email_norm === '' ) {
            return new \WP_Error( 'email_required', 'Email is required.' );
        }

        $site_key_public = $this->get_site_public_key_b64();
        if ( $site_key_public === '' ) {
            return new \WP_Error( 'site_key_unavailable', 'Site key is unavailable on this server.' );
        }

        $payload = $this->get_site_payload( $email_norm );
        $payload['site_key_public'] = $site_key_public;
        $payload['activation_reason'] = sanitize_key( $activation_reason ) ?: 'manual';
        try {
            $payload['proof'] = $this->build_site_key_proof( 'private_request_otp', strtolower( $email_norm ), null, null );
        } catch ( \Throwable $e ) {
            return new \WP_Error( 'site_key_unavailable', $e->getMessage() );
        }

        return $this->api->post( 'services/connect/private/request-otp', $payload );
    }

    public function verify_otp( string $email, string $otp ) {
        $this->api = new SecureApi( '', '', '' );
        $email_norm = sanitize_email( $email );
        $otp_norm = sanitize_text_field( $otp );

        $payload = $this->get_site_payload( $email_norm );
        $payload['otp'] = $otp_norm;
        try {
            $payload['proof'] = $this->build_site_key_proof( 'verify_otp', strtolower( $email_norm ), $otp_norm, null );
        } catch ( \Throwable $e ) {
            return new \WP_Error( 'site_key_unavailable', $e->getMessage() );
        }

        $response = $this->api->post( 'services/connect/verify-otp', $payload );
        if ( is_wp_error( $response ) ) {
            return $response;
        }

        try {
            $this->apply_activation_response( $response, 'free' );
        } catch ( \Throwable $e ) {
            return new \WP_Error( 'activation_failed', $this->consume_runtime_error_message( $e ) );
        }
        return $response;
    }

    public function verify_private_otp( string $email, string $activation_session_token, string $otp ) {
        $this->api = new SecureApi( '', '', '' );
        $email_norm = sanitize_email( $email );
        $token = sanitize_text_field( $activation_session_token );
        $otp_norm = sanitize_text_field( $otp );
        if ( $email_norm === '' ) {
            return new \WP_Error( 'email_required', 'Email is required.' );
        }
        if ( $token === '' ) {
            return new \WP_Error( 'activation_session_required', 'Activation session token is required.' );
        }
        if ( $otp_norm === '' ) {
            return new \WP_Error( 'otp_required', 'OTP is required.' );
        }

        $site_key_public = $this->get_site_public_key_b64();
        if ( $site_key_public === '' ) {
            return new \WP_Error( 'site_key_unavailable', 'Site key is unavailable on this server.' );
        }

        $payload = $this->get_site_payload( $email_norm );
        $payload['otp'] = $otp_norm;
        $payload['activation_session_token'] = $token;
        $payload['site_key_public'] = $site_key_public;
        try {
            $payload['proof'] = $this->build_site_key_proof( 'private_verify_otp', strtolower( $email_norm ), $otp_norm, $token );
        } catch ( \Throwable $e ) {
            return new \WP_Error( 'site_key_unavailable', $e->getMessage() );
        }

        $response = $this->api->post( 'services/connect/private/verify-otp', $payload );
        if ( is_wp_error( $response ) ) {
            return $response;
        }

        try {
            $this->apply_activation_response( $response, 'free' );
        } catch ( \Throwable $e ) {
            return new \WP_Error( 'activation_failed', $this->consume_runtime_error_message( $e ) );
        }
        return $response;
    }

    public function verify_license( string $license_key, string $email = '', string $otp = '' ) {
        $this->api = new SecureApi( '', '', '' );
        $key = trim( $license_key );
        if ( $key === '' ) {
            return new \WP_Error( 'license_key_required', 'License key is required.' );
        }
        $email_norm = sanitize_email( $email );
        $otp_norm = sanitize_text_field( $otp );
        if ( $otp_norm === '' ) {
            return new \WP_Error( 'otp_required', 'OTP is required to activate a license key.' );
        }
        if ( strlen( $otp_norm ) !== 6 || ! ctype_digit( $otp_norm ) ) {
            return new \WP_Error( 'otp_invalid', 'OTP must be a valid 6-digit code.' );
        }

        // SHA-256 fingerprint matches Console: hashlib.sha256(key.encode("utf-8")).hexdigest()
        $fingerprint = hash( 'sha256', $key );

        $payload = $this->get_site_payload( $email_norm );
        $payload['license_key'] = $key;
        if ( $otp_norm !== '' ) {
            $payload['otp'] = $otp_norm;
        }
        try {
            $proof_otp = $otp_norm . ':' . $fingerprint;
            $payload['proof'] = $this->build_site_key_proof( 'verify_license', strtolower( $email_norm ), $proof_otp, null );
        } catch ( \Throwable $e ) {
            return new \WP_Error( 'site_key_unavailable', $e->getMessage() );
        }

        $response = $this->api->post( 'services/connect/verify-license', $payload );
        if ( is_wp_error( $response ) ) {
            return $response;
        }

        try {
            $this->apply_activation_response( $response, 'free' );
        } catch ( \Throwable $e ) {
            return new \WP_Error( 'activation_failed', $this->consume_runtime_error_message( $e ) );
        }
        return $response;
    }

    public function verify_private_license( string $license_key, string $activation_session_token, string $email = '', string $otp = '' ) {
        $this->api = new SecureApi( '', '', '' );
        $key = trim( $license_key );
        if ( $key === '' ) {
            return new \WP_Error( 'license_key_required', 'License key is required.' );
        }
        $token = sanitize_text_field( $activation_session_token );
        if ( $token === '' ) {
            return new \WP_Error( 'activation_session_required', 'Activation session token is required.' );
        }
        $email_norm = sanitize_email( $email );
        $otp_norm = sanitize_text_field( $otp );
        if ( $otp_norm === '' ) {
            return new \WP_Error( 'otp_required', 'OTP is required to activate a license key.' );
        }
        if ( strlen( $otp_norm ) !== 6 || ! ctype_digit( $otp_norm ) ) {
            return new \WP_Error( 'otp_invalid', 'OTP must be a valid 6-digit code.' );
        }

        $site_key_public = $this->get_site_public_key_b64();
        $site_key_fp = $this->get_site_public_key_fingerprint( $site_key_public );
        if ( $site_key_public === '' || $site_key_fp === '' ) {
            return new \WP_Error( 'site_key_unavailable', 'Site key is unavailable on this server.' );
        }

        $fingerprint = hash( 'sha256', $key );
        $payload = $this->get_site_payload( $email_norm );
        $payload['license_key'] = $key;
        $payload['otp'] = $otp_norm;
        $payload['activation_session_token'] = $token;
        $payload['site_key_public'] = $site_key_public;
        try {
            $proof_otp = $otp_norm . ':' . $fingerprint . ':' . $site_key_fp;
            $payload['proof'] = $this->build_site_key_proof( 'private_verify_license', strtolower( $email_norm ), $proof_otp, $token );
        } catch ( \Throwable $e ) {
            return new \WP_Error( 'site_key_unavailable', $e->getMessage() );
        }

        $response = $this->api->post( 'services/connect/private/verify-license', $payload );
        if ( is_wp_error( $response ) ) {
            return $response;
        }

        try {
            $this->apply_activation_response( $response, (string) ( $response['plan'] ?? 'free' ) );
        } catch ( \Throwable $e ) {
            return new \WP_Error( 'activation_failed', $this->consume_runtime_error_message( $e ) );
        }
        return $response;
    }

    public function console_connect_init( string $return_to ) {
        $this->api = new SecureApi( '', '', '' );
        $return_to_norm = esc_url_raw( $return_to );
        if ( $return_to_norm === '' ) {
            return new \WP_Error( 'invalid_return_to', 'Invalid return URL.' );
        }

        $payload = $this->get_site_payload();
        $payload['return_to'] = $return_to_norm;
        try {
            $payload['proof'] = $this->build_site_key_proof( 'console_connect_init', null, null, null );
        } catch ( \Throwable $e ) {
            return new \WP_Error( 'site_key_unavailable', $e->getMessage() );
        }

        $response = $this->api->post( 'services/connect/console/init', $payload );
        if ( is_wp_error( $response ) ) {
            return $response;
        }
        return $response;
    }

    public function private_console_connect_init( string $return_to, string $activation_reason = 'manual' ) {
        $this->api = new SecureApi( '', '', '' );
        $return_to_norm = esc_url_raw( $return_to );
        if ( $return_to_norm === '' ) {
            return new \WP_Error( 'invalid_return_to', 'Invalid return URL.' );
        }

        $site_key_public = $this->get_site_public_key_b64();
        if ( $site_key_public === '' ) {
            return new \WP_Error( 'site_key_unavailable', 'Site key is unavailable on this server.' );
        }

        $payload = $this->get_site_payload();
        $payload['return_to'] = $return_to_norm;
        $payload['site_key_public'] = $site_key_public;
        $payload['activation_reason'] = sanitize_key( $activation_reason ) ?: 'manual';
        try {
            $payload['proof'] = $this->build_site_key_proof( 'private_console_connect_init', null, null, null );
        } catch ( \Throwable $e ) {
            return new \WP_Error( 'site_key_unavailable', $e->getMessage() );
        }

        $response = $this->api->post( 'services/connect/private/console/init', $payload );
        if ( is_wp_error( $response ) ) {
            return $response;
        }
        return $response;
    }

    public function console_connect_exchange( string $code ) {
        $this->api = new SecureApi( '', '', '' );
        $code_norm = sanitize_text_field( $code );
        if ( $code_norm === '' ) {
            return new \WP_Error( 'connect_code_required', 'Missing exchange code.' );
        }

        $payload = $this->get_site_payload();
        $payload['code'] = $code_norm;
        try {
            // Bind the exchange code into the signed proof payload.
            $payload['proof'] = $this->build_site_key_proof( 'console_connect_exchange', null, null, $code_norm );
        } catch ( \Throwable $e ) {
            return new \WP_Error( 'site_key_unavailable', $e->getMessage() );
        }

        $response = $this->api->post( 'services/connect/console/exchange', $payload );
        if ( is_wp_error( $response ) ) {
            return $response;
        }

        try {
            $this->apply_activation_response( $response, 'free' );
        } catch ( \Throwable $e ) {
            return new \WP_Error( 'activation_failed', $this->consume_runtime_error_message( $e ) );
        }
        return $response;
    }

    public function private_console_connect_exchange( string $code ) {
        $this->api = new SecureApi( '', '', '' );
        $code_norm = sanitize_text_field( $code );
        if ( $code_norm === '' ) {
            return new \WP_Error( 'connect_code_required', 'Missing exchange code.' );
        }

        $site_key_public = $this->get_site_public_key_b64();
        if ( $site_key_public === '' ) {
            return new \WP_Error( 'site_key_unavailable', 'Site key is unavailable on this server.' );
        }

        $payload = $this->get_site_payload();
        $payload['code'] = $code_norm;
        $payload['site_key_public'] = $site_key_public;
        try {
            $payload['proof'] = $this->build_site_key_proof( 'private_console_connect_exchange', null, null, $code_norm );
        } catch ( \Throwable $e ) {
            return new \WP_Error( 'site_key_unavailable', $e->getMessage() );
        }

        $response = $this->api->post( 'services/connect/private/console/exchange', $payload );
        if ( is_wp_error( $response ) ) {
            return $response;
        }

        try {
            $this->apply_activation_response( $response, 'free' );
        } catch ( \Throwable $e ) {
            return new \WP_Error( 'activation_failed', $this->consume_runtime_error_message( $e ) );
        }
        return $response;
    }

    /**
     * Retrieve non-sensitive requester metadata for a console connect exchange code.
     *
     * This runs server-to-server from WordPress to Console to avoid browser CORS
     * dependency in wp-admin.
     */
    public function console_connect_preview( string $code ) {
        $this->api = new SecureApi( '', '', '' );
        $code_norm = sanitize_text_field( $code );
        if ( $code_norm === '' ) {
            return new \WP_Error( 'connect_code_required', 'Missing exchange code.' );
        }

        $response = $this->api->get( 'services/connect/console/exchange/preview', [ 'code' => $code_norm ] );
        if ( is_wp_error( $response ) ) {
            return $response;
        }

        $requester_display = '';
        if ( isset( $response['requester_display'] ) && is_string( $response['requester_display'] ) ) {
            $requester_display = sanitize_text_field( $response['requester_display'] );
        }

        if ( $requester_display === '' ) {
            return new \WP_Error(
                'requester_unavailable',
                'Unable to verify requester account. Start the connection again from SiteFort Console.'
            );
        }

        return [ 'requester_display' => $requester_display ];
    }

    public function revalidate() {
        // Revalidation is a read-only sync of the currently linked plan.
        // It must not implicitly upgrade Free -> Pro.
        return $this->validate_license( true, true );
    }

    /**
     * Attempt to activate Pro for a site currently on Free.
     *
     * Uses the same backend auto-seat-allocation behavior as license/check.
     * If no Pro seat/subscription is available, returns an actionable error
     * with an upgrade URL for UI redirect.
     */
    public function activate_pro() {
        $previous_console_enabled = ! empty( $this->settings['console_enabled'] );
        if ( empty( $this->settings['site_token'] ) || empty( $this->settings['site_secret'] ) ) {
            return new \WP_Error( 'not_connected', 'Site is not connected.' );
        }

        // Use activate_pro=1 flag so the backend clears any manual-unlink guard
        // and allows re-assignment of a Pro seat.
        $response = $this->api->get(
            'services/license/check',
            [ 'activate_pro' => '1' ]
        );
        if ( is_wp_error( $response ) ) {
            if ( $this->should_fail_closed_for_auth_error( $response ) ) {
                $this->clear_local_activation();
                return new \WP_Error( 'site_deactivated', 'Site credentials are no longer valid. Reconnect this site from Console.' );
            }
            return $response;
        }

        if ( isset( $response['action'] ) && $response['action'] === 'deactivate' ) {
            $this->clear_local_activation();
            return new \WP_Error( 'site_deactivated', 'Site has been unlinked from your subscription.' );
        }

        $this->hydrate_license_metadata_from_response( $response, 'free', 'inactive' );
        if ( ! CloudSettings::save( $this->settings ) ) {
            $persist_error = CloudSettings::get_last_persist_error();
            return new \WP_Error(
                'settings_persist_failed',
                $persist_error ?: 'Cloud settings could not be persisted to the database.',
                [ 'status' => 500 ]
            );
        }
        $this->settings = CloudSettings::get_all();
        $this->persist_last_license_key( (string) ( $this->settings['license_key'] ?? '' ) );

        WhiteLabel::sync_for_license_state(
            $this->settings,
            $response['branding'] ?? null,
            array_key_exists( 'branding', $response )
        );

        $this->schedule_console_state_sync_if_needed( $previous_console_enabled );

        $plan = strtolower( (string) ( $this->settings['license_plan'] ?? 'free' ) );
        if ( $plan === 'pro' || $plan === 'managed' ) {
            return [
                'message' => 'SiteFort activated successfully.',
                'status'  => (string) ( $this->settings['license_status'] ?? 'active' ),
                'plan'    => (string) ( $this->settings['license_plan'] ?? 'free' ),
            ];
        }

        $upgrade_url = $this->get_upgrade_url();
        return new \WP_Error(
            'pro_unavailable',
            'No Pro license found for your account. Visit the pricing page to get one.',
            [
                'status' => 409,
                'detail' => [
                    'error_code'  => 'pro_unavailable',
                    'message'     => 'No Pro license found for your account. Visit the pricing page to get one.',
                    'upgrade_url' => $upgrade_url,
                ],
            ]
        );
    }

    public function deactivate() {
        if ( ! empty( $this->settings['site_token'] ) && ! empty( $this->settings['site_secret'] ) ) {
            $this->api->post( 'services/connect/deactivate', [] );
        }
        $this->clear_local_activation();
        return true;
    }

    public function check_status(): void {
        $this->validate_license( false );
    }

    /**
     * Shared license validation logic.
     *
     * @param bool $strict If true, returns WP_Error on failure; if false, silently absorbs errors.
     * @return true|\WP_Error|null True on success, WP_Error on failure (strict), null on failure (silent).
     */
    private function validate_license( bool $strict, bool $preserve_current_plan = false ) {
        $previous_console_enabled = ! empty( $this->settings['console_enabled'] );
        if ( empty( $this->settings['site_token'] ) || empty( $this->settings['site_secret'] ) ) {
            return $strict ? new \WP_Error( 'not_connected', 'Site is not connected.' ) : null;
        }

        $query = $preserve_current_plan ? [ 'preserve_plan' => '1' ] : [];
        $response = $this->api->get( 'services/license/check', $query );
        if ( is_wp_error( $response ) ) {
            // Fail-closed on definitive auth revocation; keep transient errors non-destructive.
            if ( $this->should_fail_closed_for_auth_error( $response ) ) {
                $this->clear_local_activation();
                return $strict
                    ? new \WP_Error( 'site_deactivated', 'Site credentials are no longer valid. Reconnect this site from Console.' )
                    : null;
            }
            return $strict ? $response : null;
        }

        if ( isset( $response['action'] ) && $response['action'] === 'deactivate' ) {
            $this->clear_local_activation();
            return $strict ? new \WP_Error( 'site_deactivated', 'Site has been unlinked from your subscription.' ) : null;
        }

        if ( isset( $response['migration_status'] ) && $response['migration_status'] === 'approved_replace' ) {
            $reissue = $this->complete_migration_replace();
            if ( is_wp_error( $reissue ) ) {
                return $strict ? $reissue : null;
            }
            return true;
        }

        $this->hydrate_license_metadata_from_response( $response, 'free', 'inactive' );
        $this->hydrate_migration_state_from_response( $response );
        if ( ! CloudSettings::save( $this->settings ) ) {
            $persist_error = CloudSettings::get_last_persist_error();
            return $strict
                ? new \WP_Error(
                    'settings_persist_failed',
                    $persist_error ?: 'Cloud settings could not be persisted to the database.',
                    [ 'status' => 500 ]
                )
                : null;
        }
        $this->settings = CloudSettings::get_all();
        $this->persist_last_license_key( (string) ( $this->settings['license_key'] ?? '' ) );

        WhiteLabel::sync_for_license_state(
            $this->settings,
            $response['branding'] ?? null,
            array_key_exists( 'branding', $response )
        );

        // Converge local state to whatever Console just told us. This is the path a pushed
        // license.sync takes, and it runs in a REST request where ScanCron::init() skips its
        // own reconciliation, so without this a suspended site keeps its scheduled-scan event
        // until the next admin page view.
        \SiteFort\Modules\Cloud\Service\Entitlements::reconcile();

        $this->schedule_console_state_sync_if_needed( $previous_console_enabled );

        if ( isset( $response['migration_status'] ) && $response['migration_status'] === 'approved_connect_new' ) {
            $this->clear_local_activation( true );
            return true;
        }

        return true;
    }
}
