<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\adminmenu\Controller;

use JTL\Helpers\Form;
use JTL\Helpers\Request;
use JTL\Shop;
use Plugin\tnt_mcp\src\Ai\AiBudget;
use Plugin\tnt_mcp\src\Ai\AiConfiguration;
use Plugin\tnt_mcp\src\Ai\AiConfigurationStore;
use Plugin\tnt_mcp\src\Ai\AiConnectionResult;
use Plugin\tnt_mcp\src\Ai\AiConnectionTester;
use Plugin\tnt_mcp\src\Ai\AiStatus;
use Plugin\tnt_mcp\src\Ai\AiStatusRecord;
use Plugin\tnt_mcp\src\Ai\AiSetupTokenStatus;
use Plugin\tnt_mcp\src\Ai\AiSetupTokenStore;
use Plugin\tnt_mcp\src\Ai\AiStatusStore;
use Plugin\tnt_mcp\src\Ai\AiUsageRepository;
use Plugin\tnt_mcp\src\Enums\AiProviders;
use Plugin\tnt_mcp\src\Settings\PluginSettingWriter;
use Throwable;

/**
 * "KI" tab: the one place a merchant connects this shop to an LLM.
 *
 * ## Why the whole setup is one screen
 *
 * The provider, the key, the model and the spending ceilings used to live on a tab in one plugin
 * while the thing that consumed them lived in another, so "set up AI" meant knowing which of two
 * plugins owned which half. This tab is the end of that: everything the connection needs is here,
 * and the primary button both saves AND tests, because a saved-but-untested key is the failure mode
 * this form exists to prevent — it is discovered hours later, by a workflow, at 3am.
 *
 * ## Why it posts instead of using HTMX
 *
 * Every other tab in this plugin is a plain form post gated on the backend XSRF token
 * ({@see Changes}), and this one has no partial-update need that a full render does not serve: a
 * save changes the banner, the key hint, the model suggestions and the button labels all at once.
 * The design's HTMX swap for the API-address field is instead a few lines of progressive-disclosure
 * script — the field is *always* rendered correctly by the server; the script only hides it for the
 * providers that have their own default URL, and the value is ignored on save for those providers.
 * No JavaScript means one extra field, never a broken form.
 *
 * ## What is never rendered
 *
 * The API key. It leaves {@see AiConfigurationStore} through exactly one method, for exactly one
 * caller (the HTTP lane) — this tab only ever shows the last four characters, which identify which
 * key is stored without being a credential.
 */
final class Ai extends BaseAdminController
{
    /** POST action names, also the value of the submit buttons. */
    private const ACTION_SAVE = 'save';

    private const ACTION_SAVE_TEST = 'saveTest';

    private const ACTION_TEST = 'test';

    private const ACTION_CLEAR = 'clear';

    public function render(): void
    {
        if (!$this->isAdmin()) {
            $this->notLoggedInNotice();

            return;
        }

        // The strings of this tab live in locale/<iso>/base.mo (see bin/compile-mo.php). Loading is
        // idempotent and cached by GetText, so doing it on render costs nothing on a warm request.
        $this->loadLocale();

        $flash  = null;
        $result = null;
        if (Request::postVar('tntMcpAiAction') !== null) {
            [$flash, $result] = $this->handlePost();
        }

        // Only ever reached past the isAdmin() gate above: a setup link never authenticates anyone,
        // it only says who prepared the form. See AiSetupTokenStore.
        $entry = $this->consumeSetupToken();

        // Guarantee the backend XSRF token exists so the form below can carry it.
        Form::getTokenInput();

        $db     = $this->db();
        $config = (new AiConfigurationStore($db))->load();
        $budget = AiBudget::rawValues($db);
        $status = $this->status($config);

        echo $this->fetch('ai.tpl', [
            'aiConfig'    => $config,
            'aiStatus'    => $status,
            'providers'   => $this->providerOptions(),
            'spendDay'    => $budget[AiBudget::SETTING_MAX_SPEND_DAY] ?? '',
            'runsHour'    => $budget[AiBudget::SETTING_MAX_RUNS_HOUR] ?? '',
            // Only ever populated by a test made in THIS request: the provider's model list is not
            // worth a table, and an outdated suggestion list is worse than none.
            'modelHints'  => $result?->ok === true ? $result->models : [],
            // Deliberately no raw provider message on the form: {@see AiConnectionTester} writes it
            // in English, and it says the same thing as the mapped sentence in the banner directly
            // above. Two error lines, one of them in the wrong language, is worse than one.
            'flash'       => $flash,
            // Sentences with placeholders are assembled here rather than in the template: Smarty has
            // no sprintf and the alternative — splitting a translated sentence around a variable —
            // is exactly how translations end up with the words in the wrong order.
            'aiText'      => $this->bannerTexts($config, $status),
            // '' when the merchant simply opened the tab; otherwise the note, or the expiry line.
            'entryNote'   => $entry,
            'formUrl'     => $this->selfUrl([]),
            'jtlToken'    => $_SESSION['jtl_token'] ?? '',
            'compatible'  => AiProviders::COMPATIBLE->value,
        ]);
    }

