<?php

declare(strict_types=1);

namespace App\Application\Admin;

use App\Domain\Audit\AuditAction;
use App\Domain\Audit\AuditEmitter;
use App\Domain\Time\Clock;
use App\Domain\User\User;
use App\Infrastructure\Auth\UserRepository;
use Doctrine\DBAL\Connection;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

/**
 * `/api/v1/admin/users` — admin-only user roster + disable/enable.
 *
 * Read surface: paginated list of all `users`. Write surface: a single
 * `PATCH /{id}` that toggles `disabled_at` (SEC_REVIEW F11). Disabling a
 * user immediately blocks impersonation: ImpersonationMiddleware refuses
 * to rewrite the principal, and `AuthController::upsert{Oidc,Local}`
 * refuses to recompute role on the disabled row, so a subsequent OIDC or
 * local sign-in attempt 403s.
 *
 * Two guard rails on disable:
 *   - Cannot disable yourself (`self_disable`) — the operator who would
 *     have to re-enable the row would be locked out alongside.
 *   - Cannot disable the local-admin row (`cannot_disable_local_admin`) —
 *     the local admin is the documented break-glass path. Per SPEC §6,
 *     there's exactly one such row; locking it requires DB access to
 *     recover. If an operator really wants to disable local sign-in,
 *     they unset `LOCAL_ADMIN_PASSWORD_HASH` in the UI's env instead.
 *
 * Both writes are wrapped in `Connection::transactional()` + emitOrThrow
 * per SEC_REVIEW F4 — a failed audit insert rolls back the state change.
 */
final class UsersController
{
    use AdminControllerSupport;

    public function __construct(
        private readonly UserRepository $users,
        private readonly AuditEmitter $audit,
        private readonly Connection $connection,
        private readonly Clock $clock,
    ) {
    }

    public function list(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
    {
        $page = self::pagination($request, defaultLimit: 50, maxLimit: 200);
        $items = $this->users->listAll($page['limit'], $page['offset']);
        $total = $this->users->countAll();

        return self::json($response, 200, [
            'items' => array_map(self::serialize(...), $items),
            'page' => $page['page'],
            'page_size' => $page['limit'],
            'total' => $total,
        ]);
    }

    /**
     * @param array{id: string} $args
     */
    public function show(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
    {
        $id = self::parseId($args['id']);
        if ($id === null) {
            return self::error($response, 404, 'not_found');
        }

        $user = $this->users->findById($id);
        if ($user === null) {
            return self::error($response, 404, 'not_found');
        }

        return self::json($response, 200, self::serialize($user));
    }

    /**
     * @param array{id: string} $args
     */
    public function update(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
    {
        $id = self::parseId($args['id']);
        if ($id === null) {
            return self::error($response, 404, 'not_found');
        }

        $existing = $this->users->findById($id);
        if ($existing === null) {
            return self::error($response, 404, 'not_found');
        }

        $body = self::jsonBody($request);
        if (!array_key_exists('disabled', $body) || !is_bool($body['disabled'])) {
            return self::validationFailed($response, ['disabled' => 'required, boolean']);
        }
        $targetDisabled = $body['disabled'];

        // Idempotent: state already matches → 200 with the row, no audit emit.
        if ($targetDisabled === $existing->isDisabled()) {
            return self::json($response, 200, self::serialize($existing));
        }

        if ($targetDisabled) {
            $actingUserId = self::actingUserId($request);
            if ($actingUserId === $id) {
                return self::json($response, 409, ['error' => 'cannot_disable_self']);
            }
            if ($existing->isLocal) {
                return self::json($response, 409, ['error' => 'cannot_disable_local_admin']);
            }
        }

        $auditCtx = self::auditContext($request);
        $this->connection->transactional(function () use ($id, $targetDisabled, $existing, $auditCtx): void {
            if ($targetDisabled) {
                $this->users->disable($id, $this->clock->now());
                $this->audit->emitOrThrow(
                    AuditAction::USER_DISABLED,
                    'user',
                    $id,
                    [
                        'subject' => $existing->subject,
                        'email' => $existing->email,
                        'display_name' => $existing->displayName,
                        'role' => $existing->role->value,
                        'is_local' => $existing->isLocal,
                    ],
                    $auditCtx,
                    $existing->email ?? $existing->displayName,
                );
            } else {
                $this->users->enable($id);
                $this->audit->emitOrThrow(
                    AuditAction::USER_ENABLED,
                    'user',
                    $id,
                    [
                        'subject' => $existing->subject,
                        'email' => $existing->email,
                        'display_name' => $existing->displayName,
                        'role' => $existing->role->value,
                        'is_local' => $existing->isLocal,
                        'previously_disabled_at' => $existing->disabledAt?->format('c'),
                    ],
                    $auditCtx,
                    $existing->email ?? $existing->displayName,
                );
            }
        });

        $updated = $this->users->findById($id);
        if ($updated === null) {
            return self::error($response, 500, 'update_failed');
        }

        return self::json($response, 200, self::serialize($updated));
    }

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

    /**
     * @return array{
     *   id: int,
     *   subject: ?string,
     *   email: ?string,
     *   display_name: ?string,
     *   role: string,
     *   is_local: bool,
     *   disabled: bool,
     *   disabled_at: ?string,
     * }
     */
    private static function serialize(User $user): array
    {
        return [
            'id' => $user->id,
            'subject' => $user->subject,
            'email' => $user->email,
            'display_name' => $user->displayName,
            'role' => $user->role->value,
            'is_local' => $user->isLocal,
            'disabled' => $user->isDisabled(),
            'disabled_at' => $user->disabledAt?->format('c'),
        ];
    }
}
