<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\OAuth;

use Defuse\Crypto\Key;
use JTL\DB\DbInterface;
use JTL\Shop;
use RuntimeException;

/**
 * Owns the cryptographic material league/oauth2-server needs, and keeps it out of the document root.
 *
 * Three artefacts, all generated lazily on first use so a fresh install is self-provisioning and no
 * private key is ever committed:
 *  - private/public : RSA-2048 PEM keypair signing + verifying the access-token JWTs.
 *  - encryption     : a Defuse\Crypto ASCII-safe key that encrypts auth codes / refresh tokens at
 *                     rest inside the OAuth flow.
 *
 * They live in {@see KeyStore} — encrypted rows in the shop database — **not** in
 * `plugins/tnt_mcp/keys/`, which was web-reachable on every nginx shop. Callers hand the key
 * *contents* to league's `CryptKey`, which accepts a PEM string as readily as a path, so nothing has
 * to be written to disk to be used.
 *
 * Installs made before 0.17.0 have the old files. {@see importLegacyFiles()} adopts them into the
 * store and deletes them, so already-issued tokens keep verifying across the upgrade instead of
 * every connector being logged out. The migration calls it; `ensure()` calls it too, which covers a
 * shop restored from a backup that carries the files but not the rows.
 */
final class KeyManager
{
    /** @var array<string,string> artefact name => legacy file name */
    private const LEGACY_FILES = [
        KeyStore::PRIVATE_KEY    => 'private.key',
        KeyStore::PUBLIC_KEY     => 'public.key',
        KeyStore::ENCRYPTION_KEY => 'encryption.key',
    ];

    private bool $ensured = false;

    public function __construct(
        private readonly KeyStore $store,
        private readonly ?string $legacyDir = null,
    ) {
    }

    /**
     * @param DbInterface|null $db the shop DB; resolved from the container when omitted
     */
    public static function default(?DbInterface $db = null): self
    {
        return new self(KeyStore::default($db ?? Shop::Container()->getDB()), self::defaultLegacyDir());
    }

    /** `<plugin-root>/keys` — where installs before 0.17.0 kept the files. */
    public static function defaultLegacyDir(): string
    {
        return \dirname(__DIR__, 3) . '/keys';
    }

    /**
     * True when all three artefacts are present and decryptable. Read-only: unlike {@see ensure()}
     * this never generates anything, so the admin status tab can report provisioning state without
     * side effects.
     */
    public function isProvisioned(): bool
    {
        return $this->store->has(KeyStore::PRIVATE_KEY)
            && $this->store->has(KeyStore::PUBLIC_KEY)
            && $this->store->has(KeyStore::ENCRYPTION_KEY);
    }

    /** PEM contents of the signing key — pass straight to `CryptKey`. */
    public function privateKey(): string
    {
        return $this->read(KeyStore::PRIVATE_KEY);
    }

    /** PEM contents of the verification key — pass straight to `CryptKey`. */
    public function publicKey(): string
    {
        return $this->read(KeyStore::PUBLIC_KEY);
    }

    public function encryptionKey(): Key
    {
        return Key::loadFromAsciiSafeString(trim($this->read(KeyStore::ENCRYPTION_KEY)));
    }

    /**
     * Generate whatever is missing. Idempotent, and existing material is never regenerated, so
     * tokens already issued stay valid across requests and deploys.
     *
     * Only ever called once per instance — provisioning reads three rows, and the resource server
     * asks for a key on every single MCP request.
     */
    public function ensure(): void
    {
        if ($this->ensured) {
            return;
        }
        $this->ensured = true;

        if (!$this->store->has(KeyStore::PRIVATE_KEY) || !$this->store->has(KeyStore::PUBLIC_KEY)) {
            $this->generateKeypair();
        }
        if (!$this->store->has(KeyStore::ENCRYPTION_KEY)) {
            $this->store->put(KeyStore::ENCRYPTION_KEY, Key::createNewRandomKey()->saveToAsciiSafeString());
        }
    }

    /**
     * Adopt a pre-0.17.0 `keys/` directory: copy each file into the store (never overwriting a row
     * that is already there — the database is the source of truth the moment it has one), then
     * delete the file, because leaving a readable private key behind is the whole reason this class
     * changed.
     *
     * ⚠️ **Called by the migration and nothing else.** It was briefly called from `ensure()`, which
     * meant every unit test that built a `KeyManager` against a fake database deleted the *real*
     * shop's key files as a side effect — the material went into the fake store and the files were
     * gone. A file is now deleted only once the artefact is provably in the store this instance
     * reads, so a failed read can no longer destroy the only copy.
     *
     * Deliberately quiet otherwise: a shop with no legacy dir is the normal case, and a file we
     * cannot delete (read-only mount) must not break provisioning.
     *
     * @return list<string> the artefacts that were adopted, for the migration's log line
     */
    public function importLegacyFiles(): array
    {
        if ($this->legacyDir === null || !is_dir($this->legacyDir)) {
            return [];
        }

        $adopted = [];
        foreach (self::LEGACY_FILES as $name => $file) {
            $path = $this->legacyDir . '/' . $file;
            if (!is_file($path)) {
                continue;
            }
            if (!$this->store->has($name)) {
                $contents = file_get_contents($path);
                if ($contents === false || trim($contents) === '') {
                    continue; // unreadable: keep the file, it is still the only copy
                }
                $this->store->put($name, $contents);
                $adopted[] = $name;
            }
            if ($this->store->has($name)) {
                @unlink($path);
            }
        }

        if (glob($this->legacyDir . '/*.key') === []) {
            @unlink($this->legacyDir . '/.htaccess');
            @rmdir($this->legacyDir);
        }

        return $adopted;
    }

    private function read(string $name): string
    {
        $this->ensure();
        $value = $this->store->get($name);
        if ($value === null) {
            throw new RuntimeException('tnt_mcp: OAuth key material "' . $name . '" is missing');
        }

        return $value;
    }

    private function generateKeypair(): void
    {
        $res = openssl_pkey_new([
            'private_key_bits' => 2048,
            'private_key_type' => \OPENSSL_KEYTYPE_RSA,
        ]);
        if ($res === false) {
            throw new RuntimeException('tnt_mcp: openssl_pkey_new failed');
        }

        $privatePem = '';
        if (openssl_pkey_export($res, $privatePem) !== true) {
            throw new RuntimeException('tnt_mcp: openssl_pkey_export failed');
        }
        $details = openssl_pkey_get_details($res);
        if (!\is_array($details) || !isset($details['key']) || !\is_string($details['key'])) {
            throw new RuntimeException('tnt_mcp: openssl_pkey_get_details failed');
        }

        // Both halves or neither: a public key stored without its private counterpart would make
        // `isProvisioned()` true while every signature attempt failed.
        $this->store->put(KeyStore::PRIVATE_KEY, $privatePem);
        $this->store->put(KeyStore::PUBLIC_KEY, $details['key']);
    }
}
