<?php

declare(strict_types=1);

namespace Numinix\SeekmodoWordPress\Mode;

use Numinix\SeekmodoWordPress\Config\Options;

/**
 * Auto-promoter for the `active` mode.
 *
 * Same FSM as the AKS connector. Three rolling gauges, each computed
 * over a 5-min window:
 *
 *   - p95 latency (ms) of gateway responses
 *   - 5xx rate (count / total)
 *   - fallback rate (count of times we fell back to native / total)
 *
 * Thresholds (filter-overridable via `seekmodo_autopromote_thresholds`):
 *   - promote_p95_ms     = 300
 *   - promote_5xx_rate   = 0.01   (1%)
 *   - promote_fallback   = 0.02   (2%)
 *   - demote_p95_ms      = 500    (2x to avoid flapping)
 *   - demote_5xx_rate    = 0.05   (5%)
 *   - demote_fallback    = 0.10   (10%)
 *   - window_sec         = 300    (5 min)
 *   - min_samples        = 20     (don't flap on <20 obs)
 *
 * Transitions (only when mode=active and auto_promote=true):
 *   shadow  -> enforce: all 3 gauges below the *promote* thresholds for
 *                       sustained 5 min.
 *   enforce -> shadow:  any gauge breaches the *demote* threshold for
 *                       sustained 2 min.
 *
 * Storage: a single autoloaded `seekmodo_wp_health_metrics` option
 * holding a ring buffer (FIFO) of up to 200 samples. Each sample is
 * `[ts, latency_ms, http_code, fell_back]`.
 *
 * This class is hot-path adjacent (recordRequest fires from Client::*
 * on every gateway call). All option writes use `autoload=no` and the
 * ring buffer is bounded so the option payload stays under a few KB.
 */
final class AutoPromoter
{
    private const DEFAULTS = [
        'promote_p95_ms'   => 300,
        'promote_5xx_rate' => 0.01,
        'promote_fallback' => 0.02,
        'demote_p95_ms'    => 500,
        'demote_5xx_rate'  => 0.05,
        'demote_fallback'  => 0.10,
        'window_sec'       => 300,
        'min_samples'      => 20,
        'demote_window'    => 120,
    ];

    private const MAX_SAMPLES = 200;

    /**
     * Append a fresh observation and evaluate the FSM. Called from
     * Client::dispatchWithRetries() on every gateway round-trip.
     */
    public static function recordRequest(int $latency_ms, int $http_code, bool $fell_back): void
    {
        $samples = self::load();
        $now     = time();

        $samples[] = [
            'ts'         => $now,
            'latency_ms' => $latency_ms,
            'http_code'  => $http_code,
            'fell_back'  => $fell_back ? 1 : 0,
        ];

        $samples = self::evict($samples, $now);
        self::save($samples);

        self::evaluateFsm($now);
    }

    /**
     * Cron-driven tick (Sprint 2 also wires this into the
     * `seekmodo_wp_index_tick` hook so the FSM doesn't drift if there's
     * been no recent traffic).
     */
    public static function tick(): void
    {
        self::evaluateFsm(time());
    }

    /**
     * @return array<string,float|int|bool>
     */
    public static function metrics(): array
    {
        $samples = self::load();
        $now     = time();
        $window  = self::thresholds()['window_sec'];
        $samples = array_filter($samples, static fn ($s) => is_array($s) && ($s['ts'] ?? 0) >= $now - $window);
        $samples = array_values($samples);
        $n       = count($samples);
        if ($n === 0) {
            return ['samples' => 0, 'p95_ms' => 0, 'p50_ms' => 0, 'r5xx' => 0.0, 'rfb' => 0.0];
        }

        $latencies = array_map(static fn ($s) => (int) $s['latency_ms'], $samples);
        sort($latencies);
        $p50 = $latencies[(int) floor($n * 0.50)] ?? 0;
        $p95 = $latencies[(int) min($n - 1, floor($n * 0.95))] ?? 0;
        $errs = array_filter($samples, static fn ($s) => (int) $s['http_code'] >= 500);
        $fbs  = array_filter($samples, static fn ($s) => !empty($s['fell_back']));

        return [
            'samples' => $n,
            'p50_ms'  => $p50,
            'p95_ms'  => $p95,
            'r5xx'    => round(count($errs) / $n, 4),
            'rfb'     => round(count($fbs) / $n, 4),
        ];
    }

    // -------------------------------------------------------------------
    // FSM
    // -------------------------------------------------------------------

