<?php

declare(strict_types=1);

namespace SecurWP\Modules\Cloud\Service;

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

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

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

    private const TABLE_NAME = 'securwp_settings';
    // Backup storage for site_uuid so dropping the plugin table doesn't regenerate identity.
    private const OPTION_SITE_UUID = 'securwp_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;
    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;

    /**
     * Load all cloud settings with decrypted secrets.
     *
     * @return array<string, mixed>
     */
    public static function get_all(): array {
        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( '\\SecurWP\\Includes\\SettingsStore' ) ) {
            $fallback_license_key = sanitize_text_field(
                (string) \SecurWP\Includes\SettingsStore::get_field(
                    \SecurWP\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;
            }
        }

        $has_encrypted = ( $settings['site_token_enc'] ?? '' ) !== ''
            && ( $settings['site_secret_enc'] ?? '' ) !== '';

        $settings['site_token']  = self::decrypt_secret( $settings['site_token_enc'] ?? '' );
        $settings['site_secret'] = self::decrypt_secret( $settings['site_secret_enc'] ?? '' );

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

        // Clear any stale _key_error from DB (runtime-only field).
        unset( $settings['_key_error'] );

        // Detect key/credential issues for the recovery modal.
        if ( $has_encrypted && empty( $settings['site_token'] ) ) {
            $master_error = \SecurWP\Includes\MasterKey::get_last_error();
            if ( $master_error ) {
                // Group 1: MasterKey infrastructure error (permissions, corrupt, write-fail, bad constant).
                // Show the blocking recovery modal.
                $settings['_key_error'] = $master_error;
            } elseif ( ! empty( $settings['license_key'] ) ) {
                // Group 2: Key changed but license key exists → can recover from Console.
                // Show the blocking recovery modal with one-click recovery.
                $settings['_key_error'] = [
                    'code'        => 'key_changed',
                    'can_recover' => true,
                ];
            } else {
                // Group 3: Key changed, no license key → stale data is unrecoverable.
                // Auto-clear the garbage and let the user reconnect normally.
                $stored['site_token_enc']  = '';
                $stored['site_secret_enc'] = '';
                $stored['license_status']  = '';
                $stored['license_plan']    = 'free';
                $stored_updated            = true;
                $settings['site_token']    = '';
                $settings['site_secret']   = '';
                $settings['license_status'] = '';
                $settings['license_plan']  = 'free';
                // No _key_error → no blocking modal. User sees the normal connect form.
            }
        }

        // 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',
            'entitlements' => [],
            'linked_record_url' => '',
            'site_uuid' => '',
            'rest_url' => '',
            'migration_status' => 'none',
            'migration_pending_url' => '',
            'migration_replace_allowed' => true,
            'migration_replace_message' => '',
            '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,
            'console_enabled' => false,
            '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
        );
    }

    /**
     * Ensure shared settings table exists with cloud column.
     *
     * @return void
     */
    private static function ensure_table(): void {
        if ( self::$table_ready ) {
            return;
        }

        global $wpdb;

        $table = $wpdb->prefix . self::TABLE_NAME;
        $charset_collate = $wpdb->get_charset_collate();

        $sql = "CREATE TABLE {$table} (
            id bigint(20) NOT NULL AUTO_INCREMENT,
            settings longtext NOT NULL,
            cloud_settings longtext NULL,
            firewall longtext NULL,
            hardening longtext NULL,
            global_settings longtext NULL,
            twofa longtext NULL,
            headers longtext NULL,
            notifications longtext NULL,
            cloudflare longtext NULL,
            branding longtext NULL,
            state longtext NULL,
            created_at int(11) NOT NULL,
            updated_at int(11) NOT NULL,
            PRIMARY KEY (id)
        ) {$charset_collate};";

        require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
        dbDelta( $sql );

        self::$table_ready = true;
    }

    /**
     * Load stored cloud settings payload.
     *
     * @return array<string, mixed>
     */
    private static function get_stored_settings( bool $strip_retired = true ): array {
        if ( ! self::$stored_settings_loaded ) {
            global $wpdb;
            $table = $wpdb->prefix . self::TABLE_NAME;

            // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            $row = $wpdb->get_row(
                $wpdb->prepare(
                    "SELECT cloud_settings FROM {$table} WHERE id = %d LIMIT 1",
                    1
                ),
                ARRAY_A
            );
            // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared

            if ( ! $row || empty( $row['cloud_settings'] ) ) {
                self::set_stored_settings_cache( [] );
            } else {
                $decoded = json_decode( $row['cloud_settings'], true );
                self::set_stored_settings_cache( is_array( $decoded ) ? $decoded : [] );
            }
        }

        $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 {
        global $wpdb;
        $table = $wpdb->prefix . self::TABLE_NAME;

        $encoded = wp_json_encode( $settings );
        if ( false === $encoded ) {
            return false;
        }

        $now = time();
        $empty_settings = wp_json_encode( [] );

        // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $sql = $wpdb->prepare(
            "INSERT INTO {$table} (id, settings, cloud_settings, created_at, updated_at)
             VALUES (1, %s, %s, %d, %d)
             ON DUPLICATE KEY UPDATE cloud_settings = VALUES(cloud_settings), updated_at = VALUES(updated_at)",
            $empty_settings,
            $encoded,
            $now,
            $now
        );
        // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared -- Executes the prepared upsert statement assembled above.
        $result = $wpdb->query( $sql );
        if ( $result !== false ) {
            self::set_stored_settings_cache( $settings );
        }
        return $result !== false;
    }

    /**
     * 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'] ?? '' ) );
        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['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 );

        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'] );
        }

        $secret_enc = $normalized['site_secret_enc'] ?? '';
        if ( array_key_exists( 'site_secret', $normalized ) ) {
            $secret_enc = self::encrypt_secret( (string) $normalized['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
     */
    /**
     * Encrypt secret value for storage.
     *
     * @param string $secret Secret value.
     * @return string
     */
    private static function encrypt_secret( string $secret ): string {
        if ( $secret === '' ) {
            return '';
        }

        try {
            $enc = SecretManager::encrypt( $secret );
            self::$last_encrypt_error = null;
            return $enc;
        } catch ( \Throwable $e ) {
            self::$last_encrypt_error = $e->getMessage();
            securwp_log( '[SecurWP 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;
    }

    /**
     * Decrypt a secret value.
     *
     * @param string $encrypted Encrypted secret.
     * @return string Plaintext, or empty string on failure.
     */
    private static function decrypt_secret( string $encrypted ): string {
        if ( $encrypted === '' ) {
            return '';
        }

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

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

}
