<?php

declare(strict_types=1);

namespace Numinix\SeekmodoWordPress\Gateway;

/**
 * Sliding-window counter for `signature_mismatch` 401 responses
 * from `mcp.seekmodo.com`.
 *
 * Origin: docs/follow-ups/connector-signature-mismatch-repair.md
 * (audit P-G self-recovery, 2026-06-10). The connector has always
 * fallen back gracefully on a 401 (`Client::post()` returns null,
 * the caller renders native WP search), but a merchant whose
 * shared-secret has drifted -- because they rotated >10 min before
 * re-pairing, or their snapshot cache was warmer than the gateway's
 * 10-minute previous-secret grace window -- would stay broken
 * forever without an obvious user-visible signal. The fix surfaces
 * an admin notice that points straight at the Re-pair button once
 * the failure count crosses the threshold, then clears itself the
 * moment a 2xx returns.
 *
 * Design constraints:
 *   - Storage in a single WP option (no schema, no migrations).
 *   - Sliding window enforced on every record() call so a stale
 *     row from a transient outage doesn't trip the notice forever.
 *   - Threshold + window are filterable so an operator can tune
 *     them per-tenant without forking the plugin.
 *   - All public methods are no-ops if the WP option API is
 *     missing (defensive for CLI/test contexts).
 */
final class SignatureMismatchTracker
{
    public const OPT_FAILURES = 'seekmodo_wp_sigmismatch_failures';

    /**
     * Default sliding window. 3 failures within 5 minutes trips
     * the notice -- matches the original follow-up doc's
     * acceptance criterion.
     */
    private const DEFAULT_WINDOW_SECONDS = 300;
    private const DEFAULT_THRESHOLD      = 3;

    /**
     * Record a `signature_mismatch` 401 from the gateway. Prunes
     * out-of-window entries on every call so the option's payload
     * stays small even under a flapping-gateway scenario.
     */
    public static function recordFailure(): void
    {
        if (!function_exists('get_option') || !function_exists('update_option')) {
            return;
        }
        $now = time();
        $rows = self::loadAndPrune($now);
        $rows[] = $now;
        update_option(self::OPT_FAILURES, $rows, false);
    }

    /**
     * Clear the failure history. Called from {@see Client::post()}
     * on every 2xx so a recovered tenant doesn't have to wait for
     * the sliding window to age the rows out.
     */
    public static function clear(): void
    {
        if (!function_exists('update_option')) {
            return;
        }
        update_option(self::OPT_FAILURES, [], false);
    }

    /**
     * True when the count of in-window failures meets or exceeds
     * the threshold. The notice in {@see \Numinix\SeekmodoWordPress\
     * Admin\SettingsPage} consults this on every admin page render.
     */
    public static function isTripped(): bool
    {
        if (!function_exists('get_option')) {
            return false;
        }
        $now = time();
        $rows = self::loadAndPrune($now);
        $threshold = self::threshold();

        return count($rows) >= $threshold;
    }

    /**
     * Read + prune the option contents in one pass. Always writes
     * the pruned set back to the option so a long-running site
     * doesn't accumulate dead rows.
     *
     * @return array<int,int>
     */
    private static function loadAndPrune(int $now): array
    {
        $window = self::windowSeconds();
        $cutoff = $now - $window;
        $stored = get_option(self::OPT_FAILURES, []);
        if (!is_array($stored)) {
            $stored = [];
        }
        $pruned = [];
        foreach ($stored as $ts) {
            $ts_int = is_numeric($ts) ? (int) $ts : 0;
            if ($ts_int >= $cutoff) {
                $pruned[] = $ts_int;
            }
        }
        if ($pruned !== $stored && function_exists('update_option')) {
            update_option(self::OPT_FAILURES, $pruned, false);
        }

        return $pruned;
    }

    private static function windowSeconds(): int
    {
        $value = function_exists('apply_filters')
            ? apply_filters('seekmodo_sigmismatch_window_seconds', self::DEFAULT_WINDOW_SECONDS)
            : self::DEFAULT_WINDOW_SECONDS;

        return is_int($value) && $value > 0 ? $value : self::DEFAULT_WINDOW_SECONDS;
    }

    private static function threshold(): int
    {
        $value = function_exists('apply_filters')
            ? apply_filters('seekmodo_sigmismatch_threshold', self::DEFAULT_THRESHOLD)
            : self::DEFAULT_THRESHOLD;

        return is_int($value) && $value > 0 ? $value : self::DEFAULT_THRESHOLD;
    }
}
