<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Ai;

use InvalidArgumentException;
use JTL\DB\DbInterface;
use JTL\Shop;
use Plugin\tnt_mcp\src\Enums\AiProviders;
use RuntimeException;
use Throwable;

/**
 * The single place the shop's LLM credentials are read and written (`tnt_mcp_ai_config`, epic 4.2).
 *
 * ## The one-row table
 *
 * One provider per shop, so the table has a fixed primary key of 1 and the write is a REPLACE. That
 * is not a limitation waiting to be lifted: a workflow action that had to pick between several
 * configured providers would need a provider argument on every action, and the merchant would need
 * to understand which of their keys is billed for what. One key, one bill, one place to rotate it.
 *
 * ## What may leave this class
 *
 * {@see self::load()} returns an {@see AiConfiguration}, which has NO key on it. The plaintext key
 * leaves through {@see self::apiKey()} alone, and its only legitimate caller is the code that builds
 * an outbound request. Nothing that renders, logs, or answers an MCP call may call it — see
 * `AiConfiguration` for why that split is the whole design.
 *
 * ## Fail-closed
 *
 * Every read failure — missing table (plugin not migrated), unreadable ciphertext, unknown provider
 * string — resolves to "nothing configured" rather than an exception. A shop whose AI configuration
 * is broken must still serve pages and still run every workflow that does not need AI; the failure
 * surfaces where it matters, as `isComplete() === false` with `missingReason()` naming the cause.
 * Note the asymmetry: WRITES throw, loudly and with a reason, because a merchant saving a key is
 * entitled to know it did not work.
 */
final class AiConfigurationStore
{
    public const TABLE = 'tnt_mcp_ai_config';

    /** The one row. The table exists to hold a singleton; see the class doc block. */
    private const ROW_ID = 1;

    /** Guards against a paste of an entire config file into the key field. */
    private const MAX_KEY_LENGTH = 512;

    private const MAX_MODEL_LENGTH = 128;

    private const MAX_URL_LENGTH = 255;

    private readonly DbInterface $db;

    /**
     * Null when this installation cannot encrypt at all — no `BLOWFISH_KEY`, or no ext-sodium.
     * Deriving it is deferred rather than done in the constructor because the constructor runs
     * wherever a tool is merely BUILT, and an MCP tool list must not fail to assemble on a shop
     * whose config is unusual; the failure belongs at the call that needs the key.
     */
    private readonly ?SecretBox $box;

    /**
     * Never throws. A shop that cannot encrypt reads as "nothing configured" and refuses to SAVE
     * with {@see SecretBox::UNAVAILABLE_HINT} — the same read/write asymmetry as everywhere else in
     * this class.
     *
     * @param SecretBox|null $box injected by tests; production derives it from the shop secret
     */
    public function __construct(?DbInterface $db = null, ?SecretBox $box = null)
    {
        $this->db = $db ?? Shop::Container()->getDB();

        if ($box !== null) {
            $this->box = $box;

            return;
        }

        try {
            $this->box = SecretBox::fromShop();
        } catch (RuntimeException) {
            $this->box = null;
        }
    }

    /** Whether this installation can store a key at all — see {@see SecretBox}. */
    public function canEncrypt(): bool
    {
        return $this->box !== null;
    }

    /**
     * Never throws — see the class doc block on fail-closed reads.
     */
    public function load(): AiConfiguration
    {
        $row = $this->row();
        if ($row === null) {
            return new AiConfiguration();
        }

        $provider = AiProviders::tryFrom((string)($row->provider ?? ''));
        if ($provider === null) {
            // A provider this build no longer knows. Reporting it as unconfigured is honest: no
            // action could run against it anyway, and the merchant is asked to choose again.
            return new AiConfiguration();
        }

        $cipher = (string)($row->apiKeyCipher ?? '');
        $hasKey = $cipher !== '';
        // No box = this shop cannot decrypt anything, so a stored key is unreadable by definition.
        $unreadable = $hasKey && ($this->box === null || $this->box->decrypt($cipher) === null);

        return new AiConfiguration(
            provider: $provider,
            model: (string)($row->model ?? ''),
            baseUrl: (string)($row->baseUrl ?? ''),
            apiKeyConfigured: $hasKey,
            apiKeyLast4: (string)($row->apiKeyLast4 ?? ''),
            updated: ($row->updated ?? null) === null ? null : (string)$row->updated,
            apiKeyUnreadable: $unreadable,
        );
    }

