<?php
/**
 * Settings facade — reads from network-option (Multisite) or option (single site).
 * Site-level settings override network-level when both present.
 *
 * @package Brainwerk SEO Suite
 */

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

class Seoforge_Settings {

    /**
     * Keyed by blog id (0 on single-site) rather than a single flat cache,
     * because a request can switch_to_blog() between sites (cron, WP-CLI
     * --url, admin-ajax on network screens) and a flat static cache would
     * then silently serve one site's merged settings to another. Any
     * update() call still flushes the WHOLE array (not just the current
     * blog's entry), since a network-scope write changes what every
     * per-blog entry would merge to.
     */
    private static $cache = array();

    public static function all() {
        $blog_id = is_multisite() ? get_current_blog_id() : 0;
        if ( isset( self::$cache[ $blog_id ] ) ) {
            return self::$cache[ $blog_id ];
        }
        $network = is_multisite() ? get_site_option( SEOFORGE_OPTIONS_KEY, array() ) : array();
        $site    = get_option( SEOFORGE_OPTIONS_KEY, array() );

        $merged = is_array( $network ) ? $network : array();
        if ( is_array( $site ) ) {
            $merged = array_replace( $merged, $site );
        }
        self::$cache[ $blog_id ] = $merged;
        return $merged;
    }

    public static function get( $key, $default = null ) {
        $all = self::all();
        return array_key_exists( $key, $all ) ? $all[ $key ] : $default;
    }

    /**
     * True when the current request context is genuinely allowed to write
     * the NETWORK-wide option — a super admin / manage_network_options
     * holder, or network-admin context. Everyone else (including a plain
     * subsite Administrator, who always has manage_options for their own
     * site) only ever writes their own per-blog option, no matter which
     * scope was requested.
     */
    public static function can_write_network_scope() {
        return is_super_admin() || current_user_can( 'manage_network_options' ) || is_network_admin();
    }

    /**
     * Pure decision: given the requested scope, whether the caller may
     * legitimately write the network-wide option, and whether this is a
     * multisite install, resolve to 'site' or 'network'. Split out from
     * update() so the subsite-admin guard is unit-testable without a full
     * WordPress environment.
     */
    public static function resolve_write_scope( $scope, $can_write_network, $is_multisite ) {
        if ( ! $is_multisite ) {
            return 'site';
        }
        if ( 'site' === $scope ) {
            return 'site';
        }
        // 'network' or 'auto' (the default): only a genuine network-admin
        // context may write the network-wide option; everyone else —
        // including a plain subsite Administrator, who always has
        // manage_options for their own site — is confined to their own
        // per-blog option, no matter which scope was requested.
        return $can_write_network ? 'network' : 'site';
    }

    /**
     * Writes only the keys actually being changed, not a full snapshot of
     * every setting. On Multisite this matters a lot for 'site' scope: a
     * full-snapshot write used to copy every network default into the
     * per-blog option on a subsite's first save, silently shadowing that
     * site from all FUTURE network-level changes with no visible cause (the
     * 1.7.0 audit's "sparse override" finding). A key is only persisted to
     * the per-blog option when its new value actually differs from the
     * network value; a change that brings a key back in line with the
     * network is pruned from the override instead of being stored as a
     * now-redundant duplicate, so previously-broad overrides shrink over
     * time as settings get touched again.
     */
    public static function update( array $changes, $scope = 'auto' ) {
        $sanitized = self::sanitize( $changes );
        $target    = self::resolve_write_scope( $scope, self::can_write_network_scope(), is_multisite() );

        if ( 'site' === $target ) {
            $network = is_multisite() ? (array) get_site_option( SEOFORGE_OPTIONS_KEY, array() ) : array();
            $site    = (array) get_option( SEOFORGE_OPTIONS_KEY, array() );
            foreach ( $sanitized as $key => $value ) {
                if ( is_multisite() && array_key_exists( $key, $network ) && $network[ $key ] === $value ) {
                    unset( $site[ $key ] ); // matches the inherited network value — no override needed.
                } else {
                    $site[ $key ] = $value;
                }
            }
            update_option( SEOFORGE_OPTIONS_KEY, $site );
            $next = array_replace( $network, $site );
        } else {
            $network = (array) get_site_option( SEOFORGE_OPTIONS_KEY, array() );
            $next    = array_replace( $network, $sanitized );
            update_site_option( SEOFORGE_OPTIONS_KEY, $next );
        }
        self::flush_cache();

        // Mirror the configuration to the reinstall backup (or clear it when the
        // user opts out). Default on — so settings survive uninstall/reinstall.
        $persist = array_key_exists( 'persist_settings', $next ) ? (bool) $next['persist_settings'] : true;
        if ( $persist ) {
            self::write_backup();
        } else {
            self::delete_backup();
        }
        return $next;
    }

