<?php

declare(strict_types=1);

namespace App\Application\Admin;

use App\Domain\Audit\AuditAction;
use App\Domain\Audit\AuditEmitter;
use App\Domain\Auth\Role;
use App\Domain\Auth\TokenHasher;
use App\Domain\Auth\TokenIssuer;
use App\Domain\Auth\TokenKind;
use App\Domain\Time\Clock;
use App\Infrastructure\Auth\TokenRecord;
use App\Infrastructure\Auth\TokenRepository;
use App\Infrastructure\Auth\UserRepository;
use App\Infrastructure\Consumer\ConsumerRepository;
use App\Infrastructure\Reporter\ReporterRepository;
use DateTimeImmutable;
use DateTimeZone;
use Doctrine\DBAL\Connection;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

/**
 * Admin token CRUD. Three creatable kinds — `reporter`, `consumer`,
 * `admin` — each with its own constraint:
 *
 *   reporter → reporter_id required, no role / consumer_id
 *   consumer → consumer_id required, no role / reporter_id
 *   admin    → role required, no FKs
 *
 * `service` is rejected with 400; service tokens come from the bootstrap
 * path only. The list endpoint filters service-kind out unconditionally.
 *
 * The raw token string appears **only** in the create response — we
 * persist its SHA-256 hash and forget the rest.
 */
final class TokensController
{
    use AdminControllerSupport;

    public function __construct(
        private readonly TokenRepository $tokens,
        private readonly TokenIssuer $issuer,
        private readonly TokenHasher $hasher,
        private readonly ReporterRepository $reporters,
        private readonly ConsumerRepository $consumers,
        private readonly UserRepository $users,
        private readonly Clock $clock,
        private readonly AuditEmitter $audit,
        private readonly Connection $connection,
    ) {
    }

