<?php

declare(strict_types=1);

namespace SiteFort\Includes;

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

/**
 * Central settings storage layer.
 *
 * All SiteFort module settings are stored in the singleton row (id=1) of
 * the wp_sitefort_settings table. Each module gets its own longtext column
 * containing a JSON-encoded array. This class provides the read/write API and
 * fires custom actions so other systems (e.g. StatePush) can react to changes.
 */
class SettingsStore {

    private const TABLE = 'sitefort_settings';
    private const MAX_IDENTIFIER_LENGTH = 64;

    public const COL_FIREWALL      = 'firewall';
    public const COL_HARDENING     = 'hardening';
    public const COL_GLOBAL        = 'global_settings';
    public const COL_TWOFA         = 'twofa';
    public const COL_HEADERS       = 'headers';
    public const COL_NOTIFICATIONS = 'notifications';
    public const COL_CLOUDFLARE    = 'cloudflare';
    public const COL_BRANDING      = 'branding';
    public const COL_STATE         = 'state';

    /**
     * All valid column names for validation.
     */
    private const VALID_COLUMNS = [
        self::COL_FIREWALL,
        self::COL_HARDENING,
        self::COL_GLOBAL,
        self::COL_TWOFA,
        self::COL_HEADERS,
        self::COL_NOTIFICATIONS,
        self::COL_CLOUDFLARE,
        self::COL_BRANDING,
        self::COL_STATE,
    ];

    private const BUCKET_COLUMNS = [
        'boot'    => [
            self::COL_FIREWALL,
            self::COL_HARDENING,
            self::COL_GLOBAL,
            self::COL_TWOFA,
            self::COL_HEADERS,
            self::COL_NOTIFICATIONS,
            self::COL_CLOUDFLARE,
            self::COL_BRANDING,
        ],
        'state'   => [ self::COL_STATE ],
        'scanner' => [ 'settings' ],
        'cloud'   => [ 'cloud_settings' ],
    ];

    private const SERVICE_COLUMNS = [
        'settings',
        'cloud_settings',
    ];

    /** @var array<string, array> Per-request cache keyed by column name. */
    private static array $cache = [];

    /** @var bool Whether ensure_table() has already run this request. */
    private static bool $table_ready = false;

    /** @var string|null Table-prefix context for the current request cache. */
    private static ?string $context_prefix = null;

    /** @var array<string,array<string,mixed>> Encoded settings grouped by query. */
    private static array $bucket_cache = [];

    /** @var array<string,bool> */
    private static array $bucket_loaded = [];

    /** Object-cache group for settings queries. */
    private const CACHE_GROUP = 'sitefort_settings';

    /** Cache TTL in seconds. Writes invalidate affected entries. */
    private const CACHE_TTL = 300;


    /**
     * Get all settings for a column, merged with defaults.
     *
     * @param string $column  One of the COL_* constants.
     * @param array  $defaults Default values to merge with stored data.
     * @return array
     */
    public static function get( string $column, array $defaults = [] ): array {
        self::sync_context();

        if ( ! self::is_valid_column( $column ) ) {
            return $defaults;
        }

        if ( isset( self::$cache[ $column ] ) ) {
            return wp_parse_args( self::$cache[ $column ], $defaults );
        }

        $stored = self::load_column( $column );
        self::$cache[ $column ] = $stored;

        return wp_parse_args( $stored, $defaults );
    }

    /**
     * Save an entire settings array for a column.
     *
     * Replaces whatever was in the column. Fires a custom action so that
     * StatePush and other listeners can react.
     *
     * @param string $column One of the COL_* constants.
     * @param array  $data   Settings array to store.
     * @return bool True on success.
     */
    public static function save( string $column, array $data ): bool {
        self::sync_context();

        if ( ! self::is_valid_column( $column ) ) {
            return false;
        }

        $ok = self::persist_column( $column, $data );

        if ( $ok ) {
            self::$cache[ $column ] = $data;

            /**
             * Fires after a settings column is saved.
             *
             * Hook name example: sitefort_settings_saved_firewall
             *
             * @param array $data The saved settings.
             */
            do_action( "sitefort_settings_saved_{$column}", $data );
        }

        return $ok;
    }

    /**
     * Read a single key from a column.
     *
     * @param string $column  One of the COL_* constants.
     * @param string $key     The key within the JSON settings.
     * @param mixed  $default Fallback value.
     * @return mixed
     */
    public static function get_field( string $column, string $key, $default = null ) {
        $settings = self::get( $column );
        return $settings[ $key ] ?? $default;
    }

