<?php

declare(strict_types=1);

namespace SiteFort\Modules\Cloud\Service;

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

use SiteFort\Includes\SecretManager;
use SiteFort\Modules\Cloud\SecureApi;

/**
 * CloudSettings
 *
 * Stores cloud connection settings in the shared settings table with encrypted secrets.
 */
class CloudSettings {

    // Backup storage for site_uuid so dropping the plugin table doesn't regenerate identity.
    private const OPTION_SITE_UUID = 'sitefort_site_uuid';
    private const RETIRED_KEYS = [
        'last_sync_rules',
        'linked_site_url',
        'migration_pending_at',
        'migration_last_reported_url',
        'migration_last_reported_at',
        'processed_command_ids',
    ];
    private static $table_ready = false;
    /** @var string|null Table-prefix context for the current request caches (multisite). */
    private static ?string $context_prefix = null;
    private static $rest_url_populate_scheduled = false;
    /** @var array<string, mixed> */
    private static array $stored_settings_cache = [];
    private static bool $stored_settings_loaded = false;
    /** @var array<string, mixed> */
    private static array $all_settings_cache = [];
    private static bool $all_settings_loaded = false;
    /** @var string|null Last encryption error message, set by encrypt_secret(). */
    private static ?string $last_encrypt_error = null;
    /** @var string|null Last persistence error message, set by persist(). */
    private static ?string $last_persist_error = null;

    /**
     * Load all cloud settings with decrypted secrets.
     *
     * @return array<string, mixed>
     */
    public static function get_all(): array {
        self::sync_context();

        if ( self::$all_settings_loaded ) {
            return self::$all_settings_cache;
        }

        self::ensure_table();

        $stored = self::get_stored_settings();
        $settings = wp_parse_args( $stored, self::get_defaults() );
        $stored_updated = false;

        if ( empty( $settings['license_key'] ) && class_exists( '\\SiteFort\\Includes\\SettingsStore' ) ) {
            $fallback_license_key = sanitize_text_field(
                (string) \SiteFort\Includes\SettingsStore::get_field(
                    \SiteFort\Includes\SettingsStore::COL_STATE,
                    'last_license_key',
                    ''
                )
            );
            if ( $fallback_license_key !== '' ) {
                $settings['license_key'] = $fallback_license_key;
                $stored['license_key']   = $fallback_license_key;
                $stored_updated          = true;
            }
        }

        $token_ciphertext  = (string) ( $settings['site_token_enc'] ?? '' );
        $secret_ciphertext = (string) ( $settings['site_secret_enc'] ?? '' );
        $has_ciphertext    = $token_ciphertext !== '' || $secret_ciphertext !== '';

        $settings['site_token']  = self::decrypt_secret( $token_ciphertext, self::SECRET_CONTEXT_SITE_TOKEN );
        $settings['site_secret'] = self::decrypt_secret( $secret_ciphertext, self::SECRET_CONTEXT_SITE_SECRET );

        unset( $settings['site_token_enc'], $settings['site_secret_enc'] );

        unset( $settings['_key_error'] );

        if ( $has_ciphertext && ( empty( $settings['site_token'] ) || empty( $settings['site_secret'] ) ) ) {
            $missing_ciphertext = $token_ciphertext === '' || $secret_ciphertext === '';
            $failed_ciphertext  = empty( $settings['site_token'] ) ? $token_ciphertext : $secret_ciphertext;
            $provider_ready     = $failed_ciphertext !== ''
                && SecretManager::is_envelope_provider_available( $failed_ciphertext );
            $master_error       = \SiteFort\Includes\MasterKey::get_last_error();

            if ( $missing_ciphertext ) {
                $settings['_key_error'] = [
                    'code'    => 'credential_incomplete',
                    'message' => 'Stored SiteFort credentials are incomplete and must be reconnected.',
                ];
            } elseif ( ! $provider_ready ) {
                $settings['_key_error'] = [
                    'code'    => 'crypto_unavailable',
                    'message' => 'The PHP crypto provider required by the stored SiteFort credentials is unavailable.',
                ];
            } elseif ( $master_error ) {
                $settings['_key_error'] = $master_error;
            } else {
                $settings['_key_error'] = [
                    'code'    => 'decrypt_failed',
                    'message' => 'Stored SiteFort credentials could not be authenticated with the current encryption key.',
                ];
            }

        }

        // Stable site UUID is required for seat binding.
        if ( empty( $settings['site_uuid'] ) ) {
            // First try the wp_options fallback (survives plugin table drops).
            $opt = function_exists( 'get_option' ) ? get_option( self::OPTION_SITE_UUID, '' ) : '';
            $opt = is_string( $opt ) ? sanitize_text_field( $opt ) : '';
            if ( $opt !== '' ) {
                $settings['site_uuid'] = $opt;
            } else {
                $settings['site_uuid'] = function_exists( 'wp_generate_uuid4' )
                    ? wp_generate_uuid4()
                    : md5( (string) home_url() . ':' . (string) time() );
                if ( function_exists( 'update_option' ) ) {
                    // autoload=false: avoid adding unnecessary autoload bloat.
                    update_option( self::OPTION_SITE_UUID, (string) $settings['site_uuid'], false );
                }
            }
            $stored['site_uuid'] = $settings['site_uuid'];
            $stored_updated      = true;
        }

        // Cache rest_url for the Console push path and refresh it if the site URL changed.
        $rest_url = self::safe_get_rest_url();
        if ( $rest_url !== '' ) {
            if ( (string) ( $settings['rest_url'] ?? '' ) !== $rest_url ) {
                $settings['rest_url'] = $rest_url;
                $stored['rest_url']   = $rest_url;
                $stored_updated       = true;
            }
        } elseif ( empty( $settings['rest_url'] ) ) {
            // rest_url needs WP to finish bootstrapping; populate it lazily.
            self::schedule_rest_url_population();
        }

        if ( $stored_updated ) {
            self::persist( $stored );
            self::persist_site_uuid_option( $stored );
        }

        self::$all_settings_cache  = $settings;
        self::$all_settings_loaded = true;

        return $settings;
    }