    public function list(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
    {
        $page = self::pagination($request);
        $rows = $this->tokens->listNonService($page['limit'], $page['offset']);
        $total = $this->tokens->countNonService();

        // Resolve the unique user ids referenced by these rows so the UI can
        // render "issued by …" without a per-row N+1. Tokens minted before
        // SEC_REVIEW F16 (or via `bin/console`) carry user_id=null and are
        // labelled "—" in the UI.
        $userIds = array_values(array_unique(array_filter(
            array_map(static fn (TokenRecord $r): ?int => $r->userId, $rows),
            static fn (?int $id): bool => $id !== null,
        )));
        $userLabels = [];
        foreach ($userIds as $uid) {
            $user = $this->users->findById($uid);
            if ($user !== null) {
                $userLabels[$uid] = $user->displayName ?? $user->email ?? ('user#' . $uid);
            }
        }

        $data = array_map(static function (TokenRecord $r) use ($userLabels): array {
            return [
                'id' => $r->id,
                'kind' => $r->kind->value,
                'prefix' => $r->prefix,
                'reporter_id' => $r->reporterId,
                'consumer_id' => $r->consumerId,
                'role' => $r->role?->value,
                'user_id' => $r->userId,
                'user_label' => $r->userId !== null ? ($userLabels[$r->userId] ?? null) : null,
                'expires_at' => $r->expiresAt?->format('Y-m-d\TH:i:s\Z'),
                'revoked_at' => $r->revokedAt?->format('Y-m-d\TH:i:s\Z'),
                'last_used_at' => $r->lastUsedAt?->format('Y-m-d\TH:i:s\Z'),
            ];
        }, $rows);

        return self::json($response, 200, [
            'data' => $data,
            'page' => $page['page'],
            'limit' => $page['limit'],
            'total' => $total,
        ]);
    }

    public function create(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
    {
        $body = self::jsonBody($request);
        $errors = [];

        $kindValue = isset($body['kind']) && is_string($body['kind']) ? $body['kind'] : '';
        $kind = TokenKind::tryFrom($kindValue);
        if ($kind === null) {
            return self::validationFailed($response, ['kind' => 'must be reporter, consumer, or admin']);
        }
        if ($kind === TokenKind::Service) {
            return self::error($response, 400, 'service tokens cannot be created via API');
        }

        $reporterId = self::optInt($body['reporter_id'] ?? null);
        $consumerId = self::optInt($body['consumer_id'] ?? null);
        $roleValue = isset($body['role']) && is_string($body['role']) ? $body['role'] : null;
        $role = $roleValue !== null ? Role::tryFrom($roleValue) : null;

        $expiresAt = null;
        if (array_key_exists('expires_at', $body) && $body['expires_at'] !== null) {
            if (!is_string($body['expires_at'])) {
                $errors['expires_at'] = 'must be ISO 8601 string';
            } else {
                $parsed = self::parseUtc($body['expires_at']);
                if ($parsed === null) {
                    $errors['expires_at'] = 'must be ISO 8601 string';
                } elseif ($parsed <= $this->clock->now()) {
                    $errors['expires_at'] = 'must be in the future';
                } else {
                    $expiresAt = $parsed;
                }
            }
        }

        switch ($kind) {
            case TokenKind::Reporter:
                if ($reporterId === null) {
                    $errors['reporter_id'] = 'required for reporter tokens';
                } elseif ($this->reporters->findById($reporterId) === null) {
                    $errors['reporter_id'] = 'unknown reporter';
                }
                if ($consumerId !== null) {
                    $errors['consumer_id'] = 'must be null for reporter tokens';
                }
                if ($roleValue !== null) {
                    $errors['role'] = 'must be null for reporter tokens';
                }
                break;
            case TokenKind::Consumer:
                if ($consumerId === null) {
                    $errors['consumer_id'] = 'required for consumer tokens';
                } elseif ($this->consumers->findById($consumerId) === null) {
                    $errors['consumer_id'] = 'unknown consumer';
                }
                if ($reporterId !== null) {
                    $errors['reporter_id'] = 'must be null for consumer tokens';
                }
                if ($roleValue !== null) {
                    $errors['role'] = 'must be null for consumer tokens';
                }
                break;
            case TokenKind::Admin:
                if ($role === null) {
                    $errors['role'] = 'required for admin tokens (viewer|operator|admin)';
                }
                if ($reporterId !== null) {
                    $errors['reporter_id'] = 'must be null for admin tokens';
                }
                if ($consumerId !== null) {
                    $errors['consumer_id'] = 'must be null for admin tokens';
                }
                break;
            default:
                // Unreachable; service was already rejected.
                break;
        }

        if ($errors !== []) {
            return self::validationFailed($response, $errors);
        }

        $raw = $this->issuer->issue($kind);
        $hash = $this->hasher->hash($raw);
        $prefix = substr($raw, 0, 8);

        // SEC_REVIEW F16: admin tokens carry their issuer's user_id so
        // TokenAuthenticationMiddleware can refuse the token after the
        // issuer is disabled or demoted below the token's role. Reporter
        // and consumer tokens stay user-less — they are device credentials,
        // not delegated user privilege. If no acting user is present
        // (impossible via the live admin endpoints, defensive for tests),
        // the token is created unbound and behaves like a legacy admin row.
        $issuerUserId = $kind === TokenKind::Admin ? self::actingUserId($request) : null;

        $auditCtx = self::auditContext($request);
        $created = $this->connection->transactional(function () use ($kind, $hash, $prefix, $reporterId, $consumerId, $role, $expiresAt, $issuerUserId, $auditCtx): TokenRecord {
            $this->tokens->create(new TokenRecord(
                id: null,
                kind: $kind,
                hash: $hash,
                prefix: $prefix,
                reporterId: $kind === TokenKind::Reporter ? $reporterId : null,
                consumerId: $kind === TokenKind::Consumer ? $consumerId : null,
                role: $kind === TokenKind::Admin ? $role : null,
                expiresAt: $expiresAt,
                revokedAt: null,
                lastUsedAt: null,
                userId: $issuerUserId,
            ));

            $created = $this->tokens->findByHashIncludingInvalid($hash);
            if ($created === null) {
                throw new \RuntimeException('token not retrievable after insert');
            }

            // Audit payload deliberately excludes the raw token. Prefix is OK.
            $this->audit->emitOrThrow(
                AuditAction::TOKEN_CREATED,
                'token',
                $created->id,
                [
                    'kind' => $created->kind->value,
                    'prefix' => $created->prefix,
                    'reporter_id' => $created->reporterId,
                    'consumer_id' => $created->consumerId,
                    'role' => $created->role?->value,
                    'user_id' => $created->userId,
                    'expires_at' => $created->expiresAt?->format('c'),
                ],
                $auditCtx,
                self::tokenLabel($created->kind->value, $created->prefix, $created->role?->value),
            );

            return $created;
        });

        return self::json($response, 201, [
            'id' => $created->id,
            'kind' => $created->kind->value,
            'prefix' => $created->prefix,
            'reporter_id' => $created->reporterId,
            'consumer_id' => $created->consumerId,
            'role' => $created->role?->value,
            'user_id' => $created->userId,
            'expires_at' => $created->expiresAt?->format('Y-m-d\TH:i:s\Z'),
            'raw_token' => $raw,
        ]);
    }

    /**
     * @param array{id: string} $args
     */
    public function delete(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
    {
        $id = self::parseId($args['id']);
        if ($id === null) {
            return self::error($response, 404, 'not_found');
        }
        $token = $this->tokens->findById($id);
        if ($token === null) {
            return self::error($response, 404, 'not_found');
        }
        if ($token->kind === TokenKind::Service) {
            return self::error($response, 403, 'cannot revoke service tokens via API');
        }

        $auditCtx = self::auditContext($request);
        $now = $this->clock->now();
        $this->connection->transactional(function () use ($id, $token, $auditCtx, $now): void {
            $this->tokens->revoke($id, $now);
            $this->audit->emitOrThrow(
                AuditAction::TOKEN_REVOKED,
                'token',
                $id,
                ['kind' => $token->kind->value, 'prefix' => $token->prefix],
                $auditCtx,
                self::tokenLabel($token->kind->value, $token->prefix, $token->role?->value),
            );
        });

        return $response->withStatus(204);
    }

    /**
     * Hard-delete a revoked, non-service token. Refuses (409) on tokens
     * that are still active to keep "revoke first, then prune" the only
     * code path that removes a row.
     *
     * @param array{id: string} $args
     */
    public function purge(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
    {
        $id = self::parseId($args['id']);
        if ($id === null) {
            return self::error($response, 404, 'not_found');
        }
        $token = $this->tokens->findById($id);
        if ($token === null) {
            return self::error($response, 404, 'not_found');
        }
        if ($token->kind === TokenKind::Service) {
            return self::error($response, 403, 'cannot delete service tokens via API');
        }
        if ($token->revokedAt === null) {
            return self::json($response, 409, [
                'error' => 'not_revoked',
                'message' => 'Revoke the token first; only revoked tokens can be deleted.',
            ]);
        }

        $auditCtx = self::auditContext($request);
        $this->connection->transactional(function () use ($id, $token, $auditCtx): void {
            $this->tokens->deleteRow($id);
            $this->audit->emitOrThrow(
                AuditAction::TOKEN_DELETED,
                'token',
                $id,
                ['kind' => $token->kind->value, 'prefix' => $token->prefix],
                $auditCtx,
                self::tokenLabel($token->kind->value, $token->prefix, $token->role?->value),
            );
        });

        return $response->withStatus(204);
    }

    private static function optInt(mixed $value): ?int
    {
        if (is_int($value) && $value > 0) {
            return $value;
        }
        if (is_string($value) && ctype_digit($value) && $value !== '0') {
            return (int) $value;
        }

        return null;
    }

    private static function parseUtc(string $iso): ?DateTimeImmutable
    {
        try {
            return new DateTimeImmutable($iso, new DateTimeZone('UTC'));
        } catch (\Exception) {
            return null;
        }
    }

    private static function parseId(string $raw): ?int
    {
        return preg_match('/^[1-9][0-9]*$/', $raw) === 1 ? (int) $raw : null;
    }

    /**
     * Compose a human-friendly label like "admin viewer (abc12345…)" or
     * "consumer (zxcvbnm9…)". Includes the role for admin tokens so the
     * audit reader can tell a viewer-token revoke from an admin-token revoke.
     */
    private static function tokenLabel(string $kind, string $prefix, ?string $role): string
    {
        $base = $kind === 'admin' && $role !== null ? sprintf('admin %s', $role) : $kind;

        return sprintf('%s (%s…)', $base, $prefix);
    }
}
