<?php
/**
 * Options management.
 *
 * @package NimblyMail\SMTP
 *
 * @since 1.0.0
 */

namespace NimblyMail\SMTP;

defined( 'ABSPATH' ) || exit;

/**
 * Class Options
 *
 * Centralized options management with constants overlay, per-site overrides,
 * network-wide defaults, network-lock flags, and encryption-at-rest for
 * sensitive fields.
 *
 * Effective value precedence (on every read):
 *
 *     PHP constant  >  network-locked value  >  site override  >  network default  >  plugin default
 *
 * On single-site installs the network layer is a no-op and the chain collapses
 * to constant > site > default.
 *
 * @since 1.0.0
 * @since 1.0.3 Fixed the auth_method default-fallback mismatch in maybe_clear_oauth_on_method_change().
 * @since 1.0.5 Added is_sensitive_field(), a public accessor for the sensitive-field check so consumers outside this class (e.g. settings-import preview) can mask credentials before display.
 */
class Options {

    const OPTION_KEY         = 'nimblymail_options';
    const NETWORK_OPTION_KEY = 'nimblymail_network_options';

    /**
     * Singleton instance.
     *
     * @since 1.0.0
     *
     * @var self|null
     */
    private static ?self $instance = null;

    /**
     * Cached site-level (sparse) overrides from the blog option.
     *
     * Only fields the site has explicitly set are stored here. Absent keys
     * resolve from the network layer (on multisite) or the plugin defaults.
     *
     * @since 1.0.0
     *
     * @var array
     */
    private array $options = array();

    /**
     * Cached network-level state from the site option.
     *
     * Null on single-site installs; populated from get_site_option() on
     * multisite. Shape:
     *   array(
     *     'values'           => array<string, array<string, mixed>>,
     *     'locks'            => array<string, array<string, bool>>,
     *     'bootstrapped'     => bool,
     *     'wizard_completed' => bool,
     *     'db_version'       => string,
     *   )
     *
     * @since 1.0.0
     *
     * @var array|null
     */
    private ?array $network = null;

    /**
     * Default shape for the network option.
     *
     * @since 1.0.0
     *
     * @var array
     */
    private static array $network_defaults = array(
        'values'           => array(),
        'locks'            => array(),
        'bootstrapped'     => false,
        'wizard_completed' => false,
        'db_version'       => '1.0.0',
    );

    /**
     * Default values for all settings groups.
     *
     * @since 1.0.0
     *
     * @var array
     */
    private static array $defaults = array(
        'mail'      => array(
            'from_email'        => '',
            'from_email_force'  => true,
            'from_name'         => '',
            'from_name_force'   => false,
            'mailer'            => 'smtp',
            'return_path'       => false,
            'return_path_email' => '',
        ),
        'smtp'      => array(
            'host'       => '',
            'port'       => 587,
            'encryption' => 'tls',
            'autotls'    => true,
            'auth'       => true,
            'user'       => '',
            'pass'       => '',
        ),
        'gmail'     => array(
            // TEMPORARY (OAuth proxy pending approval): default to 'byok' so
            // BackendFactory selects DirectClient (user's own OAuth app). Revert
            // to 'hosted' once the NimblyMail OAuth proxy is approved for Google.
            'auth_method'    => 'byok',
            'client_id'      => '',
            'client_secret'  => '',
            'access_token'   => '',
            'refresh_token'  => '',
            'token_expiry'   => 0,
            'email'          => '',
            'install_id'     => '',
            'install_secret' => '',
        ),
        'outlook'   => array(
            // TEMPORARY (OAuth proxy pending approval): default to 'byok' so
            // BackendFactory selects DirectClient (user's own OAuth app). Revert
            // to 'hosted' once the NimblyMail OAuth proxy is approved for Microsoft.
            'auth_method'    => 'byok',
            'client_id'      => '',
            'client_secret'  => '',
            'tenant'         => 'common',
            'access_token'   => '',
            'refresh_token'  => '',
            'token_expiry'   => 0,
            'email'          => '',
            'install_id'     => '',
            'install_secret' => '',
        ),
        'ses'       => array(
            'access_key_id'     => '',
            'secret_access_key' => '',
            'region'            => 'us-east-1',
        ),
        'resend'    => array(
            'api_key' => '',
        ),
        'sendlayer' => array(
            'api_key' => '',
        ),
        'brevo'     => array(
            'api_key' => '',
        ),
        'zeptomail' => array(
            'api_key' => '',
            'region'  => 'com',
        ),
        'logs'      => array(
            'enabled'  => true,
            'log_body' => true,
        ),
        'rotation'  => array(
            'by_amount'       => false,
            'by_amount_limit' => 75,
            'by_time'         => false,
            'by_time_days'    => 30,
        ),
        'controls'  => array(),
        'alerts'    => array(
            'enabled'  => false,
            'email_to' => '',
            'cc'       => '',
            'bcc'      => '',
        ),
        'general'   => array(
            'do_not_send'                 => false,
            'hide_delivery_errors'        => false,
            'uninstall_data'              => false,
            'debug_mode'                  => false,
            'debug_events_retention_days' => 7,
        ),
        'queue'     => array(
            'enabled'                 => false,
            'max_attempts'            => 5,
            'batch_size'              => 25,
            'initial_backoff_seconds' => 60,
            'failed_retention_days'   => 7,
            'stuck_reclaim_minutes'   => 30,
            'bypass_admin_emails'     => true,
            'default_priority'        => 'normal',
            'pause_processing'        => false,
        ),
        'sandbox'   => array(
            'enabled'        => false,
            'mode'           => 'log',
            'safe_address'   => '',
            'allowlist'      => '',
            'subject_prefix' => '[SANDBOX] ',
        ),
        'display'   => array(
            'datetime_wp_format'    => false,
            'preferred_mail_format' => 'html',
            'show_attachments'      => false,
            'per_page'              => 25,
        ),
    );

