<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\Admin;

use JTL\DB\DbInterface;
use Throwable;

/**
 * Read model for the admin "Tokens & Audit" tab. Aggregates, for display only, the legacy static
 * bearer tokens, the dynamically-registered OAuth clients, the issued OAuth access tokens, and the
 * audit log — across both the legacy (tnt_mcp_token / tnt_mcp_audit) and OAuth tables.
 *
 * Secret hygiene: NO secret is ever surfaced. Legacy tokens are stored only as a sha256 hash and it
 * is never returned; only the stable id (`tok_<k>`), label, scopes and metadata are shown. OAuth
 * access-token ids and client secrets are masked to a short prefix. Best-effort throughout: a
 * missing table yields an empty section rather than an exception.
 */
final class TokenAuditOverview
{
    public function __construct(private readonly DbInterface $db)
    {
    }

    /**
     * @return list<array<string,mixed>>
     */
    public function legacyTokens(): array
    {
        $rows = $this->query(
            'SELECT kToken, cLabel, cScopes, nActive, dCreated, dLastUsed
               FROM tnt_mcp_token ORDER BY kToken DESC',
        );

        $out = [];
        foreach ($rows as $row) {
            $out[] = [
                'id'       => 'tok_' . (int)($row->kToken ?? 0),
                'kToken'   => (int)($row->kToken ?? 0),
                'label'    => (string)($row->cLabel ?? ''),
                'scopes'   => $this->decodeScopes($row->cScopes ?? null),
                'active'   => (int)($row->nActive ?? 0) === 1,
                'created'  => (string)($row->dCreated ?? ''),
                'lastUsed' => (string)($row->dLastUsed ?? ''),
            ];
        }

        return $out;
    }

    /**
     * @return list<array<string,mixed>>
     */
    public function oauthClients(): array
    {
        // A shop accumulates a client per re-registration, and ChatGPT registers itself as
        // "ChatGPT" every time — five identical names distinguishable only by a UUID, with
        // "delete" as the only action offered (docs/UI_REVIEW.md U26). The newest token and
        // whether any of them is still live is what tells them apart.
        $rows = $this->query(
            'SELECT c.cClientId, c.cName, c.nConfidential, c.cScopes, c.cGrantTypes, c.dCreated,
                    (SELECT MAX(t.dCreated) FROM tnt_mcp_oauth_access_token t WHERE t.cClientId = c.cClientId) AS dLastToken,
                    (SELECT SUM(CASE WHEN t.nRevoked = 0 AND (t.dExpires IS NULL OR t.dExpires > NOW())
                                     THEN 1 ELSE 0 END)
                       FROM tnt_mcp_oauth_access_token t WHERE t.cClientId = c.cClientId) AS nLive
               FROM tnt_mcp_oauth_client c
              ORDER BY c.dCreated DESC',
        );

        $out = [];
        foreach ($rows as $row) {
            $out[] = [
                'clientId'     => (string)($row->cClientId ?? ''),
                'name'         => (string)($row->cName ?? ''),
                'confidential' => (int)($row->nConfidential ?? 0) === 1,
                'scopes'       => $this->decodeScopes($row->cScopes ?? null),
                'grantTypes'   => $this->decodeScopes($row->cGrantTypes ?? null),
                'created'      => (string)($row->dCreated ?? ''),
                'lastToken'    => (string)($row->dLastToken ?? ''),
                // "Has a token that would still be accepted right now" — not "was used recently".
                // A client with none of those is the one that is safe to delete.
                'live'         => (int)($row->nLive ?? 0) > 0,
            ];
        }

        return $out;
    }

    /**
     * True when `dExpires` is in the past. An empty or unparsable value counts as NOT expired: the
     * status column must never claim a token is dead on the strength of a date it could not read.
     */
    private function hasExpired(string $expires): bool
    {
        $expires = \trim($expires);
        // MySQL's zero date is "no value", but strtotime() happily turns it into a real (negative)
        // timestamp — which would report every such row as long expired.
        if ($expires === '' || \str_starts_with($expires, '0000-00-00')) {
            return false;
        }

        $timestamp = \strtotime($expires);

        return $timestamp !== false && $timestamp < \time();
    }