    /**
     * Write a single key within a column (read-modify-write).
     *
     * @param string $column One of the COL_* constants.
     * @param string $key    The key within the JSON settings.
     * @param mixed  $value  The value to set.
     * @return bool True on success.
     */
    public static function set_field( string $column, string $key, $value ): bool {
        $settings = self::get( $column );
        $settings[ $key ] = $value;
        return self::save( $column, $settings );
    }

    /**
     * Remove a key from a column's settings.
     *
     * @param string $column One of the COL_* constants.
     * @param string $key    The key to remove.
     * @return bool True on success.
     */
    public static function delete_field( string $column, string $key ): bool {
        $settings = self::get( $column );
        if ( ! array_key_exists( $key, $settings ) ) {
            return true;
        }
        unset( $settings[ $key ] );
        return self::save( $column, $settings );
    }

    /** @param string|null $column Specific column, or null for all. */
    public static function reset_cache( ?string $column = null ): void {
        self::invalidate_cache( $column );
    }


    /**
     * Migrate settings from wp_options to the custom table.
     *
     * Safe to call multiple times. Skips if already migrated.
     * Preserves old wp_options rows as a rollback safety net.
     */
    public static function maybe_migrate(): void {
        self::sync_context();

        // Skip the migration probe on anonymous front-end requests.
        if ( ! RequestContext::allows_inline_migrations() ) {
            return;
        }

        if ( get_option( 'sitefort_settings_migrated' ) ) {
            return;
        }

        if ( ! self::ensure_table() ) {
            return;
        }

        // Map: wp_options key => [ column, state_key (if going into state column) ]
        $option_to_column = [
            'sitefort_firewall_settings'     => [ self::COL_FIREWALL, null ],
            'sitefort_hardening_settings'    => [ self::COL_HARDENING, null ],
            'sitefort_global_settings'       => [ self::COL_GLOBAL, null ],
            'sitefort_2fa_settings'          => [ self::COL_TWOFA, null ],
            'sitefort_headers_settings'      => [ self::COL_HEADERS, null ],
            'sitefort_notification_settings' => [ self::COL_NOTIFICATIONS, null ],
            'sitefort_cloudflare_settings'   => [ self::COL_CLOUDFLARE, null ],
            'sitefort_branding'              => [ self::COL_BRANDING, null ],
        ];

        // Migrate module settings (each option becomes a full column).
        foreach ( $option_to_column as $option_key => $mapping ) {
            [ $column ] = $mapping;
            $value = get_option( $option_key, null );
            if ( null !== $value && is_array( $value ) ) {
                // Only write if the column is currently empty.
                $existing = self::load_column( $column );
                if ( empty( $existing ) ) {
                    self::persist_column( $column, $value );
                    self::$cache[ $column ] = $value;
                }
            }
        }

        // Migrate scalar state values into the state column.
        $state_keys = [
            'sitefort_published_scan_id'  => 'published_scan_id',
            'sitefort_scan_issues_count'  => 'scan_issues_count',
            'sitefort_scans_completed'    => 'scans_completed',
            'sitefort_last_scan_status'   => 'last_scan_status',
            'sitefort_last_scan_time'     => 'last_scan_time',
            'sitefort_geoip_last_download' => 'geoip_last_download',
            'sitefort_setup_dismissed'    => 'setup_dismissed',
            'sitefort_login_access_token' => 'login_access_token',
            'sitefort_key_backup_available' => 'key_backup_available',
            'sitefort_last_license_key'   => 'last_license_key',
            'sitefort_digest_settings'    => 'digest_settings',
            'sitefort_vulnerability_issues' => 'vulnerability_issues',
            'sitefort_vulnerability_results' => 'vulnerability_results',
        ];

        $state = self::load_column( self::COL_STATE );
        $state_changed = false;

        foreach ( $state_keys as $option_key => $state_key ) {
            $value = get_option( $option_key, null );
            if ( null !== $value && ! array_key_exists( $state_key, $state ) ) {
                $state[ $state_key ] = $value;
                $state_changed = true;
            }
        }

        if ( $state_changed ) {
            self::persist_column( self::COL_STATE, $state );
            self::$cache[ self::COL_STATE ] = $state;
        }

        // Mark as migrated (store in wp_options; this flag must survive table operations).
        $version = defined( 'SITEFORT_VERSION' ) ? SITEFORT_VERSION : '0';
        update_option( 'sitefort_settings_migrated', $version, false );
    }