    /**
     * Constants that can override DB values.
     *
     * Define these constants in wp-config.php to override settings
     * stored in the database. When a constant is defined, the
     * corresponding field becomes read-only in the admin UI.
     *
     * @since 1.0.0
     *
     * @var array
     */
    private static array $constants_map = array(
        'mail'      => array(
            'from_email'        => 'NIMBLYMAIL_FROM',
            'from_email_force'  => 'NIMBLYMAIL_FROM_FORCE',
            'from_name'         => 'NIMBLYMAIL_FROM_NAME',
            'from_name_force'   => 'NIMBLYMAIL_FROM_NAME_FORCE',
            'mailer'            => 'NIMBLYMAIL_MAILER',
            'return_path'       => 'NIMBLYMAIL_RETURN_PATH',
            'return_path_email' => 'NIMBLYMAIL_RETURN_PATH_EMAIL',
        ),
        'smtp'      => array(
            'host'       => 'NIMBLYMAIL_SMTP_HOST',
            'port'       => 'NIMBLYMAIL_SMTP_PORT',
            'encryption' => 'NIMBLYMAIL_SMTP_ENCRYPTION',
            'autotls'    => 'NIMBLYMAIL_SMTP_AUTOTLS',
            'auth'       => 'NIMBLYMAIL_SMTP_AUTH',
            'user'       => 'NIMBLYMAIL_SMTP_USER',
            'pass'       => 'NIMBLYMAIL_SMTP_PASS',
        ),
        'gmail'     => array(
            'auth_method'   => 'NIMBLYMAIL_GMAIL_AUTH_METHOD',
            'client_id'     => 'NIMBLYMAIL_GMAIL_CLIENT_ID',
            'client_secret' => 'NIMBLYMAIL_GMAIL_CLIENT_SECRET',
        ),
        'outlook'   => array(
            'auth_method'   => 'NIMBLYMAIL_OUTLOOK_AUTH_METHOD',
            'client_id'     => 'NIMBLYMAIL_OUTLOOK_CLIENT_ID',
            'client_secret' => 'NIMBLYMAIL_OUTLOOK_CLIENT_SECRET',
            'tenant'        => 'NIMBLYMAIL_OUTLOOK_TENANT',
        ),
        'ses'       => array(
            'access_key_id'     => 'NIMBLYMAIL_SES_ACCESS_KEY_ID',
            'secret_access_key' => 'NIMBLYMAIL_SES_SECRET_ACCESS_KEY',
            'region'            => 'NIMBLYMAIL_SES_REGION',
        ),
        'resend'    => array(
            'api_key' => 'NIMBLYMAIL_RESEND_API_KEY',
        ),
        'sendlayer' => array(
            'api_key' => 'NIMBLYMAIL_SENDLAYER_API_KEY',
        ),
        'brevo'     => array(
            'api_key' => 'NIMBLYMAIL_BREVO_API_KEY',
        ),
        'zeptomail' => array(
            'api_key' => 'NIMBLYMAIL_ZEPTOMAIL_API_KEY',
            'region'  => 'NIMBLYMAIL_ZEPTOMAIL_REGION',
        ),
        'logs'      => array(
            'enabled'  => 'NIMBLYMAIL_LOGS_ENABLED',
            'log_body' => 'NIMBLYMAIL_LOGS_LOG_BODY',
        ),
        'alerts'    => array(
            'enabled'  => 'NIMBLYMAIL_ALERT_ENABLED',
            'email_to' => 'NIMBLYMAIL_ALERT_EMAIL_TO',
            'cc'       => 'NIMBLYMAIL_ALERT_CC',
            'bcc'      => 'NIMBLYMAIL_ALERT_BCC',
        ),
        'general'   => array(
            'do_not_send'          => 'NIMBLYMAIL_DO_NOT_SEND',
            'hide_delivery_errors' => 'NIMBLYMAIL_HIDE_DELIVERY_ERRORS',
            'debug_mode'           => 'NIMBLYMAIL_DEBUG_MODE',
        ),
        'sandbox'   => array(
            'enabled'      => 'NIMBLYMAIL_SANDBOX_ENABLED',
            'mode'         => 'NIMBLYMAIL_SANDBOX_MODE',
            'safe_address' => 'NIMBLYMAIL_SANDBOX_SAFE_ADDRESS',
            'allowlist'    => 'NIMBLYMAIL_SANDBOX_ALLOWLIST',
        ),
    );

