<?php
/*
 * Copyright 2026 Alessandro Chiapparini <sprint_planer_web@chiapparini.org>
 * SPDX-License-Identifier: Apache-2.0
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * See the LICENSE file in the project root for the full license text.
 */

declare(strict_types=1);

namespace App\Repositories;

use App\Domain\User;
use PDO;
use RuntimeException;

final class UserRepository
{
    public function __construct(private readonly PDO $pdo)
    {
    }

    public function find(int $id): ?User
    {
        $stmt = $this->pdo->prepare('SELECT * FROM users WHERE id = ?');
        $stmt->execute([$id]);
        $row = $stmt->fetch();
        return is_array($row) ? self::hydrate($row) : null;
    }

    public function findByOid(string $oid): ?User
    {
        $stmt = $this->pdo->prepare('SELECT * FROM users WHERE entra_oid = ?');
        $stmt->execute([$oid]);
        $row = $stmt->fetch();
        return is_array($row) ? self::hydrate($row) : null;
    }

    public function count(): int
    {
        return (int) $this->pdo->query('SELECT COUNT(*) FROM users')->fetchColumn();
    }

    /**
     * Insert-or-update a user keyed by entra_oid. Returns the hydrated row and
     * the version *before* the call (null for a newly-created user) so the
     * caller can audit the change.
     *
     * @param bool $promoteToAdmin  Set is_admin=1 on INSERT only.
     * @param bool $forceAdmin      Additionally set is_admin=1 on UPDATE. Used
     *                              for the local-admin login path so the
     *                              configured admin keeps admin rights.
     *
     * @return array{user: User, before: ?User}
     */
    public function upsertFromOidc(
        string $oid,
        string $email,
        string $name,
        bool $promoteToAdmin,
        bool $forceAdmin = false,
    ): array {
        $now      = gmdate('Y-m-d\TH:i:s\Z');
        $existing = $this->findByOid($oid);

        if ($existing === null) {
            $stmt = $this->pdo->prepare(
                'INSERT INTO users (entra_oid, email, display_name, is_admin, created_at, last_login_at)
                 VALUES (?, ?, ?, ?, ?, ?)'
            );
            $stmt->execute([
                $oid,
                $email,
                $name,
                ($promoteToAdmin || $forceAdmin) ? 1 : 0,
                $now,
                $now,
            ]);
            $id = (int) $this->pdo->lastInsertId();
            $user = $this->find($id);
            if ($user === null) {
                throw new RuntimeException('Inserted user not found');
            }
            return ['user' => $user, 'before' => null];
        }

        // R01-N23: a successful sign-in by a tombstoned user un-tombstones
        // them. The tombstone is a display-redaction marker, not an access
        // control. If the user can produce a valid OIDC identity (or the
        // configured local-admin credentials, where forceAdmin is set), the
        // org has not actually revoked their access — the `(former user)`
        // label would lie. The clear is part of the same UPDATE so the
        // audit `before/after` snapshot captures the transition cleanly.
        if ($forceAdmin) {
            $stmt = $this->pdo->prepare(
                'UPDATE users SET email = ?, display_name = ?, last_login_at = ?, is_admin = 1, tombstoned_at = NULL WHERE id = ?'
            );
            $stmt->execute([$email, $name, $now, $existing->id]);
        } else {
            $stmt = $this->pdo->prepare(
                'UPDATE users SET email = ?, display_name = ?, last_login_at = ?, tombstoned_at = NULL WHERE id = ?'
            );
            $stmt->execute([$email, $name, $now, $existing->id]);
        }

        $after = $this->find($existing->id) ?? $existing;
        return ['user' => $after, 'before' => $existing];
    }

    /** @return list<User> ordered by LOWER(email) ASC */
    public function all(): array
    {
        $stmt = $this->pdo->query('SELECT * FROM users ORDER BY LOWER(email) ASC');
        $out = [];
        foreach ($stmt as $row) {
            $out[] = self::hydrate($row);
        }
        return $out;
    }

    /** How many admin users currently exist. */
    public function countAdmins(): int
    {
        return (int) $this->pdo
            ->query('SELECT COUNT(*) FROM users WHERE is_admin = 1')
            ->fetchColumn();
    }

    /**
     * Force is_admin to the given value. Returns before/after so the caller
     * can audit. Guardrails (cannot demote self, cannot remove the last
     * admin) live in the controller — this repo only does the SQL.
     *
     * @return array{before: User, after: User}
     */
    public function setAdmin(int $id, bool $isAdmin): array
    {
        $before = $this->find($id);
        if ($before === null) {
            throw new \RuntimeException("User {$id} not found");
        }
        $this->pdo
            ->prepare('UPDATE users SET is_admin = ? WHERE id = ?')
            ->execute([$isAdmin ? 1 : 0, $id]);
        $after = $this->find($id) ?? $before;
        return ['before' => $before, 'after' => $after];
    }

    /**
     * R01-N23: tombstone or restore a user. Tombstoning also forces
     * `is_admin = 0` because a tombstoned admin is nonsensical (the
     * UI hides the row's name/email). Restore clears the timestamp but
     * does NOT auto-promote — admin restoration is a separate, explicit
     * action via setAdmin(). Returns before/after for audit.
     *
     * @return array{before: User, after: User}
     */
    public function setTombstoned(int $id, bool $tombstoned): array
    {
        $before = $this->find($id);
        if ($before === null) {
            throw new \RuntimeException("User {$id} not found");
        }
        if ($tombstoned) {
            $this->pdo
                ->prepare('UPDATE users SET tombstoned_at = ?, is_admin = 0 WHERE id = ?')
                ->execute([gmdate('Y-m-d\TH:i:s\Z'), $id]);
        } else {
            $this->pdo
                ->prepare('UPDATE users SET tombstoned_at = NULL WHERE id = ?')
                ->execute([$id]);
        }
        $after = $this->find($id) ?? $before;
        return ['before' => $before, 'after' => $after];
    }

    /**
     * @param array<string,mixed> $row
     */
    private static function hydrate(array $row): User
    {
        return new User(
            id:          (int) $row['id'],
            entraOid:    (string) $row['entra_oid'],
            email:       (string) $row['email'],
            displayName: (string) $row['display_name'],
            isAdmin:     ((int) $row['is_admin']) === 1,
            createdAt:   (string) $row['created_at'],
            lastLoginAt: isset($row['last_login_at']) && $row['last_login_at'] !== null
                ? (string) $row['last_login_at']
                : null,
            tombstonedAt: isset($row['tombstoned_at']) && $row['tombstoned_at'] !== null
                ? (string) $row['tombstoned_at']
                : null,
        );
    }
}