    /**
     * Load cloud settings without secrets for UI or logging use.
     *
     * @return array<string, mixed>
     */
    public static function get_public(): array {
        $settings = self::get_all();
        unset( $settings['site_token'], $settings['site_secret'] );
        $settings['license_key'] = self::mask_license_key_for_display( (string) ( $settings['license_key'] ?? '' ) );
        return $settings;
    }

    /**
     * Mask a license key for read-only UI/API responses.
     */
    public static function mask_license_key_for_display( string $license_key ): string {
        $license_key = trim( sanitize_text_field( $license_key ) );
        if ( $license_key === '' ) {
            return '';
        }

        $visible_length = strlen( $license_key ) > 4 ? 4 : strlen( $license_key );
        $visible_tail   = substr( $license_key, -$visible_length );

        return '***' . $visible_tail;
    }

    /**
     * Persist a full settings payload, encrypting secrets at rest.
     *
     * @param array<string, mixed> $settings Settings payload.
     * @return bool
     */
    public static function save( array $settings ): bool {
        self::ensure_table();
        $prepared = self::prepare_for_storage( $settings );
        $ok = self::persist( $prepared );
        self::persist_site_uuid_option( $prepared );
        return $ok;
    }

    /**
     * Merge and persist partial updates.
     *
     * @param array<string, mixed> $partial Partial settings payload.
     * @return bool
     */
    public static function update( array $partial ): bool {
        $settings = self::get_all();
        foreach ( $partial as $key => $value ) {
            $settings[ $key ] = $value;
        }
        return self::save( $settings );
    }

    /**
     * Defaults for cloud settings payload.
     *
     * @return array<string, mixed>
     */
    private static function get_defaults(): array {
        return [
            'license_key' => '',
            'license_status' => '',
            'license_plan' => 'free',
            'license_expires_at' => 0,
            'entitlements' => [],
            // When Console last confirmed the entitlement set. Entitlements::all() falls back
            // to free defaults once this goes stale.
            'entitlements_checked_at' => 0,
            'linked_record_url' => '',
            'site_uuid' => '',
            'rest_url' => '',
            'migration_status' => 'none',
            'migration_pending_url' => '',
            'migration_replace_allowed' => true,
            'migration_replace_message' => '',
            'connectivity' => 'public',
            'public_identity_verified' => true,
            'reachability_status' => 'unknown',
            'reachability_error_code' => '',
            'activation_channel' => 'public_pull',
            'site_token_enc' => '',
            'site_secret_enc' => '',
            'last_sync_vuln' => 0,
            'last_sync_malware' => 0,
            'scan_credits_remaining' => -1,
            'scan_credits_total' => -1,
            'scan_credit_reset_at' => null,
            'scan_credit_limit_reached' => false,
            'scan_credit_block_reason' => null,
            'console_enabled' => false,
            'console_gate_allowed' => false,
            'console_pending_request' => false,
            'console_last_request_at' => 0,
            'pending_command_acks' => [],
        ];
    }