    /**
     * Validate a column name against the whitelist.
     *
     * @param string $column Column name to check.
     * @return bool
     */
    private static function is_valid_column( string $column ): bool {
        return in_array( $column, self::VALID_COLUMNS, true );
    }

    /**
     * Load a single column's JSON data from the database.
     *
     * @param string $column Column name.
     * @return array Decoded settings, or empty array.
     */
    private static function load_column( string $column ): array {
        self::sync_context();
        if ( ! self::is_valid_column( $column ) ) {
            return [];
        }

        return self::decode_column( self::load_bucket( self::bucket_for_column( $column ) ), $column );
    }

    /**
     * @param string $column Column name (e.g. 'cloud_settings', 'settings').
     * @return array<string,mixed> Decoded JSON, or [] when absent/empty.
     */
    public static function get_service_settings( string $column ): array {
        self::sync_context();

        if ( ! in_array( $column, self::SERVICE_COLUMNS, true ) ) {
            return [];
        }

        return self::decode_column( self::load_bucket( self::bucket_for_column( $column ) ), $column );
    }

    /**
     * Persist a settings column owned by a service layer.
     *
     * @param string $column Service settings column.
     * @param array  $data   Settings payload.
     * @return bool
     */
    public static function save_service_settings( string $column, array $data ): bool {
        self::sync_context();

        if ( ! in_array( $column, self::SERVICE_COLUMNS, true ) ) {
            return false;
        }

        return self::persist_storage_column( $column, $data );
    }

    /**
     * Clear one settings cache entry, or all settings caches.
     */
    public static function invalidate_cache( ?string $column = null ): void {
        self::sync_context();

        if ( null === $column ) {
            self::$cache         = [];
            self::$bucket_cache  = [];
            self::$bucket_loaded = [];

            foreach ( array_keys( self::BUCKET_COLUMNS ) as $bucket ) {
                wp_cache_delete( self::cache_key( $bucket ), self::CACHE_GROUP );
            }
            return;
        }

        if ( ! in_array( $column, self::SERVICE_COLUMNS, true ) && ! self::is_valid_column( $column ) ) {
            return;
        }

        $bucket = self::bucket_for_column( $column );
        unset( self::$cache[ $column ], self::$bucket_cache[ $bucket ], self::$bucket_loaded[ $bucket ] );
        wp_cache_delete( self::cache_key( $bucket ), self::CACHE_GROUP );
    }

    /**
     * Load one settings group once per request.
     *
     * @return array<string,mixed> Encoded columns keyed by name, or [] when none.
     */
    private static function load_bucket( string $bucket ): array {
        self::sync_context();

        if ( ! empty( self::$bucket_loaded[ $bucket ] ) ) {
            return self::$bucket_cache[ $bucket ] ?? [];
        }

        if ( ! self::ensure_table() ) {
            self::$bucket_cache[ $bucket ]  = [];
            self::$bucket_loaded[ $bucket ] = true;
            return [];
        }

        $columns = self::bucket_columns( $bucket );
        if ( empty( $columns ) ) {
            return [];
        }

        $cache_key = self::cache_key( $bucket );
        $found     = false;
        $cached    = wp_cache_get( $cache_key, self::CACHE_GROUP, false, $found );
        if ( $found && is_array( $cached ) ) {
            self::$bucket_cache[ $bucket ]  = $cached;
            self::$bucket_loaded[ $bucket ] = true;
            return self::$bucket_cache[ $bucket ];
        }

        global $wpdb;
        $table = self::table_identifier();
        $select_columns = implode(
            ', ',
            array_map(
                static fn( string $column ): string => '`' . $column . '`',
                $columns
            )
        );

        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table and columns are validated or hardcoded above.
        $sql = $wpdb->prepare( "SELECT {$select_columns} FROM {$table} WHERE id = %d LIMIT 1", 1 );

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Prepared above and cached below.
        $row = $wpdb->get_row(
            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Prepared above.
            $sql,
            ARRAY_A
        );

        self::$bucket_cache[ $bucket ]  = is_array( $row ) ? $row : [];
        self::$bucket_loaded[ $bucket ] = true;
        wp_cache_set( $cache_key, self::$bucket_cache[ $bucket ], self::CACHE_GROUP, self::CACHE_TTL );

        return self::$bucket_cache[ $bucket ];
    }

    private static function bucket_for_column( string $column ): string {
        foreach ( self::BUCKET_COLUMNS as $bucket => $columns ) {
            if ( in_array( $column, $columns, true ) ) {
                return $bucket;
            }
        }

        return '';
    }