    /**
     * The verdict for the banner, refreshing the stored signal first when it has gone stale.
     *
     * The refresh is what makes the banner trustworthy without a cron: a shop whose key was revoked
     * this morning must not show green all day because nothing happened to call the provider. It
     * costs one cheap model-list request per {@see AiStatus::RECHECK_TTL}, and only for a shop that
     * is configured — an unconfigured one has nothing to check.
     */
    private function status(AiConfiguration $config): AiStatus
    {
        $store  = new AiStatusStore($this->db());
        $record = $store->load();

        if ($config->isComplete() && $store->isStale($record, AiStatus::RECHECK_TTL)) {
            $this->test($store, AiStatusRecord::SOURCE_LIVE);
            $record = $store->load();
        }

        $usage = new AiUsageRepository($this->db());

        return AiStatus::evaluate(
            $config,
            $record,
            AiBudget::fromSettings($this->db()),
            $usage->spentTodayMicros(),
            $usage->runsThisHour(),
        );
    }

    /**
     * @return array{0: array{type: string, text: string}|null, 1: AiConnectionResult|null}
     */
    private function handlePost(): array
    {
        if (!Form::validateToken()) {
            return [$this->flash('danger', $this->text([
                'de' => 'Sicherheits-Token ungültig. Bitte erneut versuchen.',
                'en' => 'Security token invalid. Please try again.',
            ])), null];
        }

        return match ((string)Request::postVar('tntMcpAiAction', '')) {
            self::ACTION_SAVE      => $this->saveOnly(),
            self::ACTION_SAVE_TEST => $this->saveAndTest(),
            self::ACTION_TEST      => $this->testOnly(),
            self::ACTION_CLEAR     => [$this->clear(), null],
            default                => [null, null],
        };
    }

    /**
     * Stores what was typed.
     *
     * The key is only overwritten when the field was filled in — it is write-only by design, so an
     * empty field has to mean "keep", never "clear" (that is what the disconnect button is for).
     * The ceilings are validated BEFORE the write because {@see AiBudget} reads an unparsable value
     * as "no limit": silently turning a typo into an unlimited budget is the one failure this form
     * must not have.
     *
     * @return array{type: string, text: string}|null null when the save succeeded and the caller
     *                                                (save-and-test) will report the outcome itself
     */
    private function save(bool $quietOnSuccess): ?array
    {
        $provider = AiProviders::tryFrom((string)Request::postVar('aiProvider', ''));
        if ($provider === null) {
            return $this->flash('danger', $this->translate('ai.missing.provider'));
        }

        $apiKey   = \trim((string)Request::postVar('aiApiKey', ''));
        $model    = (string)Request::postVar('aiModel', '');
        $spendDay = \trim((string)Request::postVar('aiSpendDay', ''));
        $runsHour = \trim((string)Request::postVar('aiRunsHour', ''));
        // A provider with its own default API root ignores the field entirely, so a value left over
        // from a previous "OpenAI-kompatibel" selection cannot silently redirect the shop's traffic.
        $baseUrl = $provider === AiProviders::COMPATIBLE
            ? (string)Request::postVar('aiBaseUrl', '')
            : '';

        if (!AiBudget::isValidAmount($spendDay) || !AiBudget::isValidAmount($runsHour)) {
            return $this->flash('danger', $this->text([
                'de' => 'Bitte geben Sie bei den Kostengrenzen nur Zahlen ein (0 = keine Grenze).',
                'en' => 'Please enter numbers only for the cost limits (0 = no limit).',
            ]));
        }

        try {
            $db = $this->db();
            (new AiConfigurationStore($db))->save($provider, $apiKey === '' ? null : $apiKey, $model, $baseUrl);

            $writer = new PluginSettingWriter($db);
            $writer->set('tnt_mcp', AiBudget::SETTING_MAX_SPEND_DAY, $spendDay);
            $writer->set('tnt_mcp', AiBudget::SETTING_MAX_RUNS_HOUR, $runsHour);
        } catch (Throwable $e) {
            // The store's own messages name the field the merchant has to correct, so they are shown
            // rather than replaced by a generic failure.
            return $this->flash('danger', $e->getMessage());
        }

        return $quietOnSuccess ? null : $this->flash('success', $this->translate('ai.saved'));
    }

