<?php

declare(strict_types=1);

namespace App\Infrastructure\Auth;

use App\Domain\Auth\Role;
use App\Domain\Auth\TokenKind;
use DateTimeImmutable;
use DateTimeZone;
use Doctrine\DBAL\Connection;

/**
 * DBAL-backed gateway for the api_tokens table.
 *
 * `findByHash()` already filters out revoked and expired rows, so callers
 * can treat any non-null return as "currently valid". Time comparisons are
 * done in UTC against ISO 8601 strings (SQLite) or DATETIME(6) values
 * (MySQL) — both lexicographically sortable when expressed in UTC.
 */
final class TokenRepository
{
    public function __construct(private readonly Connection $connection)
    {
    }

    public function findByHash(string $hash): ?TokenRecord
    {
        $now = (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Y-m-d H:i:s');

        $sql = 'SELECT id, kind, token_hash, token_prefix, reporter_id, consumer_id, role, user_id, '
            . 'expires_at, revoked_at, last_used_at '
            . 'FROM api_tokens '
            . 'WHERE token_hash = :hash '
            . 'AND revoked_at IS NULL '
            . 'AND (expires_at IS NULL OR expires_at > :now) '
            . 'LIMIT 1';

        /** @var array<string, mixed>|false $row */
        $row = $this->connection->fetchAssociative($sql, [
            'hash' => $hash,
            'now' => $now,
        ]);

        if ($row === false) {
            return null;
        }

        return $this->hydrate($row);
    }

    public function findByHashIncludingInvalid(string $hash): ?TokenRecord
    {
        $sql = 'SELECT id, kind, token_hash, token_prefix, reporter_id, consumer_id, role, user_id, '
            . 'expires_at, revoked_at, last_used_at '
            . 'FROM api_tokens WHERE token_hash = :hash LIMIT 1';

        /** @var array<string, mixed>|false $row */
        $row = $this->connection->fetchAssociative($sql, ['hash' => $hash]);

        if ($row === false) {
            return null;
        }

        return $this->hydrate($row);
    }

    public function create(TokenRecord $record): int
    {
        $this->connection->insert('api_tokens', [
            'token_hash' => $record->hash,
            'token_prefix' => $record->prefix,
            'kind' => $record->kind->value,
            'reporter_id' => $record->reporterId,
            'consumer_id' => $record->consumerId,
            'role' => $record->role?->value,
            'user_id' => $record->userId,
            'expires_at' => $record->expiresAt?->format('Y-m-d H:i:s'),
            'revoked_at' => $record->revokedAt?->format('Y-m-d H:i:s'),
            'last_used_at' => $record->lastUsedAt?->format('Y-m-d H:i:s'),
        ]);

        return (int) $this->connection->lastInsertId();
    }

    public function markUsed(int $id, DateTimeImmutable $when): void
    {
        $this->connection->update(
            'api_tokens',
            ['last_used_at' => $when->format('Y-m-d H:i:s')],
            ['id' => $id]
        );
    }

    public function findById(int $id): ?TokenRecord
    {
        /** @var array<string, mixed>|false $row */
        $row = $this->connection->fetchAssociative(
            'SELECT id, kind, token_hash, token_prefix, reporter_id, consumer_id, role, user_id, '
            . 'expires_at, revoked_at, last_used_at FROM api_tokens WHERE id = :id',
            ['id' => $id]
        );

        return $row === false ? null : $this->hydrate($row);
    }

    /**
     * Lists tokens for the admin UI. `service`-kind rows are filtered out
     * unconditionally — they are never an operator's concern.
     *
     * @return list<TokenRecord>
     */
    public function listNonService(int $limit, int $offset): array
    {
        $sql = 'SELECT id, kind, token_hash, token_prefix, reporter_id, consumer_id, role, user_id, '
            . 'expires_at, revoked_at, last_used_at FROM api_tokens '
            . 'WHERE kind != :svc ORDER BY id DESC LIMIT :limit OFFSET :offset';

        /** @var list<array<string, mixed>> $rows */
        $rows = $this->connection->fetchAllAssociative($sql, [
            'svc' => TokenKind::Service->value,
            'limit' => $limit,
            'offset' => $offset,
        ]);

        return array_map(fn (array $r): TokenRecord => $this->hydrate($r), $rows);
    }

    public function countNonService(): int
    {
        return (int) $this->connection->fetchOne(
            'SELECT COUNT(*) FROM api_tokens WHERE kind != :svc',
            ['svc' => TokenKind::Service->value]
        );
    }

    public function revoke(int $id, DateTimeImmutable $when): void
    {
        $this->connection->update(
            'api_tokens',
            ['revoked_at' => $when->format('Y-m-d H:i:s')],
            ['id' => $id]
        );
    }

    /**
     * Hard-delete a token row. Caller MUST have already revoked it; the
     * controller enforces that the token is non-service and revoked
     * before reaching this method.
     */
    public function deleteRow(int $id): void
    {
        $this->connection->delete('api_tokens', ['id' => $id]);
    }

    /**
     * Used by ServiceTokenBootstrap to detect rotation scenarios — i.e. an
     * existing service-kind row whose hash differs from the one we are
     * about to insert.
     */
    public function countServiceTokens(): int
    {
        $value = $this->connection->fetchOne(
            'SELECT COUNT(*) FROM api_tokens WHERE kind = :kind AND revoked_at IS NULL',
            ['kind' => TokenKind::Service->value]
        );

        return (int) $value;
    }

    /**
     * Returns every currently-valid (non-revoked, non-expired) service-kind
     * row. ServiceTokenBootstrap uses this on rotation to revoke each one
     * after the new token has been inserted (SEC_REVIEW F13).
     *
     * @return list<TokenRecord>
     */
    public function findActiveServiceTokens(): array
    {
        $now = (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Y-m-d H:i:s');

        $sql = 'SELECT id, kind, token_hash, token_prefix, reporter_id, consumer_id, role, user_id, '
            . 'expires_at, revoked_at, last_used_at FROM api_tokens '
            . 'WHERE kind = :kind '
            . 'AND revoked_at IS NULL '
            . 'AND (expires_at IS NULL OR expires_at > :now) '
            . 'ORDER BY id ASC';

        /** @var list<array<string, mixed>> $rows */
        $rows = $this->connection->fetchAllAssociative($sql, [
            'kind' => TokenKind::Service->value,
            'now' => $now,
        ]);

        return array_map(fn (array $r): TokenRecord => $this->hydrate($r), $rows);
    }

    /**
     * @param array<string, mixed> $row
     */
    private function hydrate(array $row): TokenRecord
    {
        $kind = TokenKind::from((string) $row['kind']);
        $role = isset($row['role']) && $row['role'] !== null
            ? Role::from((string) $row['role'])
            : null;

        return new TokenRecord(
            id: (int) $row['id'],
            kind: $kind,
            hash: (string) $row['token_hash'],
            prefix: (string) $row['token_prefix'],
            reporterId: $row['reporter_id'] !== null ? (int) $row['reporter_id'] : null,
            consumerId: $row['consumer_id'] !== null ? (int) $row['consumer_id'] : null,
            role: $role,
            expiresAt: self::parseDate($row['expires_at'] ?? null),
            revokedAt: self::parseDate($row['revoked_at'] ?? null),
            lastUsedAt: self::parseDate($row['last_used_at'] ?? null),
            userId: isset($row['user_id']) && $row['user_id'] !== null ? (int) $row['user_id'] : null,
        );
    }

    private static function parseDate(mixed $value): ?DateTimeImmutable
    {
        if ($value === null || $value === '') {
            return null;
        }

        return new DateTimeImmutable((string) $value, new DateTimeZone('UTC'));
    }
}
