<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\Backend;

use JTL\DB\DbInterface;

/**
 * Turns the **audit id** of the presented token into the backend login the invocation acts as
 * (decision Q1).
 *
 * ## Why this class has to exist
 *
 * `BackendIdentityFactory::forTokenUser()` already resolves a *user identifier* to an admin. What it
 * cannot do is find that identifier, because what reaches
 * {@see \Plugin\tnt_mcp\src\Mcp\Server::dispatch()} is not a token — it is the audit label
 * {@see \Plugin\tnt_mcp\src\Mcp\Auth\TokenGuard::tokenId()} produced, and the two OAuth/legacy paths
 * spell it differently:
 *
 * - `oauth_<first 12 chars of tnt_mcp_oauth_access_token.cToken>`
 *   (`OAuthTokenValidator.php:55-58` — the id is deliberately truncated so a full token id never
 *   lands in a log line).
 * - `tok_<tnt_mcp_token.kToken>` for a legacy static token (`TokenGuard.php:61`).
 *
 * So the label is looked up by prefix, and the lookup is **exact-or-nothing**: a prefix matching two
 * rows resolves to no admin at all rather than to the first one. A write path that picks an admin by
 * "close enough" is a privilege-escalation bug with a plausible-looking cause.
 *
 * ## Legacy static tokens can never invoke
 *
 * `tnt_mcp_token` rows carry a label and scopes and no admin whatsoever — they were minted in the
 * plugin's own admin page before OAuth existed, and nothing recorded who approved them. There is
 * therefore no identity to act as, and the answer is a refusal with the repair in it, never a
 * default admin. Same boundary, same reasoning as {@see LegacyTokenException}.
 */
final class TokenAdminResolver
{
    /** How many characters of the OAuth token id the audit label carries (`OAuthTokenValidator.php:56`). */
    private const OAUTH_PREFIX_LENGTH = 12;

    public function __construct(
        private readonly DbInterface $db,
        private readonly BackendIdentityFactory $identities,
    ) {
    }

    public static function default(DbInterface $db): self
    {
        return new self($db, new BackendIdentityFactory($db));
    }

    /**
     * @return string `tadminlogin.cLogin`
     *
     * @throws LegacyTokenException  when the token records no admin (legacy static token, or an
     *                               OAuth token minted before admin-identity binding)
     * @throws UnknownAdminException when it records one that no longer resolves to a usable row
     */
    public function loginFor(string $tokenId): string
    {
        return $this->identities->forTokenUser($this->userIdFor($tokenId))->login;
    }

    private function userIdFor(string $tokenId): string
    {
        if (\str_starts_with($tokenId, 'tok_')) {
            throw new LegacyTokenException(
                'This connection uses a static plugin token (' . $tokenId . '), which records no '
                . 'backend administrator. invoke_admin_action acts as the admin who approved the '
                . 'OAuth consent, so there is nobody to act as here. Connect the MCP client through '
                . 'the OAuth flow instead; read-only tools keep working with the static token.',
            );
        }

        $prefix = \str_starts_with($tokenId, 'oauth_') ? \substr($tokenId, 6) : '';
        if ($prefix === '' || \preg_match('/^[A-Za-z0-9_-]{1,64}$/', $prefix) !== 1) {
            throw new LegacyTokenException(
                'The presented token has no resolvable identity (audit id "' . $tokenId . '"), so '
                . 'there is no administrator to act as. Re-authorise the connection in your MCP '
                . 'client to issue a bound token.',
            );
        }

        $rows = $this->db->getObjects(
            'SELECT cUserId FROM tnt_mcp_oauth_access_token WHERE LEFT(cToken, :len) = :prefix',
            ['len' => \min(\strlen($prefix), self::OAUTH_PREFIX_LENGTH), 'prefix' => $prefix],
        );
        if (\count($rows) !== 1) {
            throw new LegacyTokenException(\sprintf(
                'The presented token could not be resolved to exactly one issued token (audit id '
                . '"%s" matched %d). Nothing was invoked: acting as a "closest match" administrator '
                . 'is not something this plugin will do. Re-authorise the connection in your MCP '
                . 'client.',
                $tokenId,
                \count($rows),
            ));
        }

        return (string)($rows[0]->cUserId ?? '');
    }
}