    /**
     * @return list<array<string,mixed>>
     */
    public function oauthTokens(int $limit = 100): array
    {
        $rows = $this->query(
            'SELECT cToken, cClientId, cScopes, dExpires, nRevoked, dCreated
               FROM tnt_mcp_oauth_access_token ORDER BY dCreated DESC LIMIT ' . $this->limit($limit),
        );

        $out = [];
        foreach ($rows as $row) {
            $out[] = [
                'tokenPrefix' => $this->mask((string)($row->cToken ?? '')),
                // Non-reversible handle used to target this row for deletion without ever
                // putting the raw access token into the rendered HTML.
                'ref'         => hash('sha256', (string)($row->cToken ?? '')),
                'clientId'    => (string)($row->cClientId ?? ''),
                'scopes'      => $this->decodeScopes($row->cScopes ?? null),
                'expires'     => (string)($row->dExpires ?? ''),
                'revoked'     => (int)($row->nRevoked ?? 0) === 1,
                // A token past dExpires is dead whether or not anyone revoked it. Without this the
                // tab painted every expired token green "aktiv" while Server-Status counted the
                // same rows as 0 active — two tabs answering "who has access" differently
                // (docs/UI_REVIEW.md U24).
                'expired'     => $this->hasExpired((string)($row->dExpires ?? '')),
                'created'     => (string)($row->dCreated ?? ''),
            ];
        }

        return $out;
    }

    /**
     * @return list<array<string,mixed>>
     */
    public function auditEntries(int $limit = 200): array
    {
        $rows = $this->query(
            'SELECT kAudit, cTokenId, cTool, cArgs, cOutcome, dCreated
               FROM tnt_mcp_audit ORDER BY kAudit DESC LIMIT ' . $this->limit($limit),
        );

        $out = [];
        foreach ($rows as $row) {
            $outcome = (string)($row->cOutcome ?? '');
            $args    = mb_substr((string)($row->cArgs ?? ''), 0, 300);

            $out[] = [
                'id'      => (int)($row->kAudit ?? 0),
                'tokenId' => (string)($row->cTokenId ?? ''),
                'tool'    => (string)($row->cTool ?? ''),
                // An empty call has no arguments, and printing the encoder's `[]`/`{}` into the
                // column made a successful no-arg tool read like a truncated payload
                // (docs/UI_REVIEW.md U34). Blank, so the template can render the muted dash.
                'args'    => \in_array(\trim($args), ['', '[]', '{}'], true) ? '' : $args,
                'outcome' => $outcome,
                // The table exact-matched `ok` and painted EVERYTHING else danger, so a successful
                // write — `ok:coupon.coupon.create` — was the same red as `refused:*` (U35). The
                // verdict is the part before the colon and AuditOutcome already knows how to read
                // it; the summary tally on Aktivität has used it since U28.
                'tone'    => AuditOutcome::tone($outcome),
                'created' => (string)($row->dCreated ?? ''),
            ];
        }

        return $out;
    }

    /**
     * @return list<\stdClass>
     */
    private function query(string $sql): array
    {
        try {
            // getObjects() is typed array<stdClass>; a fetch-all result is always sequential, and
            // array_values() makes that guarantee explicit for the list<> contract above.
            return \array_values($this->db->getObjects($sql));
        } catch (Throwable) {
            return [];
        }
    }

    /** @return list<string> */
    private function decodeScopes(mixed $raw): array
    {
        if (!\is_string($raw) || $raw === '') {
            return [];
        }
        $decoded = json_decode($raw, true);
        if (!\is_array($decoded)) {
            return [];
        }

        $out = [];
        foreach ($decoded as $scope) {
            if (\is_string($scope) && $scope !== '') {
                $out[] = $scope;
            }
        }

        return $out;
    }

    /** Mask an opaque id to a short, non-reversible prefix so no full token id is ever shown. */
    private function mask(string $value): string
    {
        if ($value === '') {
            return '';
        }

        return mb_substr($value, 0, 8) . '…';
    }

    private function limit(int $limit): int
    {
        return max(1, min($limit, 1000));
    }
}