    /**
     * Safely get the REST URL without triggering WP core fatals during early bootstrap.
     *
     * @return string
     */
    private static function safe_get_rest_url(): string {
        if ( ! function_exists( 'get_rest_url' ) ) {
            return '';
        }

        // In some stacks, get_rest_url() dereferences $wp_rewrite before it's ready.
        if ( ! did_action( 'init' ) ) {
            return '';
        }
        if ( empty( $GLOBALS['wp_rewrite'] ) || ! is_object( $GLOBALS['wp_rewrite'] ) ) {
            return '';
        }

        try {
            return (string) get_rest_url();
        } catch ( \Throwable $e ) {
            return '';
        }
    }

    /**
     * Populate rest_url after init when WP_Rewrite is guaranteed to be ready.
     *
     * @return void
     */
    private static function schedule_rest_url_population(): void {
        if ( self::$rest_url_populate_scheduled ) {
            return;
        }
        if ( ! function_exists( 'add_action' ) ) {
            return;
        }
        self::$rest_url_populate_scheduled = true;
        add_action(
            'init',
            static function () : void {
                $settings = self::get_all();
                if ( empty( $settings['rest_url'] ) ) {
                    $rest_url = self::safe_get_rest_url();
                    if ( $rest_url !== '' ) {
                        self::update_stored_fields( [ 'rest_url' => $rest_url ] );
                    }
                }
            },
            20
        );
    }

    /**
     * Reset request-local caches when the blog prefix changes on multisite, so a
     * switch_to_blog() never serves the previous blog's cloud settings.
     *
     * @return void
     */
    private static function sync_context(): void {
        global $wpdb;

        $prefix = isset( $wpdb->prefix ) ? (string) $wpdb->prefix : '';
        if ( self::$context_prefix === $prefix ) {
            return;
        }

        self::$context_prefix         = $prefix;
        self::$stored_settings_cache  = [];
        self::$stored_settings_loaded = false;
        self::$all_settings_cache     = [];
        self::$all_settings_loaded    = false;
        self::$table_ready            = false;
    }

    /**
     * Ensure the shared settings table exists, delegating to SettingsStore (the
     * single version-gated schema owner) so dbDelta runs once per version.
     *
     * @return void
     */
    private static function ensure_table(): void {
        self::sync_context();

        if ( self::$table_ready ) {
            return;
        }

        if ( \SiteFort\Includes\SettingsStore::ensure_table_exists() ) {
            self::$table_ready = true;
        }
    }

    /**
     * Load stored cloud settings payload.
     *
     * @return array<string, mixed>
     */
    private static function get_stored_settings( bool $strip_retired = true ): array {
        self::sync_context();

        if ( ! self::$stored_settings_loaded ) {
            self::set_stored_settings_cache(
                \SiteFort\Includes\SettingsStore::get_service_settings( 'cloud_settings' )
            );
        }

        $stored = self::$stored_settings_cache;

        return $strip_retired ? self::strip_retired_keys( $stored ) : $stored;
    }

    /**
     * Update the request-local stored settings cache and invalidate the hydrated
     * settings cache that is derived from it.
     *
     * @param array<string, mixed> $settings
     * @return void
     */
    private static function set_stored_settings_cache( array $settings ): void {
        self::$stored_settings_cache  = $settings;
        self::$stored_settings_loaded = true;
        self::$all_settings_cache     = [];
        self::$all_settings_loaded    = false;
    }

    /**
     * Persist the cloud settings payload without touching scanner settings.
     *
     * @param array<string, mixed> $settings Storage-ready settings payload.
     * @return bool
     */
    private static function persist( array $settings ): bool {
        self::sync_context();

        if ( false === wp_json_encode( $settings ) ) {
            self::$last_persist_error = 'Cloud settings could not be JSON-encoded for database persistence.';
            return false;
        }

        $result = \SiteFort\Includes\SettingsStore::save_service_settings( 'cloud_settings', $settings );
        if ( $result ) {
            self::$last_persist_error = null;
            self::set_stored_settings_cache( $settings );
        } else {
            global $wpdb;
            $db_error = is_string( $wpdb->last_error ) ? trim( $wpdb->last_error ) : '';
            self::$last_persist_error = $db_error !== ''
                ? 'Cloud settings database write failed: ' . $db_error
                : 'Cloud settings database write failed.';
        }
        return $result;
    }