    /**
     * Plain "Speichern".
     *
     * The model field can only be filled sensibly from the provider's own list, and that list needs
     * a stored key to be fetched at all — so a first save with the model still blank is the NORMAL
     * first step, not an error. It stores what is there and then asks the provider straight away,
     * which populates the suggestions on the very page the merchant is looking at. With a model
     * filled in, nothing is called: a save is a save.
     *
     * @return array{0: array{type: string, text: string}|null, 1: AiConnectionResult|null}
     */
    private function saveOnly(): array
    {
        $wantsModelList = \trim((string)Request::postVar('aiModel', '')) === '';

        $failure = $this->save(true);
        if ($failure !== null) {
            return [$failure, null];
        }
        if (!$wantsModelList) {
            return [$this->flash('success', $this->translate('ai.saved')), null];
        }

        // The save already succeeded, so a failing probe must not read as "nothing was stored":
        // testOnly()'s danger flash names what the provider said, and the banner above the form
        // still shows the configuration as present but incomplete.
        return $this->testOnly($this->translate('ai.saved_pick_model'));
    }

    /**
     * @return array{0: array{type: string, text: string}|null, 1: AiConnectionResult|null}
     */
    private function saveAndTest(): array
    {
        $failure = $this->save(true);
        if ($failure !== null) {
            return [$failure, null];
        }

        return $this->testOnly($this->translate('ai.saved_tested'));
    }

    /**
     * @return array{0: array{type: string, text: string}|null, 1: AiConnectionResult|null}
     */
    private function testOnly(string $successText = ''): array
    {
        $result = $this->test(new AiStatusStore($this->db()), AiStatusRecord::SOURCE_TEST);
        if ($result === null) {
            return [$this->flash('danger', $this->translate('ai.err.network')), null];
        }

        if (!$result->ok) {
            // The mapped sentence names the fix; the provider's own words go to the field below it.
            return [
                $this->flash('danger', $this->translate(
                    AiStatus::errorKey(AiStatusRecord::classify($result->status))
                )),
                $result,
            ];
        }

        return [
            $this->flash('success', $successText !== '' ? $successText : $this->translate('ai.saved_tested')),
            $result,
        ];
    }

    /**
     * Runs the connection test and records what it saw, whatever that was.
     *
     * Recording the FAILURE is the point as much as recording the success: it is what lets the
     * banner say "Gestört" later without making another call.
     */
    private function test(AiStatusStore $store, string $source): ?AiConnectionResult
    {
        $startedAt = \microtime(true);

        try {
            $result = (new AiConnectionTester())->testStored(new AiConfigurationStore($this->db()));
        } catch (Throwable $e) {
            $store->recordFailure(AiStatusRecord::KIND_OTHER, $e->getMessage(), $source);

            return null;
        }

        $store->recordConnectionResult($result, (int)\round((\microtime(true) - $startedAt) * 1000), $source);

        return $result;
    }

    /** @return array{type: string, text: string} */
    private function clear(): array
    {
        $db = $this->db();
        (new AiConfigurationStore($db))->clear();
        // The health record describes a connection that no longer exists; keeping it would leave a
        // red "Gestört" banner over a shop that is simply not configured any more.
        (new AiStatusStore($db))->clear();

        return $this->flash('success', $this->text([
            'de' => 'Die KI-Anbindung wurde entfernt.',
            'en' => 'The AI connection has been removed.',
        ]));
    }