    private static function evaluateFsm(int $now): void
    {
        // Only the operator-set `active` mode participates in the
        // promote / demote FSM. The other top-level modes (`off`,
        // `shadow`, `enforce`) are explicit operator intent and must
        // not be silently flipped:
        //   - off:     connector is disabled.
        //   - shadow:  operator wants to bake-in metrics without ever
        //              influencing visitor traffic (e.g. pre-cutover).
        //   - enforce: operator has manually pinned full enforce; the
        //              auto-promoter never demotes a manual pin
        //              because the operator explicitly opted out of
        //              the safety hop.
        //
        // `active` is the *only* mode that auto-bakes its substate
        // (`shadow` -> `enforce`) on healthy metrics, and demotes back
        // on a breach. See `effectiveMode()` for the projection
        // consumed by the swap-points.
        $mode = ModeFsm::current();
        if ($mode !== ModeFsm::MODE_ACTIVE) {
            return;
        }
        if (!Options::autoPromoteEnabled()) {
            return;
        }

        $m = self::metrics();
        if ((int) $m['samples'] < self::thresholds()['min_samples']) {
            return;
        }

        $t = self::thresholds();
        $under_promote = $m['p95_ms']  <= $t['promote_p95_ms']
            && (float) $m['r5xx'] <= $t['promote_5xx_rate']
            && (float) $m['rfb']  <= $t['promote_fallback'];

        $over_demote = $m['p95_ms']    >  $t['demote_p95_ms']
            || (float) $m['r5xx']      >  $t['demote_5xx_rate']
            || (float) $m['rfb']       >  $t['demote_fallback'];

        $substate = self::currentSubstate();

        if ($substate === 'shadow' && $under_promote) {
            self::setSubstate('enforce', $now);

            return;
        }

        if ($substate === 'enforce' && $over_demote) {
            self::setSubstate('shadow', $now);
        }
    }

    /**
     * Auto-promoter substate. Only meaningful when MODE=active; in
     * every other mode, `effectiveMode()` returns the headline mode
     * directly and the substate is ignored. Defaults to `shadow` so
     * a freshly-activated tenant starts in the safe (no-impact)
     * substate and only flips to `enforce` once the FSM has seen
     * enough healthy samples.
     */
    public static function currentSubstate(): string
    {
        $state = get_option('seekmodo_wp_autopromote_state', []);
        $sub   = is_array($state) ? (string) ($state['substate'] ?? 'shadow') : 'shadow';

        return $sub === 'enforce' ? 'enforce' : 'shadow';
    }

    /**
     * Effective auto-promoter thresholds (filter-overridable via
     * `seekmodo_autopromote_thresholds`). Public so the CLI's
     * cutover-readiness gate can use the same values that the
     * promoter itself uses.
     *
     * @return array<string,float|int>
     */
    public static function thresholds(): array
    {
        $merged = apply_filters('seekmodo_autopromote_thresholds', self::DEFAULTS);

        return is_array($merged) ? array_merge(self::DEFAULTS, $merged) : self::DEFAULTS;
    }

    /**
     * Persist the auto-promoter substate. The operator-set MODE in
     * Options::OPT_MODE stays the headline value; the substate adjusts
     * the effective behavior of `active` between shadow + enforce.
     */
    private static function setSubstate(string $substate, int $now): void
    {
        $state = get_option('seekmodo_wp_autopromote_state', []);
        if (!is_array($state)) {
            $state = [];
        }
        if (($state['substate'] ?? null) === $substate) {
            return;
        }
        $state['substate']        = $substate;
        $state['transitioned_at'] = $now;
        update_option('seekmodo_wp_autopromote_state', $state, false);
    }

    /**
     * Projection of the operator-set headline mode through the
     * AutoPromoter substate. Swap-points (search, typeahead, beacon)
     * MUST call this rather than `ModeFsm::current()` so that they
     * honor the substate FSM:
     *
     *   - MODE=off                            -> off
     *   - MODE=shadow                         -> shadow
     *   - MODE=enforce                        -> enforce
     *   - MODE=active, substate=shadow        -> shadow
     *   - MODE=active, substate=enforce       -> enforce
     *
     * `auto_promote=false` collapses `active` to a hard `enforce`
     * because the operator has explicitly opted out of the safety
     * hop. This matches the AKS / Zen Cart connector behavior.
     */
    public static function effectiveMode(): string
    {
        $headline = ModeFsm::current();
        if ($headline !== ModeFsm::MODE_ACTIVE) {
            return $headline;
        }
        if (!Options::autoPromoteEnabled()) {
            return ModeFsm::MODE_ENFORCE;
        }

        return self::currentSubstate() === 'enforce'
            ? ModeFsm::MODE_ENFORCE
            : ModeFsm::MODE_SHADOW;
    }

    // -------------------------------------------------------------------
    // Storage
    // -------------------------------------------------------------------

    /**
     * @return array<int,array<string,int>>
     */
    private static function load(): array
    {
        $raw = get_option(Options::OPT_HEALTH_METRICS, []);

        return is_array($raw) ? array_values(array_filter($raw, 'is_array')) : [];
    }

    /**
     * @param array<int,array<string,int>> $samples
     */
    private static function save(array $samples): void
    {
        update_option(Options::OPT_HEALTH_METRICS, array_values($samples), false);
    }

    /**
     * @param array<int,array<string,int>> $samples
     * @return array<int,array<string,int>>
     */
    private static function evict(array $samples, int $now): array
    {
        $window = self::thresholds()['window_sec'];
        $samples = array_filter($samples, static fn ($s) => ($s['ts'] ?? 0) >= $now - $window);
        $samples = array_values($samples);
        if (count($samples) > self::MAX_SAMPLES) {
            $samples = array_slice($samples, -self::MAX_SAMPLES);
        }

        return $samples;
    }
}