    /**
     * Fields that must be encrypted at rest.
     *
     * @since 1.0.0
     *
     * @var array
     */
    private static array $sensitive_fields = array(
        'smtp.pass'              => true,
        'smtp.user'              => true,
        'gmail.client_secret'    => true,
        'gmail.client_id'        => true,
        'gmail.access_token'     => true,
        'gmail.refresh_token'    => true,
        'gmail.install_secret'   => true,
        'outlook.client_id'      => true,
        'outlook.client_secret'  => true,
        'outlook.access_token'   => true,
        'outlook.refresh_token'  => true,
        'outlook.install_secret' => true,
        'ses.secret_access_key'  => true,
        'ses.access_key_id'      => true,
        'resend.api_key'         => true,
        'sendlayer.api_key'      => true,
        'brevo.api_key'          => true,
        'zeptomail.api_key'      => true,
    );

    /**
     * Return the filtered defaults array.
     *
     * Pro add-ons register groups/fields by hooking `nimblymail_options_defaults`,
     * `nimblymail_options_constants_map`, and `nimblymail_options_sensitive_fields`
     * from their own `plugins_loaded` callbacks (priority 20). Free's own boot
     * sequence may call `Options::get()` at `plugins_loaded:10`, so memoizing
     * the filter output on first read silently dropped every Pro-side
     * registration. The intentionally un-cached `apply_filters()` call below
     * re-runs each read — the cost is negligible (handful of small callbacks)
     * and correctness wins.
     *
     * @since 1.0.0
     *
     * @return array
     */
    private static function get_filtered_defaults(): array {
        return (array) apply_filters( 'nimblymail_options_defaults', self::$defaults );
    }

    /**
     * Return the filtered constants map.
     *
     * @since 1.0.0
     *
     * @return array
     */
    private static function get_filtered_constants_map(): array {
        return (array) apply_filters( 'nimblymail_options_constants_map', self::$constants_map );
    }

    /**
     * Return the filtered sensitive fields list.
     *
     * @since 1.0.0
     *
     * @return array
     */
    private static function get_filtered_sensitive_fields(): array {
        return (array) apply_filters( 'nimblymail_options_sensitive_fields', self::$sensitive_fields );
    }