    /**
     * The plaintext key, or `null` when none is stored or the stored one cannot be opened.
     *
     * ⚠️ Callers must not log, echo, or return this value. It exists to be put into an
     * `Authorization`/`x-api-key` header and nowhere else.
     */
    public function apiKey(): ?string
    {
        $row    = $this->row();
        $cipher = (string)($row->apiKeyCipher ?? '');

        return ($cipher === '' || $this->box === null) ? null : $this->box->decrypt($cipher);
    }

    /**
     * Writes the configuration.
     *
     * @param string|null $apiKey the new key, or `null` to KEEP the stored one. Keeping is the
     *                            default because changing the model must not force the merchant to
     *                            re-paste a credential they cannot read back — the field is
     *                            write-only by design, so "leave blank to keep" is the only usable
     *                            edit flow.
     *
     * @throws InvalidArgumentException when an argument is not storable — the message names the
     *                                  field and is shown to the merchant / returned over MCP
     * @throws RuntimeException         when no key would be stored at all, or the write fails
     */
    /**
     * Writes everything EXCEPT the key, keeping whatever key is already stored (including none).
     *
     * The one caller is the setup-link flow ({@see AiSetupTokenStore}): an assistant may choose the
     * provider and the model, and must then leave a form for the human with only the key left to
     * fill in. {@see self::save()} cannot serve that — it refuses to write a configuration that
     * would be unusable, which is the correct rule for the tool a merchant drives and exactly the
     * wrong one for a form that is deliberately half-finished.
     *
     * @throws InvalidArgumentException when an argument is not storable
     * @throws RuntimeException         when the write fails
     */
    public function prefill(AiProviders $provider, string $model, string $baseUrl = ''): void
    {
        $model   = \trim($model);
        $baseUrl = \rtrim(\trim($baseUrl), '/');

        // The model may legitimately be blank here — "I do not know which model yet" is a state the
        // merchant can finish on the form — but anything non-blank has to be storable.
        if ($model !== '') {
            $this->assertModel($model);
        }
        $this->assertBaseUrl($provider, $baseUrl);

        $existing = $this->row();

        $this->write(
            $provider,
            $model,
            $baseUrl,
            (string)($existing->apiKeyCipher ?? ''),
            (string)($existing->apiKeyLast4 ?? ''),
        );
    }

    public function save(
        AiProviders $provider,
        ?string $apiKey,
        string $model,
        string $baseUrl = '',
    ): void {
        if ($this->box === null) {
            throw new RuntimeException(
                SecretBox::usable() ? SecretBox::NO_SECRET_HINT : SecretBox::UNAVAILABLE_HINT
            );
        }

        $model   = \trim($model);
        $baseUrl = \rtrim(\trim($baseUrl), '/');

        // A blank model is a legitimate half-finished state, exactly as in {@see self::prefill()}:
        // the model list is only knowable AFTER a key is stored and the provider was asked, so
        // refusing the save would leave the merchant unable to reach the very list they need. The
        // configuration then loads as incomplete with "No default model is configured." — the
        // banner asks for the missing piece instead of the form rejecting the first step.
        if ($model !== '') {
            $this->assertModel($model);
        }
        $this->assertBaseUrl($provider, $baseUrl);

        $existing = $this->row();

        if ($apiKey === null) {
            $cipher = (string)($existing->apiKeyCipher ?? '');
            $last4  = (string)($existing->apiKeyLast4 ?? '');
            if ($cipher === '') {
                throw new RuntimeException(
                    'No API key was supplied and none is stored for this shop, so the '
                    . 'configuration would be unusable. Supply an API key.'
                );
            }
        } else {
            $apiKey = \trim($apiKey);
            $this->assertApiKey($apiKey);
            $cipher = $this->box->encrypt($apiKey);
            $last4  = \substr($apiKey, -4);
        }

        $this->write($provider, $model, $baseUrl, $cipher, $last4);
    }

