<?php

declare(strict_types=1);

namespace SecurWP\Includes;

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

/**
 * Central settings storage layer.
 *
 * All SecurWP module settings are stored in the singleton row (id=1) of
 * the wp_securwp_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.
 *
 * Follows the same pattern used by CloudSettings and ScannerSettings.
 *
 */
class SettingsStore {

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

    // Column name constants (one per module/domain).
    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,
    ];

    /** @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;


    /**
     * 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: securwp_settings_saved_firewall
             *
             * @param array $data The saved settings.
             */
            do_action( "securwp_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 );
    }

    /**
     * Clear the per-request cache (for testing or after external writes).
     *
     * @param string|null $column Specific column, or null for all.
     */
    public static function reset_cache( ?string $column = null ): void {
        self::sync_context();

        if ( null === $column ) {
            self::$cache = [];
        } else {
            unset( self::$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();

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

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

        // Map: wp_options key => [ column, state_key (if going into state column) ]
        $option_to_column = [
            'securwp_firewall_settings'     => [ self::COL_FIREWALL, null ],
            'securwp_hardening_settings'    => [ self::COL_HARDENING, null ],
            'securwp_global_settings'       => [ self::COL_GLOBAL, null ],
            'securwp_2fa_settings'          => [ self::COL_TWOFA, null ],
            'securwp_headers_settings'      => [ self::COL_HEADERS, null ],
            'securwp_notification_settings' => [ self::COL_NOTIFICATIONS, null ],
            'securwp_cloudflare_settings'   => [ self::COL_CLOUDFLARE, null ],
            'securwp_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 = [
            'securwp_published_scan_id'  => 'published_scan_id',
            'securwp_scan_issues_count'  => 'scan_issues_count',
            'securwp_scans_completed'    => 'scans_completed',
            'securwp_last_scan_status'   => 'last_scan_status',
            'securwp_last_scan_time'     => 'last_scan_time',
            'securwp_geoip_last_download' => 'geoip_last_download',
            'securwp_setup_dismissed'    => 'setup_dismissed',
            'securwp_login_access_token' => 'login_access_token',
            'securwp_key_backup_available' => 'key_backup_available',
            'securwp_last_license_key'   => 'last_license_key',
            'securwp_digest_settings'    => 'digest_settings',
            'securwp_vulnerability_issues' => 'vulnerability_issues',
            'securwp_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( 'SECURWP_VERSION' ) ? SECURWP_VERSION : '2.7.0';
        update_option( 'securwp_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 [];
        }
        if ( ! self::ensure_table() ) {
            return [];
        }

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

        // Column name is from a hardcoded whitelist, safe to interpolate.
        // 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 `{$column}` 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[ $column ] ) ) {
            return [];
        }

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

    /**
     * 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;
        }
        if ( ! self::ensure_table() ) {
            return false;
        }

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

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

        $now = time();

        // Column name is from a hardcoded whitelist, safe to interpolate.
        // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $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 );
        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 = 'securwp_settings_schema_ver';

    /**
     * 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 SECURWP_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( 'SECURWP_VERSION' ) ? SECURWP_VERSION : '';

        // Skip dbDelta when the schema is already up-to-date.
        // When SECURWP_VERSION is defined: match stored version to current.
        // When SECURWP_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 ) {
                self::$table_ready = true;
                return true;
            }
        } elseif ( get_option( self::SCHEMA_VERSION_OPTION ) !== false ) {
            self::$table_ready = true;
            return true;
        }

        global $wpdb;
        $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 );

        update_option( self::SCHEMA_VERSION_OPTION, $current_version !== '' ? $current_version : '0', true );
        self::$table_ready = true;
        return 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;
    }
}
