<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Ai;

/**
 * The one answer to "is the AI working?", derived at read time.
 *
 * Three surfaces render this same object — the KI tab's banner, the Start checklist row, and the
 * `get_ai_status` MCP tool — which is the whole point: a merchant asking their assistant and a
 * merchant looking at the admin must never be told two different things.
 *
 * ## Why derived and not stored
 *
 * A stored verdict ages badly. "Gestört" depends on how long ago the last failure was and whether a
 * success exists within 24 hours; "Eingeschränkt" depends on today's spend against a budget the
 * merchant may have changed a minute ago. So {@see AiStatusRecord} stores timestamped facts and
 * this class judges them against the clock, the configuration and the budget every time.
 *
 * ## The 24-hour rule
 *
 * A failure with a success behind it inside 24 hours is *degraded*, not *down* — providers have
 * blips, and painting a red banner for one 500 teaches merchants to ignore the banner. The one
 * exception is an auth failure: a rejected key is never transient, it is a key that has been
 * revoked or mistyped, and it is red immediately no matter how well things worked yesterday.
 */
final class AiStatus implements \JsonSerializable
{
    public const STATE_NONE = 'none';

    public const STATE_ACTIVE = 'active';

    public const STATE_DEGRADED = 'degraded';

    public const STATE_DOWN = 'down';

    /** How long a success keeps a later failure out of the red. */
    public const RECENT_SUCCESS_WINDOW = 86400;

    /** How old the record may get before a viewing surface spends a call to refresh it. */
    public const RECHECK_TTL = 900;

    /**
     * @param string        $state    one of the STATE_* constants
     * @param list<string>  $missing  gettext keys (`ai.missing.*`) for what the merchant still has
     *                                to fill in — only ever non-empty in STATE_NONE
     * @param list<string>  $notices  gettext keys (`ai.status.*`) explaining a non-green state
     * @param string        $errorKey the mapped `ai.err.*` key for the last failure, or ''
     */
    public function __construct(
        public readonly string $state,
        public readonly array $missing = [],
        public readonly array $notices = [],
        public readonly string $errorKey = '',
        public readonly string $errorDetail = '',
        public readonly ?string $checkedAt = null,
        public readonly int $spentTodayMicros = 0,
    ) {
    }

    /**
     * @param int $spentTodayMicros from {@see AiUsageRepository::spentTodayMicros()}
     * @param int $runsThisHour     from {@see AiUsageRepository::runsThisHour()}
     * @param int|null $now         unix timestamp, injected by tests
     */
    public static function evaluate(
        AiConfiguration $config,
        AiStatusRecord $record,
        AiBudget $budget,
        int $spentTodayMicros = 0,
        int $runsThisHour = 0,
        ?int $now = null,
    ): self {
        $now = $now ?? \time();

        if (!$config->isComplete()) {
            return new self(
                state: self::STATE_NONE,
                missing: self::missingKeys($config),
                checkedAt: $record->lastCheckedAt,
                spentTodayMicros: $spentTodayMicros,
            );
        }

        $notices = self::budgetNotices($budget, $spentTodayMicros, $runsThisHour);
        // A non-default API address is worth surfacing even when everything works: it is the single
        // most common reason for "the shop is talking to something I did not expect".
        if ($config->baseUrl !== '') {
            $notices[] = 'ai.status.baseurl_notice';
        }

        if ($record->lastSignalFailed()) {
            $errorKey = self::errorKey($record->lastErrorKind);

            return new self(
                state: self::isTransient($record, $now) ? self::STATE_DEGRADED : self::STATE_DOWN,
                notices: $notices,
                errorKey: $errorKey,
                errorDetail: $record->lastErrorMessage,
                checkedAt: $record->lastCheckedAt,
                spentTodayMicros: $spentTodayMicros,
            );
        }

        return new self(
            // A budget notice alone is enough to leave green: AI actions really are refused while
            // the limit holds, and a green banner over a paused connector is a lie.
            state: $notices === [] ? self::STATE_ACTIVE : self::STATE_DEGRADED,
            notices: $notices,
            checkedAt: $record->lastCheckedAt,
            spentTodayMicros: $spentTodayMicros,
        );
    }

    public function isUsable(): bool
    {
        return $this->state === self::STATE_ACTIVE || $this->state === self::STATE_DEGRADED;
    }

    /** The gettext key of the state's own word, e.g. `ai.status.active`. */
    public function labelKey(): string
    {
        return 'ai.status.' . $this->state;
    }

    /**
     * A failure that a success within the window vouches for. An auth failure never qualifies —
     * a rejected credential does not fix itself, so it is red from the first occurrence.
     */
    private static function isTransient(AiStatusRecord $record, int $now): bool
    {
        if ($record->lastErrorKind === AiStatusRecord::KIND_AUTH) {
            return false;
        }

        if ($record->lastOkAt === null) {
            return false;
        }

        $okAt = \strtotime($record->lastOkAt);

        return $okAt !== false && ($now - $okAt) < self::RECENT_SUCCESS_WINDOW;
    }

    /** @return list<string> */
    private static function budgetNotices(AiBudget $budget, int $spentTodayMicros, int $runsThisHour): array
    {
        $notices = [];
        if ($budget->hasRunLimit() && $runsThisHour >= $budget->maxRunsPerHour) {
            $notices[] = 'ai.status.rate_hit';
        }
        if ($budget->hasSpendLimit() && $spentTodayMicros >= $budget->maxSpendPerDayMicros) {
            $notices[] = 'ai.status.budget_hit';
        }

        return $notices;
    }

    /**
     * The chips shown under "Noch nicht eingerichtet", in the order the form asks for them, so the
     * list reads as a to-do rather than a diagnosis.
     *
     * @return list<string>
     */
    private static function missingKeys(AiConfiguration $config): array
    {
        if ($config->provider === null) {
            return ['ai.missing.provider'];
        }

        $missing = [];
        if (!$config->apiKeyConfigured || $config->apiKeyUnreadable) {
            $missing[] = 'ai.missing.key';
        }
        if ($config->model === '') {
            $missing[] = 'ai.missing.model';
        }
        if ($config->effectiveBaseUrl() === '') {
            $missing[] = 'ai.missing.baseurl';
        }

        return $missing;
    }

    /**
     * The `ai.err.*` key a failure kind is shown as. Public because the KI tab reports the outcome
     * of a test the merchant just triggered, before any record has been re-read.
     */
    public static function errorKey(string $kind): string
    {
        return match ($kind) {
            AiStatusRecord::KIND_AUTH => 'ai.err.auth',
            AiStatusRecord::KIND_NETWORK => 'ai.err.network',
            AiStatusRecord::KIND_MODEL => 'ai.err.model',
            default => 'ai.err.generic',
        };
    }

    /**
     * @return array{
     *     state: string,
     *     missing: list<string>,
     *     notices: list<string>,
     *     errorKey: string,
     *     errorDetail: string,
     *     checkedAt: string|null,
     *     spentTodayUsd: string
     * }
     */
    public function jsonSerialize(): array
    {
        return [
            'state'         => $this->state,
            'missing'       => $this->missing,
            'notices'       => $this->notices,
            'errorKey'      => $this->errorKey,
            'errorDetail'   => $this->errorDetail,
            'checkedAt'     => $this->checkedAt,
            'spentTodayUsd' => AiBudget::formatUsd($this->spentTodayMicros),
        ];
    }
}
