<?php

declare(strict_types=1);

namespace App\Infrastructure\Auth;

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

/**
 * DBAL-backed gateway for the users table.
 *
 * Owns identity upsert flows for OIDC and local sign-in. The OIDC flow
 * recomputes role from current group memberships on every login so role
 * changes in Entra propagate without an admin needing to "re-link" users.
 */
final class UserRepository
{
    private const COLUMNS = 'id, subject, email, display_name, role, is_local, disabled_at, last_login_at, created_at';

    public function __construct(
        private readonly Connection $connection,
        private readonly RoleMappingRepository $roleMappings,
    ) {
    }

    public function findById(int $id): ?User
    {
        /** @var array<string, mixed>|false $row */
        $row = $this->connection->fetchAssociative(
            'SELECT ' . self::COLUMNS . ' FROM users WHERE id = :id',
            ['id' => $id]
        );

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

    public function findBySubject(string $subject): ?User
    {
        /** @var array<string, mixed>|false $row */
        $row = $this->connection->fetchAssociative(
            'SELECT ' . self::COLUMNS . ' FROM users WHERE subject = :subject',
            ['subject' => $subject]
        );

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

    /**
     * Finds the single local-admin row, if any. SPEC §6/§8 dictates that
     * the system has at most one local admin (its password lives in the
     * UI's env, the API only mirrors the identity record). The DB has a
     * partial unique index enforcing the same invariant.
     *
     * Identifying by `is_local=1` rather than by `display_name` matches
     * the structural invariant — `display_name` is mutable (operators can
     * rename the local admin via `LOCAL_ADMIN_USERNAME`) and matching on
     * it would otherwise let a service-token holder mint additional Admin
     * users by varying the `username` payload (SEC_REVIEW F3).
     *
     * The query additionally requires `subject IS NULL` (SEC_REVIEW F12):
     * local-admin rows have a null OIDC subject by construction. If a row
     * exists with `is_local=1` AND a non-null subject (impossible via
     * application code, only reachable via direct DB tampering or a
     * compromised "data-fix" script), refuse to bind the local-admin
     * password to that hijacked identity. The migration
     * 20260505100000_add_users_local_subject_invariant enforces the same
     * invariant at the DB layer (MySQL CHECK constraint; SQLite triggers).
     */
    public function findLocal(): ?User
    {
        /** @var array<string, mixed>|false $row */
        $row = $this->connection->fetchAssociative(
            'SELECT ' . self::COLUMNS . ' FROM users '
            . 'WHERE is_local = :local AND subject IS NULL '
            . 'ORDER BY id ASC LIMIT 1',
            ['local' => 1]
        );

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

    /**
     * Admin "users" page: list all users with newest first. Pagination is
     * intentionally simple (limit + offset) since the user count is bounded
     * by the IdP — typically tens, not millions.
     *
     * @return list<User>
     */
    public function listAll(int $limit, int $offset): array
    {
        /** @var list<array<string, mixed>> $rows */
        $rows = $this->connection->fetchAllAssociative(
            'SELECT ' . self::COLUMNS . ' FROM users '
            . 'ORDER BY id DESC LIMIT :limit OFFSET :offset',
            ['limit' => $limit, 'offset' => $offset],
            ['limit' => \Doctrine\DBAL\ParameterType::INTEGER, 'offset' => \Doctrine\DBAL\ParameterType::INTEGER],
        );

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

    public function countAll(): int
    {
        return (int) $this->connection->fetchOne('SELECT COUNT(*) FROM users');
    }

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

    public function enable(int $id): void
    {
        $this->connection->update('users', ['disabled_at' => null], ['id' => $id]);
    }

    /**
     * `$email` is nullable: SPEC §10 / SEC_REVIEW F33 — some IdPs do not
     * release the `email` claim (configuration-side or scope-side), and the
     * UI surfaces `?string` from `OidcClaims->email`. The boundary used to
     * reject null emails with a 400 (and earlier still would have raised a
     * TypeError on the way to the repository); now it persists `null` and
     * lets the caller fall back to `displayName` for any user-facing label.
     *
     * @param list<string> $groupIds
     */
    public function upsertOidc(
        string $subject,
        ?string $email,
        string $displayName,
        array $groupIds,
        Role $defaultRole,
    ): User {
        $resolvedRole = $this->roleMappings->resolveRole($groupIds, $defaultRole);
        $now = (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Y-m-d H:i:s');

        $existing = $this->findBySubject($subject);
        if ($existing !== null) {
            $this->connection->update(
                'users',
                [
                    'email' => $email,
                    'display_name' => $displayName,
                    'role' => $resolvedRole->value,
                    'last_login_at' => $now,
                ],
                ['id' => $existing->id]
            );

            return new User(
                id: $existing->id,
                subject: $subject,
                email: $email,
                displayName: $displayName,
                role: $resolvedRole,
                isLocal: false,
                disabledAt: $existing->disabledAt,
            );
        }

        $this->connection->insert('users', [
            'subject' => $subject,
            'email' => $email,
            'display_name' => $displayName,
            'role' => $resolvedRole->value,
            'is_local' => 0,
            'last_login_at' => $now,
        ]);

        $id = (int) $this->connection->lastInsertId();

        return new User(
            id: $id,
            subject: $subject,
            email: $email,
            displayName: $displayName,
            role: $resolvedRole,
            isLocal: false,
        );
    }

    public function upsertLocal(string $username): User
    {
        $existing = $this->findLocal();
        $now = (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Y-m-d H:i:s');

        if ($existing !== null) {
            // Bind the (single) existing local row to this login. If the
            // operator has rotated `LOCAL_ADMIN_USERNAME` in UI env, the
            // display_name follows. Role is always Admin per SPEC §8 — we
            // re-assert it here so a row tampered with directly in the DB
            // is healed on next login.
            $this->connection->update(
                'users',
                [
                    'display_name' => $username,
                    'role' => Role::Admin->value,
                    'last_login_at' => $now,
                ],
                ['id' => $existing->id]
            );

            return new User(
                id: $existing->id,
                subject: null,
                email: null,
                displayName: $username,
                role: Role::Admin,
                isLocal: true,
                disabledAt: $existing->disabledAt,
            );
        }

        $this->connection->insert('users', [
            'subject' => null,
            'email' => null,
            'display_name' => $username,
            'role' => Role::Admin->value,
            'is_local' => 1,
            'last_login_at' => $now,
        ]);

        $id = (int) $this->connection->lastInsertId();

        return new User(
            id: $id,
            subject: null,
            email: null,
            displayName: $username,
            role: Role::Admin,
            isLocal: true,
        );
    }

    /**
     * @param array<string, mixed> $row
     */
    private function hydrate(array $row): User
    {
        $disabledAt = null;
        if (isset($row['disabled_at']) && $row['disabled_at'] !== null && $row['disabled_at'] !== '') {
            $disabledAt = new DateTimeImmutable((string) $row['disabled_at'], new DateTimeZone('UTC'));
        }

        return new User(
            id: (int) $row['id'],
            subject: isset($row['subject']) && $row['subject'] !== null ? (string) $row['subject'] : null,
            email: isset($row['email']) && $row['email'] !== null ? (string) $row['email'] : null,
            displayName: isset($row['display_name']) && $row['display_name'] !== null ? (string) $row['display_name'] : null,
            role: Role::from((string) $row['role']),
            isLocal: (bool) $row['is_local'],
            disabledAt: $disabledAt,
        );
    }
}