    /** @return string[] */
    private static function bucket_columns( string $bucket ): array {
        return self::BUCKET_COLUMNS[ $bucket ] ?? [];
    }

    /**
     * Decode one column's stored JSON from an already-fetched row.
     *
     * @param array<string,mixed> $row    Raw row keyed by column name.
     * @param string              $column Column to decode.
     * @return array<string,mixed>
     */
    private static function decode_column( array $row, string $column ): array {
        if ( empty( $row[ $column ] ) ) {
            return [];
        }

        $decoded = json_decode( (string) $row[ $column ], true );
        return is_array( $decoded ) ? $decoded : [];
    }

    /**
     * Object-cache key scoped by blog prefix and query group.
     */
    private static function cache_key( string $bucket ): string {
        try {
            return 'group:' . self::table_name() . ':' . $bucket;
        } catch ( \InvalidArgumentException $e ) {
            return 'group:' . self::TABLE . ':' . $bucket;
        }
    }

    /**
     * Persist a settings array to a single column without touching others.
     *
     * Uses INSERT...ON DUPLICATE KEY UPDATE to handle both fresh installs
     * (no row yet) and existing rows atomically.
     *
     * @param string $column Column name.
     * @param array  $data   Settings array.
     * @return bool
     */
    private static function persist_column( string $column, array $data ): bool {
        self::sync_context();
        if ( ! self::is_valid_column( $column ) ) {
            return false;
        }

        return self::persist_storage_column( $column, $data );
    }

    private static function persist_storage_column( string $column, array $data ): bool {
        if ( ! self::ensure_table() ) {
            return false;
        }

        global $wpdb;
        $table = self::table_identifier();

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

        $now = time();

        // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Column is selected from a hardcoded whitelist and the table identifier is validated.
        if ( 'settings' === $column ) {
            $sql = $wpdb->prepare(
                "INSERT INTO {$table} (id, settings, created_at, updated_at)
                 VALUES (1, %s, %d, %d)
                 ON DUPLICATE KEY UPDATE settings = VALUES(settings), updated_at = VALUES(updated_at)",
                $encoded,
                $now,
                $now
            );
        } else {
            $sql = $wpdb->prepare(
                "INSERT INTO {$table} (id, settings, `{$column}`, created_at, updated_at)
                 VALUES (1, %s, %s, %d, %d)
                 ON DUPLICATE KEY UPDATE `{$column}` = VALUES(`{$column}`), updated_at = VALUES(updated_at)",
                wp_json_encode( [] ),
                $encoded,
                $now,
                $now
            );
        }
        // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.NotPrepared -- Executing the validated plugin-owned upsert statement built above.
        $result = $wpdb->query( $sql );
        if ( $result !== false ) {
            self::invalidate_cache( $column );
        }
        return $result !== false;
    }

    /**
     * wp_options key storing the plugin version at which the settings table
     * schema was last applied. Cleared on plugin upgrade so dbDelta runs once
     * per version instead of once per request.
     */
    private const SCHEMA_VERSION_OPTION = 'sitefort_settings_schema_ver';

    /**
     * Provision the shared settings table for sibling writers (CloudSettings,
     * ScannerSettings) through this single version-gated owner.
     *
     * @return bool True when the table is provisioned and ready.
     */
    public static function ensure_table_exists(): bool {
        return self::ensure_table();
    }

    /**
     * Ensure the settings table exists with all required columns.
     *
     * Uses dbDelta which safely adds missing columns to existing tables.
     * All locations that call dbDelta on this table (Installer,
     * CloudSettings, ScannerSettings, and here) must share the same schema.
     *
     * dbDelta is expensive (schema introspection). It is skipped when the
     * stored schema version matches SITEFORT_VERSION, so it only runs on
     * first install or after a plugin upgrade.
     */
    private static function ensure_table(): bool {
        self::sync_context();

        try {
            $table = self::table_name();
        } catch ( \InvalidArgumentException $e ) {
            return false;
        }

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

        $current_version = defined( 'SITEFORT_VERSION' ) ? SITEFORT_VERSION : '';

        // Skip dbDelta when the schema is already up-to-date.
        // When SITEFORT_VERSION is defined: match stored version to current.
        // When SITEFORT_VERSION is not defined (non-standard load): use the
        // schema-version marker so dbDelta does not run on every request.
        if ( $current_version !== '' ) {
            if ( get_option( self::SCHEMA_VERSION_OPTION ) === $current_version ) {
                if ( \SiteFort\Core\Database\SchemaInspector::table_engine_ready( $table ) ) {
                    self::$table_ready = true;
                    return true;
                }
            }
        } elseif ( get_option( self::SCHEMA_VERSION_OPTION ) !== false ) {
            if ( \SiteFort\Core\Database\SchemaInspector::table_engine_ready( $table ) ) {
                self::$table_ready = true;
                return true;
            }
        }

        global $wpdb;
        $charset_collate = \SiteFort\Core\Database\SchemaInspector::validated_charset_collate( $wpdb );

		require_once ABSPATH . 'wp-admin/includes/upgrade.php';
		dbDelta( self::settings_table_schema_sql( $table, $charset_collate ) );
        \SiteFort\Core\Database\SchemaInspector::flush_cache( $table );
        \SiteFort\Core\Database\SchemaInspector::ensure_table_uses_innodb( $table );

		update_option( self::SCHEMA_VERSION_OPTION, $current_version !== '' ? $current_version : '0', true );
        self::$table_ready = true;
        return true;
    }