    /**
     * Option key holding the reinstall backup. Kept as a DB option (NOT a file
     * under uploads/) on purpose: the settings include the visitor-hash salt, so
     * it must never sit in the web-accessible uploads directory. uninstall.php
     * deliberately preserves this key so a reinstall can restore automatically.
     */
    const BACKUP_KEY = 'seoforge_settings_backup';

    /** Snapshot the current configuration into the reinstall backup. */
    public static function write_backup() {
        $settings = get_site_option( SEOFORGE_OPTIONS_KEY, array() );
        if ( ! is_array( $settings ) || empty( $settings ) ) {
            return;
        }
        update_site_option( self::BACKUP_KEY, array(
            'settings' => $settings,
            'ai'       => get_site_option( 'seoforge_ai', array() ),
            'version'  => defined( 'SEOFORGE_VERSION' ) ? SEOFORGE_VERSION : '',
            'at'       => gmdate( 'Y-m-d\TH:i:s\Z' ),
        ) );
    }

    /** Remove the reinstall backup (used when the user opts out of persistence). */
    public static function delete_backup() {
        delete_site_option( self::BACKUP_KEY );
    }

    /**
     * Restore settings (and AI config) from the reinstall backup. Returns true
     * when a usable backup was found and applied. Called from the activator on a
     * fresh install so a reinstall automatically recovers the prior config.
     */
    public static function restore_backup() {
        $backup = get_site_option( self::BACKUP_KEY, array() );
        if ( ! is_array( $backup ) || empty( $backup['settings'] ) || ! is_array( $backup['settings'] ) ) {
            return false;
        }
        update_site_option( SEOFORGE_OPTIONS_KEY, $backup['settings'] );
        if ( ! empty( $backup['ai'] ) && is_array( $backup['ai'] ) ) {
            update_site_option( 'seoforge_ai', $backup['ai'] );
        }
        self::flush_cache();
        return true;
    }