    /**
     * Normalize and encrypt settings for storage.
     *
     * @param array<string, mixed> $settings Settings payload.
     * @return array<string, mixed>
     */
    private static function prepare_for_storage( array $settings ): array {
        $normalized = wp_parse_args( $settings, self::get_defaults() );
        $normalized = self::strip_retired_keys( $normalized );

        $normalized['license_key'] = sanitize_text_field( (string) ( $normalized['license_key'] ?? '' ) );
        $normalized['license_status'] = sanitize_text_field( (string) ( $normalized['license_status'] ?? '' ) );
        $normalized['license_plan'] = sanitize_text_field( (string) ( $normalized['license_plan'] ?? '' ) );
        $normalized['license_expires_at'] = absint( $normalized['license_expires_at'] ?? 0 );
        $normalized['entitlements_checked_at'] = absint( $normalized['entitlements_checked_at'] ?? 0 );
        if ( ! is_array( $normalized['entitlements'] ?? null ) ) {
            $normalized['entitlements'] = [];
        }
        $normalized['site_uuid'] = sanitize_text_field( (string) ( $normalized['site_uuid'] ?? '' ) );
        $normalized['linked_record_url'] = esc_url_raw( (string) ( $normalized['linked_record_url'] ?? '' ) );
        $normalized['rest_url'] = esc_url_raw( (string) ( $normalized['rest_url'] ?? '' ) );
        $normalized['migration_status'] = sanitize_text_field( (string) ( $normalized['migration_status'] ?? '' ) );
        $normalized['migration_pending_url'] = esc_url_raw( (string) ( $normalized['migration_pending_url'] ?? '' ) );
        $normalized['migration_replace_allowed'] = (bool) ( $normalized['migration_replace_allowed'] ?? true );
        $normalized['migration_replace_message'] = sanitize_text_field( (string) ( $normalized['migration_replace_message'] ?? '' ) );
        $normalized['connectivity'] = sanitize_text_field( (string) ( $normalized['connectivity'] ?? 'public' ) );
        $normalized['public_identity_verified'] = (bool) ( $normalized['public_identity_verified'] ?? true );
        $normalized['reachability_status'] = sanitize_text_field( (string) ( $normalized['reachability_status'] ?? 'unknown' ) );
        $normalized['reachability_error_code'] = sanitize_text_field( (string) ( $normalized['reachability_error_code'] ?? '' ) );
        $normalized['activation_channel'] = sanitize_text_field( (string) ( $normalized['activation_channel'] ?? 'public_pull' ) );
        $normalized['last_sync_vuln'] = absint( $normalized['last_sync_vuln'] ?? 0 );
        $normalized['last_sync_malware'] = absint( $normalized['last_sync_malware'] ?? 0 );

        $normalized['console_enabled'] = (bool) ( $normalized['console_enabled'] ?? false );
        $normalized['console_gate_allowed'] = (bool) ( $normalized['console_gate_allowed'] ?? false );
        $normalized['console_pending_request'] = (bool) ( $normalized['console_pending_request'] ?? false );
        $normalized['console_last_request_at'] = absint( $normalized['console_last_request_at'] ?? 0 );

        if ( ! is_array( $normalized['pending_command_acks'] ?? null ) ) {
            $normalized['pending_command_acks'] = [];
        }

        $token_enc = $normalized['site_token_enc'] ?? '';
        if ( array_key_exists( 'site_token', $normalized ) ) {
            $token_enc = self::encrypt_secret( (string) $normalized['site_token'], self::SECRET_CONTEXT_SITE_TOKEN );
        }

        $secret_enc = $normalized['site_secret_enc'] ?? '';
        if ( array_key_exists( 'site_secret', $normalized ) ) {
            $secret_enc = self::encrypt_secret( (string) $normalized['site_secret'], self::SECRET_CONTEXT_SITE_SECRET );
        }

        // Guard: if a real site_token was provided but encryption failed silently,
        // don't persist 'active' license_status alongside empty credentials.
        // This prevents the misleading "activated" UI state after an encryption failure.
        $token_was_provided = array_key_exists( 'site_token', $normalized ) && (string) $normalized['site_token'] !== '';
        if ( $token_was_provided && $token_enc === '' ) {
            $normalized['license_status'] = '';
            $normalized['license_plan']   = 'free';
        }

        $normalized['site_token_enc'] = $token_enc;
        $normalized['site_secret_enc'] = $secret_enc;

        unset( $normalized['site_token'], $normalized['site_secret'], $normalized['_key_error'] );

        return $normalized;
    }

    /**
     * Update specific fields in stored settings without re-encrypting credentials.
     *
     * Reads the raw stored JSON, patches the given fields, and persists directly.
     * This avoids the decrypt→re-encrypt cycle that wipes credentials on key mismatch.
     *
     * @param array<string, mixed> $fields Key-value pairs to update.
     * @return bool
     */
    public static function update_stored_fields( array $fields ): bool {
        self::ensure_table();

        $stored = self::get_stored_settings();
        $fields = self::strip_retired_keys( $fields );
        foreach ( $fields as $key => $value ) {
            $stored[ $key ] = $value;
        }

        $ok = self::persist( $stored );
        self::persist_site_uuid_option( $stored );
        return $ok;
    }