    /**
     * Single source of truth for the shared settings table schema, used by both
     * this class and the activation Installer so the column list cannot drift.
     *
     * @param string $table           Validated table name (unquoted, for dbDelta).
     * @param string $charset_collate Charset/collation clause from SchemaInspector.
     * @return string
     */
    public static function settings_table_schema_sql( string $table, string $charset_collate ): string {
        return "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};";
    }

    /**
     * Record that the shared settings schema matches the running plugin version,
     * so the first request after activation does not re-run dbDelta.
     */
    public static function mark_schema_current(): void {
        $current_version = defined( 'SITEFORT_VERSION' ) ? SITEFORT_VERSION : '';
        update_option( self::SCHEMA_VERSION_OPTION, $current_version !== '' ? $current_version : '0', true );
    }

    /** Marker recording the version the boot flags were last autoloaded at. */
    private const BOOT_AUTOLOAD_VERSION_OPTION = 'sitefort_boot_autoload_ver';

    /**
     * Small boot flags that are read on most requests. Autoloading them lets them
     * ride WordPress's single alloptions query instead of a separate get_option()
     * each. Kept to short scalars only; larger or per-scan options stay lazy.
     */
    private const BOOT_AUTOLOAD_FLAGS = [
        'sitefort_settings_migrated',
        'sitefort_command_queue_schema_version',
        'sitefort_state_schema_version',
        'sitefort_twofa_default_roles_seeded',
        'sitefort_retired_firewall_rule_sync_cleaned_v1',
        'sitefort_user_nicename_remediation_completed',
        'sitefort_legacy_detectors_removed',
        'sitefort_blocklist_migrated',
        'sitefort_rebrand_from_securwp_migrated',
        'sitefort_rebrand_from_securwp_encryption_migrated',
        'sitefort_rebrand_migration_2_complete',
        'sitefort_rebrand_shared_migration_1_complete',
    ];

    /**
     * Flip the boot flags to autoload once per plugin version, in a privileged
     * context. A no-op on WordPress older than 6.4 (the flags simply stay lazy).
     *
     * @return void
     */
    public static function ensure_boot_flags_autoloaded(): void {
        if ( ! RequestContext::is_privileged() || ! function_exists( 'wp_set_option_autoload' ) ) {
            return;
        }

        $version = defined( 'SITEFORT_VERSION' ) ? SITEFORT_VERSION : '';
        if ( get_option( self::BOOT_AUTOLOAD_VERSION_OPTION ) === $version ) {
            return;
        }

        foreach ( self::BOOT_AUTOLOAD_FLAGS as $flag ) {
            wp_set_option_autoload( $flag, true );
        }

        update_option( self::BOOT_AUTOLOAD_VERSION_OPTION, $version, true );
    }

    private static function table_name(): string {
        global $wpdb;

        $table = (string) $wpdb->prefix . self::TABLE;
        if (
            $table === ''
            || strlen( $table ) > self::MAX_IDENTIFIER_LENGTH
            || ! preg_match( '/^[A-Za-z0-9_]+$/', $table )
        ) {
            throw new \InvalidArgumentException( 'Invalid table identifier.' );
        }

        return $table;
    }

    private static function table_identifier(): string {
        return '`' . str_replace( '`', '``', self::table_name() ) . '`';
    }

    /**
     * Reset per-request caches when execution switches to a different blog.
     */
    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::$cache          = [];
        self::$table_ready    = false;
        self::$bucket_cache   = [];
        self::$bucket_loaded  = [];
    }
}
