<?php
/**
 * Data-verification suite orchestrator: gap detection, the determinism
 * self-check, and the daily background integrity check + admin notice.
 *
 * Free tier — this is a trust feature, not something to gate behind a
 * license. Manual runs happen from the Verify admin tab
 * (admin/views/page-verify.php); the daily cron here is the unattended half:
 * it stays completely silent when everything checks out, and raises a plain
 * wp-admin notice only when it finds something wrong. No e-mail (explicit
 * instruction — this is not another digest channel).
 *
 * @package Brainwerk SEO Suite
 */

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

class Seoforge_Verify {

    const CRON_HOOK    = 'seoforge_verify_daily';
    const NOTICE_OPTION = 'seoforge_verify_notice';

    /** How many trailing days the daily cron's gap check looks at — bounded work, not a full-history scan every day. */
    const CRON_GAP_WINDOW_DAYS = 90;

    /** Shared nonce for every manual-run form on the Verify tab (gaps/chain/determinism/backfill/upload). */
    const NONCE = 'seoforge_verify_run';

    /** Per-blog UI-state transient (last manual-run results) — short-lived, not a persistent record. */
    const UI_STATE_PREFIX = 'seoforge_verify_ui_';
    const UI_STATE_TTL    = 1800;

    public static function register( Seoforge_Loader $loader ) {
        $loader->add_action( self::CRON_HOOK, __CLASS__, 'run_daily_check' );
        // Self-heal, same pattern as Seoforge_Notifications::maybe_schedule() —
        // Seoforge_History's own cron does NOT self-heal (activation-only), which
        // silently loses the schedule on installs that reached DB v7 via a
        // migration path that skipped activation. Not worth repeating here.
        $loader->add_action( 'init', __CLASS__, 'maybe_schedule' );
        $loader->add_action( 'admin_notices', __CLASS__, 'maybe_notice' );
        $loader->add_action( 'network_admin_notices', __CLASS__, 'maybe_notice' );

        // Manual runs from the Verify tab — each a plain nonce+cap-checked
        // admin-post handler that stores its result in the short-lived UI-state
        // transient and redirects back, same request/response shape as every
        // other admin-post handler in this plugin (see class-seoforge-reports.php).
        $loader->add_action( 'admin_post_seoforge_verify_run_gaps',        __CLASS__, 'handle_run_gaps' );
        $loader->add_action( 'admin_post_seoforge_verify_run_chain',       __CLASS__, 'handle_run_chain' );
        $loader->add_action( 'admin_post_seoforge_verify_run_determinism', __CLASS__, 'handle_run_determinism' );
        $loader->add_action( 'admin_post_seoforge_verify_backfill_gap',    __CLASS__, 'handle_backfill_gap' );
        $loader->add_action( 'admin_post_seoforge_verify_upload_log',      __CLASS__, 'handle_upload_log' );
    }