    /**
     * Get the singleton instance.
     *
     * @since 1.0.0
     *
     * @return self
     */
    public static function init(): self {
        if ( null === self::$instance ) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    /**
     * Constructor.
     *
     * Loads site-level overrides (always) and network-level state (on
     * multisite only) from the database into the singleton cache.
     *
     * @since 1.0.0
     */
    private function __construct() {
        $this->options = get_option( self::OPTION_KEY, array() );

        if ( is_multisite() ) {
            $stored        = get_site_option( self::NETWORK_OPTION_KEY, array() );
            $this->network = array_merge( self::$network_defaults, is_array( $stored ) ? $stored : array() );
        }
    }

    /**
     * Get a single setting value.
     *
     * Precedence (high to low):
     *   1. PHP constant (if defined).
     *   2. Network-locked value from the network layer (multisite only).
     *   3. Site override.
     *   4. Network default (multisite only).
     *   5. Plugin default.
     *
     * Sensitive fields are decrypted transparently before being returned.
     *
     * @since 1.0.0
     *
     * @param string $group The settings group.
     * @param string $key   The setting key.
     *
     * @return mixed
     */
    public function get( string $group, string $key ): mixed {
        // 1. Constants always win.
        if ( $this->is_const_defined( $group, $key ) ) {
            return $this->get_const_value( $group, $key );
        }

        // 2. Network lock invalidates any site override; serve the network value (or default if absent).
        if ( $this->is_network_locked( $group, $key ) ) {
            if ( isset( $this->network['values'][ $group ][ $key ] ) ) {
                return $this->decrypt_if_sensitive( $group, $key, $this->network['values'][ $group ][ $key ] );
            }
            return self::get_filtered_defaults()[ $group ][ $key ] ?? '';
        }

        // 3. Site override.
        if ( isset( $this->options[ $group ][ $key ] ) ) {
            return $this->decrypt_if_sensitive( $group, $key, $this->options[ $group ][ $key ] );
        }

        // 4. Network default (multisite only — $this->network is null on single-site).
        if ( null !== $this->network && isset( $this->network['values'][ $group ][ $key ] ) ) {
            return $this->decrypt_if_sensitive( $group, $key, $this->network['values'][ $group ][ $key ] );
        }

        // 5. Plugin default.
        return self::get_filtered_defaults()[ $group ][ $key ] ?? '';
    }

    /**
     * Decrypt a raw stored value if the field is registered as sensitive.
     *
     * Returns non-sensitive values unchanged. Returns an empty string when
     * the stored value cannot be decrypted (e.g. corrupted ciphertext), to
     * keep callers resilient.
     *
     * @since 1.0.0
     *
     * @param string $group The settings group.
     * @param string $key   The setting key.
     * @param mixed  $value The raw value as stored in the options array.
     *
     * @return mixed
     */
    private function decrypt_if_sensitive( string $group, string $key, mixed $value ): mixed {
        if ( ! $this->is_sensitive( $group, $key ) || empty( $value ) || ! is_string( $value ) ) {
            return $value;
        }

        try {
            return Encryption::decrypt( $value );
        } catch ( \RuntimeException $e ) {
            Encryption::record_decrypt_failure();
            return '';
        }
    }

    /**
     * Get all settings for a group with precedence applied.
     *
     * Key set is the union of plugin defaults, network values, and site
     * overrides so dynamic keys added at the network layer are surfaced.
     *
     * @since 1.0.0
     *
     * @param string $group The settings group.
     *
     * @return array
     */
    public function get_group( string $group ): array {
        $defaults     = self::get_filtered_defaults()[ $group ] ?? array();
        $stored       = $this->options[ $group ] ?? array();
        $network_vals = ( null !== $this->network && isset( $this->network['values'][ $group ] ) )
            ? $this->network['values'][ $group ]
            : array();
        $merged       = array_merge( $defaults, $network_vals, $stored );

        foreach ( array_keys( $merged ) as $key ) {
            $merged[ $key ] = $this->get( $group, $key );
        }

        return $merged;
    }

    /**
     * Get all settings.
     *
     * @since 1.0.0
     *
     * @return array
     */
    public function get_all(): array {
        $result = array();
        foreach ( self::get_filtered_defaults() as $group => $fields ) {
            $result[ $group ] = $this->get_group( $group );
        }
        return $result;
    }

    /**
     * Save settings.
     *
     * Writes to one of three layers depending on $context:
     *   - 'site' (default): sparse overrides for the current blog.
     *   - 'network': network-wide defaults (multisite only, requires caller
     *     to have verified manage_network_options capability).
     *   - 'network-site': sparse overrides for an arbitrary blog, written
     *     via update_blog_option (no switch_to_blog). $blog_id required.
     *
     * Fields defined by PHP constants are always stripped. On site-level
     * writes, network-locked fields are additionally stripped as a
     * defense-in-depth layer below the REST controller's own rejection.
     *
     * @since 1.0.0
     *
     * @param array  $data    The settings data to save.
     * @param string $context One of 'site', 'network', 'network-site'.
     * @param int    $blog_id Target blog id when $context is 'network-site'.
     *
     * @return bool
     */
    public function set( array $data, string $context = 'site', int $blog_id = 0 ): bool {
        switch ( $context ) {
            case 'network':
                return $this->set_network_values( $data );

            case 'network-site':
                if ( $blog_id <= 0 ) {
                    return false;
                }
                return $this->set_site_overrides( $data, $blog_id );

            case 'site':
            default:
                return $this->set_site_overrides( $data, 0 );
        }
    }

    /**
     * Persist sparse site-level overrides.
     *
     * When $blog_id is 0 the current blog's option is updated and the
     * singleton's in-memory cache is kept in sync. When $blog_id is
     * positive the option is written directly to that blog's row via
     * update_blog_option — no switch_to_blog, no cache touching.
     *
     * @since 1.0.0
     *
     * @param array $data    Raw, un-sanitized settings data.
     * @param int   $blog_id Target blog id, or 0 for the current blog.
     *
     * @return bool
     */
    private function set_site_overrides( array $data, int $blog_id ): bool {
        $data      = $this->strip_const_fields( $data );
        $data      = $this->strip_network_locked_fields( $data );
        $data      = $this->maybe_clear_oauth_on_method_change( $data, $blog_id );
        $sanitized = $this->sanitize( $data );

        if ( $blog_id > 0 ) {
            $existing = get_blog_option( $blog_id, self::OPTION_KEY, array() );
            if ( ! is_array( $existing ) ) {
                $existing = array();
            }
            $merged = array_replace_recursive( $existing, $sanitized );
            return (bool) update_blog_option( $blog_id, self::OPTION_KEY, $merged );
        }

        $this->options = array_replace_recursive( $this->options, $sanitized );
        return (bool) update_option( self::OPTION_KEY, $this->options );
    }

    /**
     * Clear OAuth tokens when a Gmail / Outlook auth_method change is detected.
     *
     * Switching between hosted (NimblyMail proxy) and BYOK (user's own OAuth
     * app) invalidates every connection-derived field, because tokens issued
     * by one backend are not honored by the other. Writes empty values
     * explicitly so they survive Options::set()'s array_replace_recursive
     * merge — omitting them would leave the stale tokens in place.
     *
     * @since 1.0.0
     * @since 1.0.3 "No prior value" fallback now reads the group's real
     *              configured default instead of a hardcoded 'hosted' —
     *              Gmail and Outlook both default to 'byok', so the old
     *              hardcoded fallback would have spuriously wiped a
     *              freshly-obtained OAuth connection the moment auth_method
     *              is restored to the schema (currently dead code; the REST
     *              layer 400s auth_method as an unknown field until then).
     *
     * @param array $data    Inbound settings payload (already const- and lock-stripped).
     * @param int   $blog_id Target blog id, or 0 for the current blog.
     *
     * @return array Settings payload, possibly augmented with cleared OAuth fields.
     */
    private function maybe_clear_oauth_on_method_change( array $data, int $blog_id ): array {
        foreach ( array( 'gmail', 'outlook' ) as $group ) {
            if ( ! isset( $data[ $group ]['auth_method'] ) ) {
                continue;
            }

            $new_method     = (string) $data[ $group ]['auth_method'];
            $default_method = (string) ( self::get_filtered_defaults()[ $group ]['auth_method'] ?? 'hosted' );

            if ( $blog_id > 0 ) {
                $existing = get_blog_option( $blog_id, self::OPTION_KEY, array() );
                $current  = is_array( $existing ) && isset( $existing[ $group ]['auth_method'] )
                    ? (string) $existing[ $group ]['auth_method']
                    : $default_method;
            } else {
                $current = isset( $this->options[ $group ]['auth_method'] )
                    ? (string) $this->options[ $group ]['auth_method']
                    : $default_method;
            }

            if ( $new_method === $current ) {
                continue;
            }

            $data[ $group ]['access_token']   = '';
            $data[ $group ]['refresh_token']  = '';
            $data[ $group ]['token_expiry']   = 0;
            $data[ $group ]['email']          = '';
            $data[ $group ]['install_id']     = '';
            $data[ $group ]['install_secret'] = '';
        }

        return $data;
    }

    /**
     * Persist network-level default values.
     *
     * Rejected silently on single-site installs. Constants remain untouched
     * at the network layer because they override every tier.
     *
     * @since 1.0.0
     *
     * @param array $data Raw, un-sanitized settings data.
     *
     * @return bool
     */
    private function set_network_values( array $data ): bool {
        if ( ! is_multisite() ) {
            return false;
        }

        $data      = $this->strip_const_fields( $data );
        $sanitized = $this->sanitize( $data );

        $this->ensure_network_loaded();
        $this->network['values'] = array_replace_recursive( $this->network['values'], $sanitized );

        return (bool) update_site_option( self::NETWORK_OPTION_KEY, $this->network );
    }

    /**
     * Set or clear per-field network locks.
     *
     * $locks is a two-level array: ['group' => ['key' => true|false]]. A
     * truthy value enables the lock; a falsy value clears it. Keys not
     * mentioned in $locks are left unchanged.
     *
     * @since 1.0.0
     *
     * @param array $locks Partial lock tree to merge into the network store.
     *
     * @return bool
     */
    public function set_locks( array $locks ): bool {
        if ( ! is_multisite() ) {
            return false;
        }

        $this->ensure_network_loaded();

        foreach ( $locks as $group => $fields ) {
            if ( ! is_array( $fields ) ) {
                continue;
            }
            foreach ( $fields as $key => $locked ) {
                if ( $locked ) {
                    $this->network['locks'][ $group ][ $key ] = true;
                } else {
                    unset( $this->network['locks'][ $group ][ $key ] );
                }
            }
            if ( isset( $this->network['locks'][ $group ] ) && empty( $this->network['locks'][ $group ] ) ) {
                unset( $this->network['locks'][ $group ] );
            }
        }

        return (bool) update_site_option( self::NETWORK_OPTION_KEY, $this->network );
    }

    /**
     * Check whether a field is network-locked.
     *
     * A lock comes from the network store and prevents sub-sites from
     * overriding the field. Unlike is_const_defined() this returns false
     * on single-site installs.
     *
     * @since 1.0.0
     *
     * @param string $group The settings group.
     * @param string $key   The setting key.
     *
     * @return bool
     */
    public function is_network_locked( string $group, string $key ): bool {
        return null !== $this->network && ! empty( $this->network['locks'][ $group ][ $key ] );
    }

    /**
     * Check whether a field is locked to the sub-site.
     *
     * True when a PHP constant is defined OR a network lock is set. Locked
     * fields must be rendered read-only in the site admin and writes to
     * them from site-scoped REST endpoints must be rejected.
     *
     * @since 1.0.0
     *
     * @param string $group The settings group.
     * @param string $key   The setting key.
     *
     * @return bool
     */
    public function is_locked( string $group, string $key ): bool {
        return $this->is_const_defined( $group, $key ) || $this->is_network_locked( $group, $key );
    }

    /**
     * Get the effective value along with metadata describing its source.
     *
     * Consumed by SettingsProvider::normalize_field() to drive "inherited
     * from network" / "overridden" / "locked" affordances in the UI.
     *
     * @since 1.0.0
     *
     * @param string $group The settings group.
     * @param string $key   The setting key.
     *
     * @return array{value: mixed, source: string, is_constant: bool, is_locked: bool}
     */
    public function get_effective_metadata( string $group, string $key ): array {
        if ( $this->is_const_defined( $group, $key ) ) {
            return array(
                'value'       => $this->get_const_value( $group, $key ),
                'source'      => 'constant',
                'is_constant' => true,
                'is_locked'   => true,
            );
        }

        if ( $this->is_network_locked( $group, $key ) ) {
            $raw   = $this->network['values'][ $group ][ $key ] ?? null;
            $value = null !== $raw
                ? $this->decrypt_if_sensitive( $group, $key, $raw )
                : ( self::get_filtered_defaults()[ $group ][ $key ] ?? '' );

            return array(
                'value'       => $value,
                'source'      => 'network-lock',
                'is_constant' => false,
                'is_locked'   => true,
            );
        }

        if ( isset( $this->options[ $group ][ $key ] ) ) {
            return array(
                'value'       => $this->decrypt_if_sensitive( $group, $key, $this->options[ $group ][ $key ] ),
                'source'      => 'site',
                'is_constant' => false,
                'is_locked'   => false,
            );
        }

        if ( null !== $this->network && isset( $this->network['values'][ $group ][ $key ] ) ) {
            return array(
                'value'       => $this->decrypt_if_sensitive( $group, $key, $this->network['values'][ $group ][ $key ] ),
                'source'      => 'network-default',
                'is_constant' => false,
                'is_locked'   => false,
            );
        }

        return array(
            'value'       => self::get_filtered_defaults()[ $group ][ $key ] ?? '',
            'source'      => 'default',
            'is_constant' => false,
            'is_locked'   => false,
        );
    }

    /**
     * Check whether a settings group is declared in the plugin defaults.
     *
     * @since 1.0.0
     *
     * @param string $group The settings group.
     *
     * @return bool
     */
    public static function group_exists( string $group ): bool {
        return isset( self::get_filtered_defaults()[ $group ] );
    }

    /**
     * Remove every key of a group from the site-level option.
     *
     * Used to implement the per-section "Reset to network defaults" button:
     * clearing the group from the sparse site option lets resolution fall
     * back to the network default (or the plugin default on single-site).
     *
     * Does not touch the network layer or PHP constants.
     *
     * @since 1.0.0
     *
     * @param string $group   The settings group to clear.
     * @param int    $blog_id Target blog id, or 0 for the current blog.
     *
     * @return bool True when the site option was updated, false otherwise.
     */
    public function reset_group( string $group, int $blog_id = 0 ): bool {
        if ( ! isset( self::get_filtered_defaults()[ $group ] ) ) {
            return false;
        }

        if ( $blog_id > 0 ) {
            $existing = get_blog_option( $blog_id, self::OPTION_KEY, array() );
            if ( ! is_array( $existing ) || ! isset( $existing[ $group ] ) ) {
                return false;
            }
            unset( $existing[ $group ] );
            return (bool) update_blog_option( $blog_id, self::OPTION_KEY, $existing );
        }

        if ( ! isset( $this->options[ $group ] ) ) {
            return false;
        }

        unset( $this->options[ $group ] );
        return (bool) update_option( self::OPTION_KEY, $this->options );
    }

    /**
     * Get the network-only effective value and metadata for a field.
     *
     * Unlike get_effective_metadata() this ignores site overrides entirely —
     * used by the Network Admin settings editor, which must only show and
     * persist the network layer's own view of each field.
     *
     * Precedence:
     *   1. PHP constant (locked, source = 'constant').
     *   2. Network value (source = 'network', locked iff network lock set).
     *   3. Plugin default (source = 'default').
     *
     * @since 1.0.0
     *
     * @param string $group The settings group.
     * @param string $key   The setting key.
     *
     * @return array{value: mixed, source: string, is_constant: bool, is_locked: bool}
     */
    public function get_network_metadata( string $group, string $key ): array {
        if ( $this->is_const_defined( $group, $key ) ) {
            return array(
                'value'       => $this->get_const_value( $group, $key ),
                'source'      => 'constant',
                'is_constant' => true,
                'is_locked'   => true,
            );
        }

        $raw = ( null !== $this->network ) ? ( $this->network['values'][ $group ][ $key ] ?? null ) : null;

        if ( null !== $raw ) {
            return array(
                'value'       => $this->decrypt_if_sensitive( $group, $key, $raw ),
                'source'      => 'network',
                'is_constant' => false,
                'is_locked'   => $this->is_network_locked( $group, $key ),
            );
        }

        return array(
            'value'       => self::get_filtered_defaults()[ $group ][ $key ] ?? '',
            'source'      => 'default',
            'is_constant' => false,
            'is_locked'   => $this->is_network_locked( $group, $key ),
        );
    }

    /**
     * Get the full locks tree from the network state.
     *
     * Returns an empty array on single-site installs. Consumers pass this to
     * the Vue frontend so the UI can render lock toggles without re-querying
     * per field.
     *
     * @since 1.0.0
     *
     * @return array<string, array<string, bool>>
     */
    public function get_locks(): array {
        if ( null === $this->network ) {
            return array();
        }

        $locks = $this->network['locks'] ?? array();
        return is_array( $locks ) ? $locks : array();
    }

    /**
     * Ensure the network state is populated in the singleton cache.
     *
     * Callers inside set_network_values()/set_locks() already guard on
     * is_multisite(), so this just fills in the default shape if the
     * constructor somehow skipped it.
     *
     * @since 1.0.0
     *
     * @return void
     */
    private function ensure_network_loaded(): void {
        if ( null === $this->network ) {
            $this->network = self::$network_defaults;
        }
    }

    /**
     * Check if a constant is defined for a setting.
     *
     * @since 1.0.0
     *
     * @param string $group The settings group.
     * @param string $key   The setting key.
     *
     * @return bool
     */
    public function is_const_defined( string $group, string $key ): bool {
        $const_name = $this->get_const_name( $group, $key );
        return $const_name && defined( $const_name );
    }

    /**
     * Get the PHP constant name mapped to a setting.
     *
     * @since 1.0.0
     *
     * @param string $group The settings group.
     * @param string $key   The setting key.
     *
     * @return string The constant name, or empty string if unmapped.
     */
    public function get_const_name( string $group, string $key ): string {
        return self::get_filtered_constants_map()[ $group ][ $key ] ?? '';
    }

    /**
     * Get the value of a constant for a setting.
     *
     * @since 1.0.0
     *
     * @param string $group The settings group.
     * @param string $key   The setting key.
     *
     * @return mixed
     */
    private function get_const_value( string $group, string $key ): mixed {
        $const_name = $this->get_const_name( $group, $key );
        return $const_name && defined( $const_name ) ? constant( $const_name ) : null;
    }

    /**
     * Check if a field is sensitive (needs encryption).
     *
     * @since 1.0.0
     * @since 1.0.5 Delegates to is_sensitive_field() so the lookup exists in
     *              exactly one place.
     *
     * @param string $group The settings group.
     * @param string $key   The setting key.
     *
     * @return bool
     */
    private function is_sensitive( string $group, string $key ): bool {
        return self::is_sensitive_field( $group, $key );
    }

    /**
     * Check if a field is registered as sensitive.
     *
     * Public counterpart of is_sensitive() so consumers outside this class
     * can decide whether to mask a value before displaying it, without
     * re-deriving or duplicating the sensitive-field list. Used by
     * AbstractImporter::preview() to mask credentials in the settings-import
     * preview response.
     *
     * @since 1.0.5
     *
     * @param string $group The settings group.
     * @param string $key   The setting key.
     *
     * @return bool
     */
    public static function is_sensitive_field( string $group, string $key ): bool {
        return isset( self::get_filtered_sensitive_fields()[ $group . '.' . $key ] );
    }

    /**
     * Remove fields that are defined by PHP constants.
     *
     * Constants always take priority over database values, so there is
     * no reason to persist a value that would be ignored on read.
     *
     * @since 1.0.0
     *
     * @param array $data The settings data.
     *
     * @return array Data with constant-defined fields removed.
     */
    private function strip_const_fields( array $data ): array {
        foreach ( $data as $group => $fields ) {
            if ( ! is_array( $fields ) ) {
                continue;
            }
            foreach ( array_keys( $fields ) as $key ) {
                if ( $this->is_const_defined( $group, $key ) ) {
                    unset( $data[ $group ][ $key ] );
                }
            }
            if ( empty( $data[ $group ] ) ) {
                unset( $data[ $group ] );
            }
        }
        return $data;
    }

    /**
     * Remove fields that are network-locked.
     *
     * Called on site-level writes so that a field the network admin has
     * locked cannot be persisted to the site option even if the request
     * manages to bypass the REST controller's 403.
     *
     * @since 1.0.0
     *
     * @param array $data The settings data.
     *
     * @return array Data with network-locked fields removed.
     */
    private function strip_network_locked_fields( array $data ): array {
        if ( null === $this->network ) {
            return $data;
        }

        foreach ( $data as $group => $fields ) {
            if ( ! is_array( $fields ) ) {
                continue;
            }
            foreach ( array_keys( $fields ) as $key ) {
                if ( $this->is_network_locked( $group, $key ) ) {
                    unset( $data[ $group ][ $key ] );
                }
            }
            if ( empty( $data[ $group ] ) ) {
                unset( $data[ $group ] );
            }
        }

        return $data;
    }

    /**
     * Sanitize settings data before saving.
     *
     * @since 1.0.0
     * @since 1.0.0 Preserve newlines for textarea fields (e.g. sandbox.allowlist).
     *
     * @param array $data Raw settings data.
     *
     * @return array Sanitized data.
     */
    private function sanitize( array $data ): array {
        // Pull out textarea fields BEFORE the catch-all sanitize_text_field walk
        // strips their newlines. Each is handled with sanitize_textarea_field
        // and re-injected after the walk.
        $textarea_buffer = array();
        if ( isset( $data['sandbox']['allowlist'] ) && is_string( $data['sandbox']['allowlist'] ) ) {
            $textarea_buffer['sandbox.allowlist'] = sanitize_textarea_field( $data['sandbox']['allowlist'] );
            unset( $data['sandbox']['allowlist'] );
        }

        // Sanitize all string values as a catch-all.
        array_walk_recursive(
            $data,
            function ( &$value ) {
                if ( is_string( $value ) ) {
                    $value = sanitize_text_field( $value );
                }
            }
        );

        foreach ( $textarea_buffer as $path => $value ) {
            list( $group, $key )    = explode( '.', $path );
            $data[ $group ][ $key ] = $value;
        }

        // Type-specific sanitization.
        if ( isset( $data['mail']['from_email'] ) ) {
            $data['mail']['from_email'] = sanitize_email( $data['mail']['from_email'] );
        }

        if ( isset( $data['mail']['return_path_email'] ) && ! empty( $data['mail']['return_path_email'] ) ) {
            $data['mail']['return_path_email'] = sanitize_email( $data['mail']['return_path_email'] );
        }

        if ( isset( $data['alerts']['email_to'] ) ) {
            $emails                     = array_filter(
                array_map( 'sanitize_email', array_map( 'trim', explode( ',', $data['alerts']['email_to'] ) ) )
            );
            $data['alerts']['email_to'] = implode( ', ', array_slice( $emails, 0, 3 ) );
        }

        if ( isset( $data['alerts']['cc'] ) ) {
            $emails               = array_filter(
                array_map( 'sanitize_email', array_map( 'trim', explode( ',', $data['alerts']['cc'] ) ) )
            );
            $data['alerts']['cc'] = implode( ', ', array_slice( $emails, 0, 3 ) );
        }

        if ( isset( $data['alerts']['bcc'] ) ) {
            $emails                = array_filter(
                array_map( 'sanitize_email', array_map( 'trim', explode( ',', $data['alerts']['bcc'] ) ) )
            );
            $data['alerts']['bcc'] = implode( ', ', array_slice( $emails, 0, 3 ) );
        }

        if ( isset( $data['smtp']['port'] ) ) {
            $data['smtp']['port'] = absint( $data['smtp']['port'] );
        }

        if ( isset( $data['rotation']['by_amount_limit'] ) ) {
            $data['rotation']['by_amount_limit'] = absint( $data['rotation']['by_amount_limit'] );
        }

        if ( isset( $data['rotation']['by_time_days'] ) ) {
            $data['rotation']['by_time_days'] = absint( $data['rotation']['by_time_days'] );
        }

        if ( isset( $data['general']['debug_events_retention_days'] ) ) {
            $data['general']['debug_events_retention_days'] = absint( $data['general']['debug_events_retention_days'] );
        }

        // Encrypt sensitive fields.
        foreach ( array_keys( self::get_filtered_sensitive_fields() ) as $field_key ) {
            list( $group, $key ) = explode( '.', $field_key );
            if ( isset( $data[ $group ][ $key ] ) && ! empty( $data[ $group ][ $key ] ) ) {
                $data[ $group ][ $key ] = Encryption::encrypt( $data[ $group ][ $key ] );
            }
        }

        return $data;
    }

    /**
     * Get the defaults array.
     *
     * Returns the filtered defaults, including any groups registered by Pro
     * add-ons via the `nimblymail_options_defaults` filter.
     *
     * @since 1.0.0
     *
     * @return array
     */
    public static function get_defaults(): array {
        return self::get_filtered_defaults();
    }

    /**
     * Reset the singleton (for testing).
     *
     * @since 1.0.0
     */
    public static function reset(): void {
        self::$instance = null;
    }
}