    /**
     * Spends the `entry` token of a link created by `begin_ai_key_entry`, if one is present.
     *
     * A visit WITHOUT a token, and a visit with a token this shop never issued, are the same thing:
     * the tab as it always was. Only a token this shop really minted produces a message — the note
     * when it is still good, the expiry line when it is spent or old — because those are the only
     * two cases where the merchant is mid-handover and needs to be told which one they are in.
     *
     * @return array{type: string, text: string}|null
     */
    private function consumeSetupToken(): ?array
    {
        $token = (string)Request::verifyGPDataString('entry');
        if ($token === '') {
            return null;
        }

        return match ((new AiSetupTokenStore($this->db()))->consume($token)) {
            AiSetupTokenStatus::Accepted => ['type' => 'note', 'text' => $this->translate('ai.entry.note')],
            AiSetupTokenStatus::Expired => ['type' => 'expired', 'text' => $this->translate('ai.entry.expired')],
            AiSetupTokenStatus::Unknown => null,
        };
    }

    /**
     * The banner's assembled sentences, plus the key-field hint, which is the one help text whose
     * wording depends on whether a key is already stored.
     *
     * @return array{
     *     label: string, detail: string, checked: string, spend: string,
     *     error: string, notices: list<string>, missing: list<string>, keyHelp: string
     * }
     */
    private function bannerTexts(AiConfiguration $config, AiStatus $status): array
    {
        $budget = AiBudget::fromSettings($this->db());

        return [
            'label'   => $this->translate($status->labelKey()),
            'detail'  => $config->provider === null ? '' : \sprintf(
                $this->translate('ai.status.detail'),
                $config->provider->label(),
                $config->model,
                $config->apiKeyLast4
            ),
            'checked' => $status->checkedAt === null
                ? ''
                : \sprintf($this->translate('ai.status.checked'), $status->checkedAt),
            // Only shown when a ceiling exists: "0.00 of 0.00 USD" over an unlimited budget is noise.
            'spend'   => $budget->hasSpendLimit() ? \sprintf(
                $this->translate('ai.status.spend'),
                AiBudget::formatUsd($status->spentTodayMicros),
                AiBudget::formatUsd($budget->maxSpendPerDayMicros)
            ) : '',
            // Only the generic case carries the provider's raw words; the mapped ones deliberately
            // replace them, because "invalid_api_key" is not an instruction and the sentence is.
            'error'   => match ($status->errorKey) {
                ''               => '',
                'ai.err.generic' => \sprintf($this->translate('ai.err.generic'), $status->errorDetail),
                default          => $this->translate($status->errorKey),
            },
            'notices' => \array_map(fn (string $key): string => $this->translate($key), $status->notices),
            'missing' => \array_map(fn (string $key): string => $this->translate($key), $status->missing),
            'keyHelp' => $config->apiKeyConfigured && !$config->apiKeyUnreadable
                ? \sprintf($this->translate('ai.field.key.help.set'), $config->apiKeyLast4)
                : $this->translate('ai.field.key.help.empty'),
        ];
    }

    /**
     * The provider select's options, label included, so the template never maps enum cases itself.
     *
     * @return list<array{value: string, label: string}>
     */
    private function providerOptions(): array
    {
        $options = [];
        foreach (AiProviders::cases() as $provider) {
            $options[] = ['value' => $provider->value, 'label' => $provider->label()];
        }

        return $options;
    }

    /**
     * A `ai.*` key through gettext. Returns the key itself when the .mo has no entry, which is
     * exactly what an untranslated string should look like in a screenshot: obvious.
     */
    private function translate(string $key): string
    {
        if ($key === '') {
            return '';
        }

        return \__($key);
    }

    private function loadLocale(): void
    {
        try {
            Shop::Container()->getGetText()->loadPluginLocale('base', $this->plugin);
        } catch (Throwable) {
            // Untranslated keys render as keys; not a reason to refuse the tab.
        }
    }

    /** @return array{type: string, text: string} */
    private function flash(string $type, string $text): array
    {
        return ['type' => $type, 'text' => $text];
    }
}
