<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\Log;

/**
 * Secret-scrubbing for free-text log *messages*.
 *
 * {@see \Plugin\tnt_mcp\src\Mcp\Db\ResultShaper} redacts by column name, which is enough when the
 * secret sits in a column called `cPasswort`. Logs are the other case: the secret is inside the
 * text — a payment module logging its request body, a plugin logging a failing URL with an api key
 * in the query string. Nothing here is guaranteed complete; it is a floor, not a promise, and it
 * runs *before* truncation so cutting a line can never expose a value that was about to be masked.
 *
 * Two families of rule, because logs carry two kinds of secret. The *key* rules (`ASSIGNMENT`,
 * `BEARER`) catch what a writer labelled — `password=…`, `Bearer …`. The *value* rules (`EMAIL`,
 * `IBAN`, `CARD`) catch what nobody labelled: a payment module dumping a request body into
 * `tzahlungslog.cLog`, which `read_logs({source:"payment"})` returns verbatim to a model. The value
 * rules are the ones that can be wrong in both directions, so the riskiest of them — a bare digit
 * run, indistinguishable from an order number — must pass a Luhn check before it is touched.
 *
 * `teinstellungenlog` already redacts `*_pass` values to `'***'` and anonymises the IP in
 * `Backend\Settings\Manager::addLog()`. We never undo that — this only ever adds masking.
 */
final class LogRedactor
{
    public const REDACTED = '***';

    /**
     * `key=value`, `key: value`, `"key":"value"` — the three shapes secrets actually appear in.
     * The value run stops at whitespace and at the usual separators so only the value is masked.
     */
    private const ASSIGNMENT = '/\b((?:pass(?:word|wort|phrase)?|pwd|secret|token|auth|api[_-]?key'
        . '|access[_-]?key|client[_-]?secret|private[_-]?key|signature)"?\s*[:=]\s*"?)'
        . '([^\s"\',;&)\]}]{3,})/i';

    /** `Authorization: Bearer <jwt>` and friends, where the keyword is the scheme, not the key. */
    private const BEARER = '/\b(Bearer|Basic|Token)\s+([A-Za-z0-9._\-+\/=]{8,})/i';

    /**
     * An e-mail address anywhere in the text.
     *
     * The key-shaped rules above only fire when the writer labelled the value. Payment and mail
     * modules do not: `tzahlungslog.cLog` and `teinstellungenlog` carry whole request bodies and
     * free prose ("Zahlung fehlgeschlagen für kunde@example.com"), and `read_logs({source:"payment"})`
     * hands that to a model verbatim. A customer's address is personal data under any reading of the
     * GDPR and has no diagnostic value to an LLM, so it goes.
     *
     * Deliberately narrower than RFC 5322 (no quoted local parts): the cost of a miss is a leaked
     * address, the cost of a false positive is an unreadable log line, and a TLD-anchored shape is
     * the balance the rest of this class already strikes.
     */
    private const EMAIL = '/[A-Za-z0-9._%+\-]+@[A-Za-z0-9\-]+(?:\.[A-Za-z0-9\-]+)*\.[A-Za-z]{2,}/';

    /**
     * An IBAN written compactly: two country letters, two check digits, 11-30 further alphanumerics
     * (ISO 13616 caps the total at 34).
     *
     * No check-digit test, unlike the card rule below: an IBAN's leading letters make a false
     * positive far less likely than a bare digit run, and the mod-97 check would only buy us the
     * right to print malformed account numbers.
     */
    private const IBAN = '/\b[A-Za-z]{2}\d{2}[A-Za-z0-9]{11,30}\b/';

    /**
     * The same thing in the groups of four banks, statements and humans print
     * (`DE89 3704 0044 0532 0130 00`).
     *
     * The groups are matched as groups — `{4}` at a time — rather than as "alphanumerics with
     * optional spaces", which is the tempting spelling and the wrong one: it is greedy across the
     * space and happily swallows the next word or two of the log line. Anchoring on the printed
     * shape costs nothing (nobody writes an IBAN in groups of three) and keeps the blast radius at
     * the account number.
     */
    private const IBAN_GROUPED = '/\b[A-Za-z]{2}\d{2}(?:[ ][A-Za-z0-9]{4}){2,7}(?:[ ][A-Za-z0-9]{1,4})?\b/';

    /**
     * A candidate card number: 13-19 digits, optionally grouped by single spaces or hyphens.
     *
     * This one is a *candidate* only. Order numbers, customer ids, invoice numbers and packed
     * timestamps are digit runs too, and masking those would make the log useless for exactly the
     * debugging `read_logs` exists for. So the match is confirmed by {@see self::luhn()} before
     * anything is replaced — every real PAN passes it, and an arbitrary id has roughly a 1-in-10
     * chance of doing so by accident.
     */
    private const CARD = '/(?<![\d\-])(?:\d[ \-]?){12,18}\d(?![\d\-])/';

    public function redact(string $text): string
    {
        $text = (string)\preg_replace(self::ASSIGNMENT, '$1' . self::REDACTED, $text);
        $text = (string)\preg_replace(self::BEARER, '$1 ' . self::REDACTED, $text);

        // Order matters, and only in one direction: the replacement `***` contains neither a letter
        // nor a digit, so a value masked by an earlier rule can never be re-matched — and an e-mail
        // whose local part is alphanumeric enough to look like an IBAN is masked as the address it
        // is, not as half an account number.
        $text = (string)\preg_replace(self::EMAIL, self::REDACTED, $text);
        $text = (string)\preg_replace(self::IBAN_GROUPED, self::REDACTED, $text);
        $text = (string)\preg_replace(self::IBAN, self::REDACTED, $text);

        return (string)\preg_replace_callback(
            self::CARD,
            static fn (array $m): string => self::luhn($m[0]) ? self::REDACTED : $m[0],
            $text
        );
    }

    /**
     * The ISO/IEC 7812 check digit every issued card number carries.
     *
     * Kept here rather than pulled from a package because it is eight lines and the alternative is a
     * dependency inside a redaction path that must never fail open.
     */
    private static function luhn(string $candidate): bool
    {
        $digits = \preg_replace('/\D/', '', $candidate) ?? '';
        $length = \strlen($digits);
        if ($length < 13 || $length > 19) {
            return false;
        }
        $sum = 0;
        for ($i = 0; $i < $length; $i++) {
            $digit = (int)$digits[$length - 1 - $i];
            if ($i % 2 === 1) {
                $digit *= 2;
                if ($digit > 9) {
                    $digit -= 9;
                }
            }
            $sum += $digit;
        }

        return $sum % 10 === 0;
    }

    public function looksRedacted(string $original, string $redacted): bool
    {
        return $original !== $redacted;
    }
}