    /** The single row-writing statement, shared by {@see self::save()} and {@see self::prefill()}. */
    private function write(
        AiProviders $provider,
        string $model,
        string $baseUrl,
        string $cipher,
        string $last4,
    ): void {
        $this->db->queryPrepared(
            'REPLACE INTO `' . self::TABLE . '`
                (`id`, `provider`, `model`, `baseUrl`, `apiKeyCipher`, `apiKeyLast4`, `updated`)
             VALUES (:id, :provider, :model, :baseUrl, :cipher, :last4, :updated)',
            [
                'id'       => self::ROW_ID,
                'provider' => $provider->value,
                'model'    => $model,
                'baseUrl'  => $baseUrl,
                'cipher'   => $cipher,
                'last4'    => $last4,
                'updated'  => \date('Y-m-d H:i:s'),
            ]
        );
    }

    /** Removes the configuration entirely — the merchant's "disconnect" button. */
    public function clear(): void
    {
        $this->db->queryPrepared(
            'DELETE FROM `' . self::TABLE . '` WHERE `id` = :id',
            ['id' => self::ROW_ID]
        );
    }

    /**
     * ⚠️ A newline or a carriage return in the key would be a header-injection vector the moment
     * {@see AiProviders::authHeaders()} puts it into a request. `HttpClient` refuses such a header
     * too, but a credential must be rejected where the merchant can still fix it, not deep inside
     * a cron run.
     *
     * @throws InvalidArgumentException
     */
    private function assertApiKey(string $apiKey): void
    {
        if ($apiKey === '') {
            throw new InvalidArgumentException('The API key must not be empty.');
        }
        if (\strlen($apiKey) > self::MAX_KEY_LENGTH) {
            throw new InvalidArgumentException(
                'The API key is longer than ' . self::MAX_KEY_LENGTH . ' characters — that is not a key.'
            );
        }
        if (\preg_match('/[\x00-\x1F\x7F]/', $apiKey) === 1) {
            throw new InvalidArgumentException(
                'The API key contains control characters or line breaks. Paste the key alone, '
                . 'without surrounding text.'
            );
        }
    }

    /** @throws InvalidArgumentException */
    private function assertModel(string $model): void
    {
        if ($model === '') {
            throw new InvalidArgumentException('A default model must be given.');
        }
        if (\strlen($model) > self::MAX_MODEL_LENGTH) {
            throw new InvalidArgumentException(
                'The model name is longer than ' . self::MAX_MODEL_LENGTH . ' characters.'
            );
        }
        if (\preg_match('/^[A-Za-z0-9._:\/-]+$/', $model) !== 1) {
            throw new InvalidArgumentException(
                'The model name may only contain letters, digits and . _ : / - characters.'
            );
        }
    }

    /**
     * `COMPATIBLE` has no default root, so for it a base URL is REQUIRED — the alternative is a
     * saved configuration that can never make a call.
     *
     * @throws InvalidArgumentException
     */
    private function assertBaseUrl(AiProviders $provider, string $baseUrl): void
    {
        if ($baseUrl === '') {
            if ($provider->defaultBaseUrl() === '') {
                throw new InvalidArgumentException(
                    'Provider ' . $provider->value . ' has no default API URL, so baseUrl is required.'
                );
            }

            return;
        }

        if (\strlen($baseUrl) > self::MAX_URL_LENGTH) {
            throw new InvalidArgumentException(
                'The base URL is longer than ' . self::MAX_URL_LENGTH . ' characters.'
            );
        }

        $scheme = \strtolower((string)\parse_url($baseUrl, \PHP_URL_SCHEME));
        if (\filter_var($baseUrl, \FILTER_VALIDATE_URL) === false || !\in_array($scheme, ['http', 'https'], true)) {
            throw new InvalidArgumentException('The base URL must be an absolute http(s) URL.');
        }
    }

    /**
     * The stored row, or `null` when there is none — including when the table does not exist yet,
     * which is the state of every shop between installing this version and running its migration.
     */
    private function row(): ?\stdClass
    {
        try {
            return $this->db->getSingleObject(
                'SELECT * FROM `' . self::TABLE . '` WHERE `id` = :id',
                ['id' => self::ROW_ID]
            );
        } catch (Throwable) {
            return null;
        }
    }
}
