<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Ai;

use RuntimeException;
use SodiumException;

/**
 * Authenticated symmetric encryption for the one secret this plugin stores at rest: the merchant's
 * LLM API key (epic D1 — "per-shop key encryption").
 *
 * ## Why this exists at all rather than a core helper
 *
 * JTL Shop ships no symmetric-encryption service — `grep -rl openssl_encrypt includes/src` finds
 * nothing, and `JTLCrypt` is password HASHING, which is one-way and therefore useless for a
 * credential we have to hand back to an HTTP client. So the plugin owns this.
 *
 * ## Why libsodium
 *
 * `sodium_crypto_secretbox` is XSalsa20-Poly1305: it AUTHENTICATES. That matters more than the
 * cipher choice, because the ciphertext lives in a database row a compromised shop admin — or a
 * SQL-injection in some other plugin — can write to. Unauthenticated encryption would let an
 * attacker flip bits in the stored key and steer the request; here a tampered row simply fails to
 * open and reads as "not configured". The extension is bundled with PHP since 7.2, so this adds no
 * install requirement; {@see self::available()} still checks, because a hardened build can disable it.
 *
 * ## The key
 *
 * Derived from `BLOWFISH_KEY`, the per-installation secret in `includes/config.JTL-Shop.ini.php`,
 * through `sodium_crypto_generichash` with a fixed personalisation string. Two consequences worth
 * stating plainly:
 *
 * - The stored key is bound to THIS shop. Copying the database to another installation (a staging
 *   clone, a restored backup on new config) does not carry the API key over — it decrypts to
 *   nothing and the merchant is asked to re-enter it. That is the correct failure: a credential
 *   should not silently follow a database dump around.
 * - Rotating `BLOWFISH_KEY` invalidates the stored key. Same handling — re-enter, not a crash.
 *
 * The derivation is NOT a password KDF (no Argon2), deliberately: `BLOWFISH_KEY` is 30 hex chars of
 * installation randomness, not a human-chosen password, so there is no low-entropy guess space for
 * a slow hash to defend. What it needs is domain separation from every other use of that constant,
 * which is what the personalisation string gives.
 *
 * ## Format
 *
 * `base64(nonce ‖ ciphertext)` in one column. The nonce is random per encryption and stored beside
 * the ciphertext because it is not secret — only unique. Base64 rather than a BLOB so the value
 * survives every mysqldump/charset path a JTL shop is dragged through.
 */
final class SecretBox
{
    /**
     * Domain separation for the key derivation. Changing this string is a key rotation for every
     * shop at once — it would orphan stored secrets. Do not.
     */
    private const CONTEXT = 'tnt_mcp.ai.apikey.v1';

    /**
     * Length the {@see self::CONTEXT} personalisation string is padded to before it is used as the
     * generichash key. It equals `SODIUM_CRYPTO_GENERICHASH_KEYBYTES` by definition — named here
     * because phpstan rejects a *_KEYBYTES constant passed as a `str_pad()` length.
     */
    private const CONTEXT_PAD_LENGTH = \SODIUM_CRYPTO_GENERICHASH_KEYBYTES;

    /** 32 bytes of key material for `sodium_crypto_secretbox`. */
    private readonly string $key;

    /**
     * @param string $secret the installation secret to derive from (see {@see self::fromShop()})
     *
     * @throws RuntimeException when libsodium is unavailable or the secret is empty
     */
    public function __construct(string $secret)
    {
        if (!self::available()) {
            throw new RuntimeException(self::UNAVAILABLE_HINT);
        }
        if (\trim($secret) === '') {
            throw new RuntimeException('Cannot derive an encryption key from an empty shop secret.');
        }

        $this->key = \sodium_crypto_generichash(
            $secret,
            \str_pad(self::CONTEXT, self::CONTEXT_PAD_LENGTH, "\0"),
            \SODIUM_CRYPTO_SECRETBOX_KEYBYTES
        );
    }

    /** What a caller tells the merchant when the extension is missing — actionable, not "error". */
    public const UNAVAILABLE_HINT = 'The PHP sodium extension is not available, so an API key '
        . 'cannot be stored encrypted. Enable ext-sodium (bundled with PHP 7.2+) and try again.';

    /** What a caller tells the merchant when the shop has no installation secret to bind to. */
    public const NO_SECRET_HINT = 'BLOWFISH_KEY is not defined in this installation, so there is no '
        . 'per-shop secret to encrypt the API key with.';

    public static function available(): bool
    {
        return \function_exists('sodium_crypto_secretbox');
    }

    /** Whether {@see self::fromShop()} can succeed — checked before offering to store a key. */
    public static function usable(): bool
    {
        return self::available() && \defined('BLOWFISH_KEY') && \trim((string)\BLOWFISH_KEY) !== '';
    }

    /**
     * @throws RuntimeException when the installation has no `BLOWFISH_KEY`, or sodium is missing
     */
    public static function fromShop(): self
    {
        if (!\defined('BLOWFISH_KEY') || \trim((string)\BLOWFISH_KEY) === '') {
            throw new RuntimeException(self::NO_SECRET_HINT);
        }

        return new self((string)\BLOWFISH_KEY);
    }

    /**
     * @return string base64 of nonce ‖ ciphertext, safe to store in a text column
     *
     * @throws RuntimeException when the plaintext is empty — storing an empty secret would be
     *                          indistinguishable from "no key configured" and must not be possible
     */
    public function encrypt(string $plaintext): string
    {
        if ($plaintext === '') {
            throw new RuntimeException('Refusing to encrypt an empty value.');
        }

        $nonce  = \random_bytes(\SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
        $cipher = \sodium_crypto_secretbox($plaintext, $nonce, $this->key);

        return \base64_encode($nonce . $cipher);
    }

    /**
     * @return string|null the plaintext, or `null` when the stored value cannot be opened —
     *                     truncated, tampered with, or written under a different `BLOWFISH_KEY`.
     *                     Callers must treat that as "no key configured", never as a fatal error:
     *                     a restored backup is a normal way to reach this branch.
     */
    public function decrypt(string $stored): ?string
    {
        $raw = \base64_decode($stored, true);
        if ($raw === false || \strlen($raw) <= \SODIUM_CRYPTO_SECRETBOX_NONCEBYTES) {
            return null;
        }

        $nonce  = \substr($raw, 0, \SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
        $cipher = \substr($raw, \SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);

        try {
            $plain = \sodium_crypto_secretbox_open($cipher, $nonce, $this->key);
        } catch (SodiumException) {
            return null;
        }

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