<?php

declare(strict_types=1);

namespace Numinix\SeekmodoWordPress\Gateway;

use Numinix\SeekmodoWordPress\Config\Options;

/**
 * Per-tenant circuit breaker for the gateway client.
 *
 * State machine (matching the Zen Cart connector / AKS connector):
 *
 *   closed  -- 5 failures in <30s --> open (60s)
 *   open    -- 60s elapsed         --> half_open (let one probe through)
 *   half_open -- probe succeeds    --> closed
 *   half_open -- probe fails       --> open (another 60s)
 *
 * Storage: a single `seekmodo_wp_breaker_state` `wp_options` row. The
 * cost of read+write per gateway call is one autoloaded option fetch +
 * one option update — same surface as the Zen Cart connector's local
 * `configuration` row. We're already paying that cost for the mode
 * check, so the breaker comes free.
 *
 * Thresholds (5 / 30s / 60s) are filter-overridable via
 * `seekmodo_breaker_thresholds`; the gateway can also push remote
 * overrides through `tenant.snapshot`.
 */
final class CircuitBreaker
{
    public const STATE_CLOSED    = 'closed';
    public const STATE_OPEN      = 'open';
    public const STATE_HALF_OPEN = 'half_open';

    private const DEFAULT_FAIL_THRESHOLD = 5;
    private const DEFAULT_WINDOW_SEC     = 30;
    private const DEFAULT_OPEN_SEC       = 60;

    /**
     * Read-only check used inside the request hot-path. Mutates state
     * lazily: an `open` breaker whose 60s window has elapsed is
     * transitioned to `half_open` here so the next call sees a probe
     * opportunity.
     */
    public static function isOpen(): bool
    {
        $state = self::load();
        $now   = time();

        if ($state['state'] === self::STATE_OPEN) {
            $thresholds = self::thresholds();
            if (($state['opened_at'] ?? 0) + $thresholds['open_sec'] <= $now) {
                self::save([
                    'state'     => self::STATE_HALF_OPEN,
                    'failures'  => [],
                    'opened_at' => $state['opened_at'] ?? 0,
                ]);

                return false;
            }

            return true;
        }

        return false;
    }

    public static function state(): string
    {
        $s = self::load();

        return (string) ($s['state'] ?? self::STATE_CLOSED);
    }

    public static function recordSuccess(): void
    {
        $state = self::load();
        if ($state['state'] !== self::STATE_CLOSED) {
            self::save(['state' => self::STATE_CLOSED, 'failures' => [], 'opened_at' => 0]);
        }
    }

    /**
     * Record a 5xx (or transport-level) failure. If the failure pushes
     * us past the rolling threshold, transition to `open`.
     */
    public static function recordFailure(): void
    {
        $state      = self::load();
        $thresholds = self::thresholds();
        $now        = time();

        // Already open. Don't record additional failures — the
        // breaker is doing its job. Recording here would also
        // overwrite `opened_at` via the closed-state fall-through
        // save, prematurely demoting the breaker back to half-open
        // on the next isOpen() call. (Reproduced in
        // ClientTest::testFiveHundredsTripTheBreaker before this
        // guard was added: the in-flight retry attempts kept
        // recording failures even after the 5th call opened the
        // breaker, and the side effect was opened_at=0 -> isOpen()
        // returns false.)
        if ($state['state'] === self::STATE_OPEN) {
            return;
        }

        // Half-open + failure → straight back to open.
        if ($state['state'] === self::STATE_HALF_OPEN) {
            self::save(['state' => self::STATE_OPEN, 'failures' => [], 'opened_at' => $now]);

            return;
        }

        $failures = is_array($state['failures'] ?? null) ? $state['failures'] : [];
        $failures = array_filter($failures, static fn ($ts) => is_int($ts) && $ts > $now - $thresholds['window_sec']);
        $failures[] = $now;

        if (count($failures) >= $thresholds['fail_threshold']) {
            self::save(['state' => self::STATE_OPEN, 'failures' => [], 'opened_at' => $now]);

            return;
        }

        self::save(['state' => self::STATE_CLOSED, 'failures' => array_values($failures), 'opened_at' => 0]);
    }

    /**
     * Force-close. Used by the admin "Reset breaker" button and by
     * `wp seekmodo health-reset`.
     */
    public static function forceClose(): void
    {
        self::save(['state' => self::STATE_CLOSED, 'failures' => [], 'opened_at' => 0]);
    }

    /**
     * @return array{state:string,failures:array<int,int>,opened_at:int}
     */
    private static function load(): array
    {
        $raw = get_option(Options::OPT_BREAKER_STATE, []);
        if (!is_array($raw)) {
            $raw = [];
        }

        return [
            'state'     => isset($raw['state']) ? (string) $raw['state'] : self::STATE_CLOSED,
            'failures'  => isset($raw['failures']) && is_array($raw['failures']) ? $raw['failures'] : [],
            'opened_at' => isset($raw['opened_at']) ? (int) $raw['opened_at'] : 0,
        ];
    }

    /**
     * @param array{state:string,failures:array<int,int>,opened_at:int} $state
     */
    private static function save(array $state): void
    {
        update_option(Options::OPT_BREAKER_STATE, $state);
    }

    /**
     * @return array{fail_threshold:int,window_sec:int,open_sec:int}
     */
    private static function thresholds(): array
    {
        $defaults = [
            'fail_threshold' => self::DEFAULT_FAIL_THRESHOLD,
            'window_sec'     => self::DEFAULT_WINDOW_SEC,
            'open_sec'       => self::DEFAULT_OPEN_SEC,
        ];
        $merged = apply_filters('seekmodo_breaker_thresholds', $defaults);

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