    /**
     * Remove retired fields from persisted cloud settings without touching
     * encrypted credentials.
     */
    public static function cleanup_retired_fields(): bool {
        self::ensure_table();

        $stored = self::get_stored_settings( false );
        $cleaned = self::strip_retired_keys( $stored );

        if ( $cleaned === $stored ) {
            return true;
        }

        $ok = self::persist( $cleaned );
        self::persist_site_uuid_option( $cleaned );
        return $ok;
    }

    /**
     * Persist site_uuid to wp_options as a resilience fallback.
     *
     * @param array<string, mixed> $settings Storage-ready settings payload.
     * @return void
     */
    private static function persist_site_uuid_option( array $settings ): void {
        if ( ! function_exists( 'update_option' ) ) {
            return;
        }
        $uuid = sanitize_text_field( (string) ( $settings['site_uuid'] ?? '' ) );
        if ( $uuid === '' ) {
            return;
        }
        update_option( self::OPTION_SITE_UUID, $uuid, false );
    }

    /**
     * Return raw stored settings without decryption or side-effect detection.
     *
     * Used by the key-regeneration endpoint to read license_key BEFORE
     * CloudSettings::get_all() fires Group-3 auto-clear.
     *
     * @return array<string, mixed>
     */
    public static function get_raw(): array {
        self::ensure_table();
        return wp_parse_args( self::get_stored_settings(), self::get_defaults() );
    }

    /**
     * Remove keys for retired features that should no longer survive in stored
     * cloud settings payloads.
     *
     * @param array<string, mixed> $settings
     * @return array<string, mixed>
     */
    private static function strip_retired_keys( array $settings ): array {
        foreach ( self::RETIRED_KEYS as $key ) {
            unset( $settings[ $key ] );
        }

        return $settings;
    }

    /**
     * Check if a key/credential error exists without exposing full settings.
     *
     * Utility for cross-module use (e.g. Scanner credential guard).
     *
     * @return array<string, mixed>|null Error info or null if OK.
     */
    public static function get_key_error(): ?array {
        $settings = self::get_all();
        return $settings['_key_error'] ?? null;
    }

    /**
     * Re-fetch site credentials from Console via verify-license.
     *
     * Called from the recovery REST endpoint when the admin clicks "Recover
     * Credentials" in the recovery modal. Runs in a dedicated AJAX request
     * so Console can fetch the public key from a separate PHP-FPM worker.
     *
     * @param array<string, mixed> $settings Current settings (with license_key).
     * @return array{site_token: string, site_secret: string}|\WP_Error
     */
    /** Associated-data context binding each credential ciphertext to its slot. */
    private const SECRET_CONTEXT_SITE_TOKEN  = 'sitefort_cloud:site_token';
    private const SECRET_CONTEXT_SITE_SECRET = 'sitefort_cloud:site_secret';

    /**
     * Encrypt secret value for storage.
     *
     * @param string $secret  Secret value.
     * @param string $context Associated-data binding for the target slot.
     * @return string
     */
    private static function encrypt_secret( string $secret, string $context ): string {
        if ( $secret === '' ) {
            return '';
        }

        try {
            $enc = SecretManager::encrypt( $secret, $context );
            self::$last_encrypt_error = null;
            return $enc;
        } catch ( \Throwable $e ) {
            self::$last_encrypt_error = $e->getMessage();
            sitefort_log( '[SiteFort CloudSettings] Encryption failed: ' . $e->getMessage() );
            return '';
        }
    }

    /**
     * Return the last encryption error message, or null if encryption succeeded.
     *
     * @return string|null
     */
    public static function get_last_encrypt_error(): ?string {
        return self::$last_encrypt_error;
    }

    /**
     * Return the last database persistence error message, or null if the last
     * persist operation succeeded.
     *
     * @return string|null
     */
    public static function get_last_persist_error(): ?string {
        return self::$last_persist_error;
    }

    /**
     * Decrypt a secret value.
     *
     * @param string $encrypted Encrypted secret.
     * @param string $context   Associated-data binding for the source slot.
     * @return string Plaintext, or empty string on failure.
     */
    private static function decrypt_secret( string $encrypted, string $context ): string {
        if ( $encrypted === '' ) {
            return '';
        }

        $plaintext = SecretManager::decrypt( $encrypted, $context );

        return is_string( $plaintext ) ? $plaintext : '';
    }

}