    public static function maybe_schedule() {
        if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
            // After the 02:40 history capture and the 03:10 intel push (see
            // Seoforge_Activator::schedule_events_for_current_site()) so a gap
            // check run today already sees today's just-captured row.
            wp_schedule_event( strtotime( 'tomorrow 03:30 UTC' ), 'daily', self::CRON_HOOK );
        }
    }

    /* -------------------------------------------------------------- gaps */

    /**
     * Missing captured_on dates for $blog_id in the trailing $days days.
     * One query for existing dates, then a pure diff against the full
     * expected date list — the table is UNIQUE(blog_id, captured_on), so
     * "missing" is exactly "not present", no fuzzy matching needed.
     *
     * @return array{date:string,backfillable:bool,reason:?string}[]
     */
    public static function find_gaps( $blog_id, $days = self::CRON_GAP_WINDOW_DAYS ) {
        global $wpdb;
        $days   = max( 1, (int) $days );
        $today  = gmdate( 'Y-m-d' );
        $since  = gmdate( 'Y-m-d', strtotime( $today ) - DAY_IN_SECONDS * $days );
        $yesterday = gmdate( 'Y-m-d', strtotime( $today ) - DAY_IN_SECONDS );

        $existing = (array) $wpdb->get_col( $wpdb->prepare(
            "SELECT captured_on FROM %i WHERE blog_id = %d AND captured_on >= %s AND captured_on <= %s",
            Seoforge_Utils::table( 'history' ), (int) $blog_id, $since, $yesterday
        ) );

        $visit_retention_days = max( 1, (int) Seoforge_Settings::get( 'visit_retention_days', 365 ) );
        $raw_data_cutoff      = gmdate( 'Y-m-d', time() - DAY_IN_SECONDS * $visit_retention_days );

        return self::build_gap_list( $since, $yesterday, $existing, $raw_data_cutoff );
    }

    /**
     * Pure: [since..until] date range, minus $existing_dates, each flagged
     * backfillable or not based on whether raw visits data for that day is
     * still inside the retention window ($raw_data_cutoff — the oldest date
     * still backfillable; anything before it has aged out of `visits`).
     */
    public static function build_gap_list( $since, $until, array $existing_dates, $raw_data_cutoff ) {
        $existing = array_flip( $existing_dates );
        $gaps     = array();
        $cursor   = strtotime( $since );
        $end      = strtotime( $until );
        while ( $cursor <= $end ) {
            $date = gmdate( 'Y-m-d', $cursor );
            if ( ! isset( $existing[ $date ] ) ) {
                $backfillable = $date >= $raw_data_cutoff;
                $gaps[] = array(
                    'date'         => $date,
                    'backfillable' => $backfillable,
                    'reason'       => $backfillable ? null : 'raw_data_aged_out',
                );
            }
            $cursor += DAY_IN_SECONDS;
        }
        return $gaps;
    }

    /**
     * Backfill one gap day by simply calling the existing capture path —
     * no separate capture logic to keep in sync with Seoforge_History's.
     * Refuses (returns false) for a day this class itself flagged as not
     * backfillable, so a caller can't accidentally fabricate a day's
     * aggregate from data that no longer exists (capture_day() would just
     * silently produce a zero/near-zero row in that case, which would look
     * like real history but isn't).
     */
    public static function backfill_gap( $blog_id, $date, $backfillable ) {
        if ( ! $backfillable ) {
            return false;
        }
        Seoforge_History::capture_day( $date, $blog_id );
        return true;
    }

    /* -------------------------------------------------------- determinism */

    /**
     * Recompute one day's history row twice from the same raw data and
     * assert the canonical content is byte-identical. Never writes to the
     * database — calls the pure Seoforge_History::build_row() directly, not
     * capture_day(). A pass proves the capture pipeline has no hidden
     * nondeterminism (e.g. an unstable array-key order, a time()-dependent
     * branch); it does NOT re-verify the hash chain — that's verify()'s job.
     *
     * @return array{ok:bool,first:string,second:string}
     */
    public static function check_determinism( $blog_id, $date ) {
        $first  = Seoforge_History_Chain::canonicalize( Seoforge_History::build_row( $date, $blog_id ) );
        $second = Seoforge_History_Chain::canonicalize( Seoforge_History::build_row( $date, $blog_id ) );
        return array(
            'ok'     => ( $first === $second ),
            'first'  => $first,
            'second' => $second,
        );
    }

    /* -------------------------------------------------------------- cron */

    /**
     * Bounded daily check: gaps in the trailing 90 days + hash-chain
     * integrity, for the current blog only (per-site cron, same as
     * Seoforge_History::capture_yesterday() — Multisite runs this once per
     * site via its own per-blog scheduled event, not a cross-network scan).
     * Silent when clean; writes a findings summary to a site option only
     * when something's actually wrong, which maybe_notice() then renders.
     */
    public static function run_daily_check() {
        $blog_id = Seoforge_Utils::current_blog_id();
        $gaps    = self::find_gaps( $blog_id, self::CRON_GAP_WINDOW_DAYS );
        $chain   = Seoforge_History_Chain::verify( $blog_id );

        // Piggybacks on this same per-blog daily cron rather than registering a
        // second one (see Seoforge_Notary's class docblock) — reuses the chain
        // verify() result already computed above instead of re-querying it.
        // No-op (cheap opted_in() check) when notarization isn't enabled.
        if ( class_exists( 'Seoforge_Notary' ) ) {
            Seoforge_Notary::cron_push( $blog_id, $chain );
        }

        if ( empty( $gaps ) && $chain['ok'] ) {
            delete_option( self::NOTICE_OPTION );
            return;
        }

        update_option( self::NOTICE_OPTION, array(
            'blog_id'    => $blog_id,
            'gap_count'  => count( $gaps ),
            'chain_ok'   => $chain['ok'],
            'break_date' => $chain['ok'] ? null : $chain['break']['date'],
            'checked_at' => time(),
        ) );
    }

    public static function maybe_notice() {
        if ( ! current_user_can( 'manage_options' ) && ! current_user_can( 'manage_network_options' ) ) {
            return;
        }
        $state = get_option( self::NOTICE_OPTION );
        if ( empty( $state ) || ! is_array( $state ) ) {
            return;
        }

        $verify_url = Seoforge_Admin::tab_url( 'verify' );
        echo '<div class="notice notice-warning seoforge-verify-notice"><p>';
        if ( ! empty( $state['gap_count'] ) ) {
            printf(
                /* translators: %d: number of missing days found in the trailing 90-day window */
                esc_html( _n(
                    'Brainwerk SEO Suite: %d day is missing from your site history.',
                    'Brainwerk SEO Suite: %d days are missing from your site history.',
                    (int) $state['gap_count'],
                    'brainwerk-seo-suite'
                ) ),
                (int) $state['gap_count']
            );
            echo ' ';
        }
        if ( empty( $state['chain_ok'] ) ) {
            printf(
                /* translators: %s: the date the integrity chain break was detected at */
                esc_html__( 'The history integrity chain shows a break at %s.', 'brainwerk-seo-suite' ),
                esc_html( (string) $state['break_date'] )
            );
            echo ' ';
        }
        printf(
            '<a href="%s">%s</a>',
            esc_url( $verify_url ),
            esc_html__( 'Review in the Verify tab', 'brainwerk-seo-suite' )
        );
        echo '</p></div>';
    }

    /* ------------------------------------------------------ manual-run UI */

    /**
     * Read the current blog's manual-run UI state (results of the last gaps/
     * chain/determinism/backfill/upload run this admin triggered), or an
     * empty array if nothing has run yet in the last self::UI_STATE_TTL
     * seconds. Deliberately a transient, not an option — this is
     * request-to-request UI scratch state for the Verify tab, not a record
     * worth persisting indefinitely (that's what the chain/history tables
     * themselves already are).
     */
    public static function get_ui_state( $blog_id ) {
        $state = get_transient( self::UI_STATE_PREFIX . (int) $blog_id );
        return is_array( $state ) ? $state : array();
    }

    /** Merge $partial into the current blog's UI state (see get_ui_state()). */
    public static function merge_ui_state( $blog_id, array $partial ) {
        $state = array_merge( self::get_ui_state( $blog_id ), $partial );
        set_transient( self::UI_STATE_PREFIX . (int) $blog_id, $state, self::UI_STATE_TTL );
        return $state;
    }

    /**
     * Resolve which blog a manual-run form targets. Every one of these forms
     * carries a `target_blog_id` field (see admin/views/page-verify.php); on
     * single-site, or for any user who isn't allowed to view network scope,
     * that field is ignored and the viewer's own current blog is used no
     * matter what was posted — same trust boundary as
     * Seoforge_Utils::stats_scope() applies to read-only analytics scope.
     * Gap/chain/determinism checks only ever SELECT from the shared
     * wp_seoforge_history table by blog_id (no switch_to_blog() needed —
     * that table isn't a per-site wp_options-style store), so this is safe
     * to resolve without ever touching another site's own database context.
     */
    private static function resolve_target_blog_id() {
        $current = Seoforge_Utils::current_blog_id();
        if ( ! is_multisite() || ! Seoforge_Utils::can_view_network_scope() ) {
            return $current;
        }
        $requested = isset( $_POST['target_blog_id'] ) ? (int) $_POST['target_blog_id'] : $current; // phpcs:ignore WordPress.Security.NonceVerification.Missing
        if ( $requested === $current ) {
            return $current;
        }
        return get_site( $requested ) ? $requested : $current;
    }

    private static function guard_manual_run() {
        check_admin_referer( self::NONCE );
        if ( ! current_user_can( 'manage_options' ) && ! current_user_can( 'manage_network_options' ) ) {
            wp_die( esc_html__( 'You do not have permission to do this.', 'brainwerk-seo-suite' ), 403 );
        }
    }

    private static function redirect_back( array $extra_args = array() ) {
        wp_safe_redirect( add_query_arg( $extra_args, Seoforge_Admin::tab_url( 'verify' ) ) );
        exit;
    }

    /** Manual gap-detection run. POST: target_blog_id (optional), days (default CRON_GAP_WINDOW_DAYS). */
    public static function handle_run_gaps() {
        self::guard_manual_run();
        $blog_id = self::resolve_target_blog_id();
        $days    = isset( $_POST['days'] ) ? max( 1, min( 1095, (int) $_POST['days'] ) ) : self::CRON_GAP_WINDOW_DAYS;

        $gaps = self::find_gaps( $blog_id, $days );
        self::merge_ui_state( $blog_id, array(
            'gaps'       => $gaps,
            'gaps_days'  => $days,
            'gaps_at'    => time(),
        ) );
        self::redirect_back( array( 'seoforge_verify_ran' => 'gaps' ) );
    }

    /** Manual chain-integrity verify run. POST: target_blog_id (optional). */
    public static function handle_run_chain() {
        self::guard_manual_run();
        $blog_id = self::resolve_target_blog_id();

        $result = Seoforge_History_Chain::verify( $blog_id );
        self::merge_ui_state( $blog_id, array(
            'chain'    => $result,
            'chain_at' => time(),
        ) );
        self::redirect_back( array( 'seoforge_verify_ran' => 'chain' ) );
    }

    /** Manual determinism self-check. POST: target_blog_id (optional), date (default yesterday). */
    public static function handle_run_determinism() {
        self::guard_manual_run();
        $blog_id = self::resolve_target_blog_id();
        $date    = isset( $_POST['date'] ) ? sanitize_text_field( wp_unslash( $_POST['date'] ) ) : gmdate( 'Y-m-d', time() - DAY_IN_SECONDS );
        if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ) {
            $date = gmdate( 'Y-m-d', time() - DAY_IN_SECONDS );
        }

        $result = self::check_determinism( $blog_id, $date );
        self::merge_ui_state( $blog_id, array(
            'determinism'      => array( 'ok' => $result['ok'], 'date' => $date ),
            'determinism_at'   => time(),
        ) );
        self::redirect_back( array( 'seoforge_verify_ran' => 'determinism' ) );
    }

    /**
     * Backfill one gap day, then immediately re-run find_gaps() over the same
     * window so the gap list shown next redisplays without the backfilled day
     * still listed as missing — a bare redirect without this would leave the
     * UI showing a day the admin just fixed as still broken until they
     * manually re-ran the gap check.
     */
    public static function handle_backfill_gap() {
        self::guard_manual_run();
        $blog_id = self::resolve_target_blog_id();
        $date    = isset( $_POST['date'] ) ? sanitize_text_field( wp_unslash( $_POST['date'] ) ) : '';
        if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ) {
            self::redirect_back( array( 'seoforge_verify_ran' => 'backfill_bad_date' ) );
        }

        $visit_retention_days = max( 1, (int) Seoforge_Settings::get( 'visit_retention_days', 365 ) );
        $raw_data_cutoff      = gmdate( 'Y-m-d', time() - DAY_IN_SECONDS * $visit_retention_days );
        $backfillable         = $date >= $raw_data_cutoff;

        $ok          = self::backfill_gap( $blog_id, $date, $backfillable );
        $prior_state = self::get_ui_state( $blog_id );
        $days        = isset( $prior_state['gaps_days'] ) ? max( 1, (int) $prior_state['gaps_days'] ) : self::CRON_GAP_WINDOW_DAYS;
        $gaps        = self::find_gaps( $blog_id, $days );
        self::merge_ui_state( $blog_id, array(
            'gaps'      => $gaps,
            'gaps_days' => $days,
            'gaps_at'   => time(),
        ) );
        self::redirect_back( array( 'seoforge_verify_ran' => $ok ? 'backfilled' : 'backfill_refused' ) );
    }

    /**
     * Store an uploaded nginx log file (the fallback for admins whose
     * PHP-FPM user can't read the real log path — see
     * Seoforge_Log_Reconciler::resolve_uploaded_path()'s docblock). Only
     * .log/.gz accepted, stored under the plugin's private upload subdir.
     * The stored filename is remembered in this blog's UI state so the
     * reconcile AJAX endpoint (Seoforge_Ajax::ajax_verify_reconcile()) knows
     * which file to stream without trusting a client-supplied path directly.
     */
    public static function handle_upload_log() {
        self::guard_manual_run();
        $blog_id = Seoforge_Utils::current_blog_id();

        if ( empty( $_FILES['verify_log_file']['tmp_name'] ) || ! is_uploaded_file( $_FILES['verify_log_file']['tmp_name'] ) ) {
            self::redirect_back( array( 'seoforge_verify_ran' => 'upload_failed' ) );
        }
        $original = sanitize_file_name( wp_unslash( $_FILES['verify_log_file']['name'] ) );
        $ext      = strtolower( (string) pathinfo( $original, PATHINFO_EXTENSION ) );
        if ( ! in_array( $ext, array( 'log', 'gz' ), true ) ) {
            self::redirect_back( array( 'seoforge_verify_ran' => 'upload_bad_type' ) );
        }
        $filetype = wp_check_filetype( $original, array( 'log' => 'text/plain', 'gz' => 'application/gzip' ) );
        if ( empty( $filetype['ext'] ) ) {
            self::redirect_back( array( 'seoforge_verify_ran' => 'upload_bad_type' ) );
        }

        $dir       = Seoforge_Log_Reconciler::private_upload_dir();
        $dest_name = 'blog-' . (int) $blog_id . '-' . time() . '-' . $original;
        $dest      = rtrim( $dir, '/\\' ) . '/' . $dest_name;
        if ( ! move_uploaded_file( $_FILES['verify_log_file']['tmp_name'], $dest ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions
            self::redirect_back( array( 'seoforge_verify_ran' => 'upload_failed' ) );
        }

        self::merge_ui_state( $blog_id, array( 'uploaded_log_filename' => $dest_name ) );
        self::redirect_back( array( 'seoforge_verify_ran' => 'uploaded' ) );
    }
}
