<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\OAuth;

use JTL\DB\DbInterface;
use RuntimeException;

/**
 * Encrypted, database-backed storage for the OAuth key material.
 *
 * **Why not files.** The keys used to live in `plugins/tnt_mcp/keys/`, i.e. inside the document
 * root, protected by an `.htaccess`. Apache honours it (verified: 403); **nginx does not read
 * `.htaccess` at all**, and JTL shops run on both. On such a shop the RSA private key that signs
 * every access token was one plain GET away. A plugin that ships to customers cannot depend on
 * which web server they happen to run, and decision D31 forbids asking them to change their server
 * setup — so the key material moves into the database, which no web server serves by accident.
 *
 * **Encrypted at rest, with a secret the database does not contain.** A DB dump (backup, replica,
 * a `mysqldump` in a support ticket) must not be enough to forge tokens. The wrapping key is
 * derived from the shop's `BLOWFISH_KEY`, which lives in `includes/config.JTL-Shop.ini.php` — a
 * *file*. Whoever holds only the database holds ciphertext; whoever holds only the config file
 * holds nothing. AES-256-GCM, so a tampered row fails to decrypt rather than decrypting to
 * attacker-chosen bytes.
 *
 * **Rotating `BLOWFISH_KEY` invalidates the keys, on purpose.** `get()` returns null for a row it
 * cannot decrypt, exactly as if the row were missing; {@see KeyManager} then provisions a fresh
 * keypair. Issued tokens stop verifying and connectors re-consent. That is the honest outcome —
 * the alternative is a plugin that is silently dead until someone reads a log.
 *
 * The table is `tnt_mcp_oauth_*`, so {@see \Plugin\tnt_mcp\src\Mcp\Db\SqlReadGuard} already denies
 * it by prefix: `run_sql_read` cannot read this table even though the ciphertext would be useless.
 */
final class KeyStore
{
    public const TABLE = 'tnt_mcp_oauth_key';

    public const PRIVATE_KEY    = 'private';
    public const PUBLIC_KEY     = 'public';
    public const ENCRYPTION_KEY = 'encryption';

    private const CIPHER  = 'aes-256-gcm';
    private const IV_LEN  = 12;
    private const TAG_LEN = 16;

    /** Domain separation: this derived key wraps OAuth material and nothing else. */
    private const KDF_CONTEXT = 'tnt_mcp/oauth-key-store/v1';

    /** @var non-empty-string */
    private readonly string $wrappingKey;

    /**
     * @param string $secret shop-side secret that is NOT stored in the database
     */
    public function __construct(private readonly DbInterface $db, string $secret)
    {
        if (trim($secret) === '') {
            throw new RuntimeException(
                'tnt_mcp: no shop secret available to encrypt the OAuth keys with '
                . '(BLOWFISH_KEY is empty in includes/config.JTL-Shop.ini.php)'
            );
        }
        $this->wrappingKey = hash_hmac('sha256', self::KDF_CONTEXT, $secret, true);
    }

    /**
     * The shop's own `BLOWFISH_KEY` is the wrapping secret. It is defined by
     * `includes/config.JTL-Shop.ini.php` on every installed shop.
     */
    public static function default(DbInterface $db): self
    {
        return new self($db, \defined('BLOWFISH_KEY') ? (string)\BLOWFISH_KEY : '');
    }

    /**
     * Plaintext of a stored artefact, or null when it is absent **or undecryptable** — see the class
     * comment on `BLOWFISH_KEY` rotation.
     */
    public function get(string $name): ?string
    {
        $row = $this->db->getSingleObject(
            'SELECT cValue FROM ' . self::TABLE . ' WHERE cName = :n',
            ['n' => $name],
        );
        if ($row === null || !isset($row->cValue) || !\is_string($row->cValue)) {
            return null;
        }

        return $this->decrypt($row->cValue);
    }

    public function has(string $name): bool
    {
        return $this->get($name) !== null;
    }

    /**
     * Store (or replace) one artefact. Uses INSERT … ON DUPLICATE KEY UPDATE so a concurrent
     * provisioning race ends with one row rather than a duplicate-key error.
     */
    public function put(string $name, string $value): void
    {
        $this->db->queryPrepared(
            'INSERT INTO ' . self::TABLE . ' (cName, cValue, dCreated) VALUES (:n, :v, NOW()) '
            . 'ON DUPLICATE KEY UPDATE cValue = VALUES(cValue), dCreated = NOW()',
            ['n' => $name, 'v' => $this->encrypt($value)],
        );
    }

    private function encrypt(string $plaintext): string
    {
        $iv  = random_bytes(self::IV_LEN);
        $tag = '';
        $ct  = openssl_encrypt($plaintext, self::CIPHER, $this->wrappingKey, \OPENSSL_RAW_DATA, $iv, $tag);
        if ($ct === false) {
            throw new RuntimeException('tnt_mcp: could not encrypt OAuth key material');
        }

        return base64_encode($iv . $tag . $ct);
    }

    private function decrypt(string $stored): ?string
    {
        $raw = base64_decode($stored, true);
        if ($raw === false || \strlen($raw) <= self::IV_LEN + self::TAG_LEN) {
            return null;
        }

        $plain = openssl_decrypt(
            substr($raw, self::IV_LEN + self::TAG_LEN),
            self::CIPHER,
            $this->wrappingKey,
            \OPENSSL_RAW_DATA,
            substr($raw, 0, self::IV_LEN),
            substr($raw, self::IV_LEN, self::TAG_LEN),
        );

        return $plain === false ? null : $plain;
    }
}