    public static function sanitize( array $input ) {
        $clean = array();
        foreach ( $input as $key => $value ) {
            switch ( $key ) {
                // Booleans
                case 'tracking_enabled':
                case 'tracking_exclude_admins':
                case 'tracking_exclude_bots':
                case 'tracking_respect_dnt':
                case 'privacy_anonymize_ip':
                case 'privacy_hash_rotate_daily':
                case 'privacy_store_full_url':
                case 'privacy_store_user_agent':
                case 'seo_meta_enabled':
                case 'seo_force_takeover':
                case 'seo_og_enabled':
                case 'seo_twitter_enabled':
                case 'seo_schema_enabled':
                case 'seo_breadcrumbs_enabled':
                case 'seo_canonical_enabled':
                case 'seo_remove_wp_emoji':
                case 'seo_clean_head':
                case 'sitemap_enabled':
                case 'robots_custom_enabled':
                case 'geo_llms_enabled':
                case 'seo_schema_faq_enabled':
                case 'intel_enabled':
                case 'notary_enabled':
                case 'redirects_enabled':
                case 'log_404_enabled':
                case 'auto_redirect_404_attached':
                case 'notify_email_enabled':
                case 'weekly_digest_enabled':
                case 'daily_digest_enabled':
                case 'traffic_spike_alerts':
                case 'traffic_spike_rule_percent_enabled':
                case 'traffic_spike_rule_absolute_enabled':
                case 'delete_on_uninstall':
                case 'persist_settings':
                    $clean[ $key ] = (bool) $value;
                    break;

                // Positive integers
                case 'sitemap_max_per_chunk':
                case 'traffic_spike_percent':
                case 'traffic_spike_absolute':
                case 'traffic_spike_min':
                case 'traffic_spike_baseline_days':
                case 'visit_retention_days':
                case 'session_retention_days':
                case 'log_404_retention_days':
                case 'daily_retention_days':
                case 'audit_retention_days':
                case 'session_timeout_min':
                    $clean[ $key ] = max( 1, (int) $value );
                    break;

                // Enum-ish strings
                case 'traffic_spike_combine':
                    $clean[ $key ] = in_array( $value, array( 'or', 'and' ), true ) ? $value : 'or';
                    break;
                case 'tracking_mode':
                    $clean[ $key ] = in_array( $value, array( 'server', 'beacon', 'hybrid' ), true ) ? $value : 'server';
                    break;
                case 'verify_nginx_log_format':
                    $clean[ $key ] = in_array( $value, array( 'combined', 'combined_xff' ), true ) ? $value : 'combined';
                    break;
                case 'seo_default_twitter_card':
                    $clean[ $key ] = in_array( $value, array( 'summary', 'summary_large_image' ), true ) ? $value : 'summary_large_image';
                    break;

                // Email
                case 'notify_email_to':
                    $email = sanitize_email( $value );
                    if ( $email ) {
                        $clean[ $key ] = $email;
                    }
                    break;

                // Newline-separated lists -> array of strings
                case 'tracking_exclude_paths':
                    $list = is_array( $value ) ? $value : array_map( 'trim', explode( "\n", (string) $value ) );
                    $list = array_filter( array_map( 'sanitize_text_field', $list ) );
                    $clean[ $key ] = array_values( $list );
                    break;

                // Lists of post-type / taxonomy slugs
                case 'sitemap_include_post_types':
                case 'sitemap_include_taxonomies':
                    $list = is_array( $value ) ? $value : array();
                    $list = array_filter( array_map( 'sanitize_key', $list ) );
                    $clean[ $key ] = array_values( $list );
                    break;

                // AI crawler tokens to block — only accept tokens we know.
                case 'geo_blocked_ai_bots':
                    $known = array_keys( Seoforge_Robots::ai_bots() );
                    $list  = is_array( $value ) ? $value : array();
                    $list  = array_values( array_intersect( array_map( 'sanitize_text_field', $list ), $known ) );
                    $clean[ $key ] = $list;
                    break;

                // Multi-line text
                case 'robots_custom_content':
                    $clean[ $key ] = wp_kses_post( $value );
                    break;

                // Structured org
                case 'seo_schema_organization':
                    if ( is_array( $value ) ) {
                        $clean[ $key ] = array(
                            'name' => isset( $value['name'] ) ? sanitize_text_field( $value['name'] ) : '',
                            'url'  => isset( $value['url'] ) ? esc_url_raw( $value['url'] ) : '',
                            'logo' => isset( $value['logo'] ) ? esc_url_raw( $value['logo'] ) : '',
                        );
                    }
                    break;

                // Free-text strings
                case 'privacy_visitor_hash_salt':
                case 'plugin_version':
                case 'intel_register_token':
                case 'verify_nginx_log_path':
                    $clean[ $key ] = sanitize_text_field( (string) $value );
                    break;

                // Intel peer group — enum mirrored from Seoforge_Intel (not Pro-gated, loads in
                // every build). Inline fallback list only matters for a hypothetical minimal
                // build that strips the Intel module entirely.
                case 'intel_vertical':
                    $verticals = class_exists( 'Seoforge_Intel' )
                        ? Seoforge_Intel::verticals()
                        : array( 'education', 'ecommerce', 'blog_media', 'corporate', 'nonprofit', 'government', 'other' );
                    $v             = sanitize_key( (string) $value );
                    $clean[ $key ] = in_array( $v, $verticals, true ) ? $v : 'other';
                    break;

                // Unknown keys ignored
            }
        }
        return $clean;
    }

    public static function flush_cache() {
        self::$cache = array();
    }

    /**
     * One-time cleanup (1.9.0) for sites whose per-blog option already
     * carries a full copy of the network settings from before update() was
     * made sparse (see update() above). Drops any key that's still
     * identical to the current network value, so those sites resume
     * inheriting future network-level changes; a key a site has genuinely
     * customized (differs from network) is left untouched. Idempotent —
     * safe to call more than once. No-op on single-site.
     */
    public static function prune_redundant_overrides() {
        if ( ! is_multisite() ) {
            return;
        }
        $network = (array) get_site_option( SEOFORGE_OPTIONS_KEY, array() );
        if ( empty( $network ) ) {
            return;
        }
        $sites = get_sites( array( 'number' => 0, 'fields' => 'ids' ) );
        foreach ( $sites as $blog_id ) {
            switch_to_blog( (int) $blog_id );
            $site = get_option( SEOFORGE_OPTIONS_KEY, array() );
            if ( is_array( $site ) && ! empty( $site ) ) {
                $pruned = array();
                foreach ( $site as $key => $value ) {
                    if ( ! array_key_exists( $key, $network ) || $network[ $key ] !== $value ) {
                        $pruned[ $key ] = $value;
                    }
                }
                if ( $pruned !== $site ) {
                    update_option( SEOFORGE_OPTIONS_KEY, $pruned );
                }
            }
            restore_current_blog();
        }
        self::flush_cache();
    }
}
