<?php

declare(strict_types=1);

namespace App\Application\Auth;

use App\Domain\Audit\AuditAction;
use App\Domain\Audit\AuditContext;
use App\Domain\Audit\AuditEmitter;
use App\Domain\Auth\Role;
use App\Infrastructure\Auth\UserRepository;
use App\Infrastructure\Http\Middleware\AuditContextMiddleware;
use Doctrine\DBAL\Connection;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

/**
 * Auth API controller — exclusively service-token routes per SPEC §6.
 *
 * Service-token-only enforcement is applied at the route level (the auth
 * routes attach RbacMiddleware::require(Role::Admin) on top of the
 * impersonation middleware, but the service token is the only realistic
 * caller). For the auth endpoints we additionally guard against admin
 * tokens calling them — see the explicit kind check below.
 */
final class AuthController
{
    public function __construct(
        private readonly UserRepository $users,
        private readonly Role $oidcDefaultRole,
        private readonly AuditEmitter $audit,
        private readonly Connection $connection,
    ) {
    }

    public function upsertOidc(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
    {
        if (($denied = $this->requireServiceToken($request, $response)) !== null) {
            return $denied;
        }

        $body = $this->json($request);
        $subject = self::str($body, 'subject');
        // SEC_REVIEW F33: email is nullable — some IdPs (or Entra app
        // configurations that omit the email scope) don't release it.
        // Accept missing / null / empty as "no email released".
        $email = self::nullableStr($body, 'email');
        $displayName = self::str($body, 'display_name');
        $groups = self::stringList($body, 'groups');

        if ($subject === '' || $displayName === '') {
            return self::error($response, 400, 'invalid request body');
        }

        // SEC_REVIEW F11: a disabled user must not be able to log in.
        // Check before opening the transaction so we don't recompute role
        // (which would emit `user.role_changed` for a denied login).
        $existingPre = $this->users->findBySubject($subject);
        if ($existingPre !== null && $existingPre->isDisabled()) {
            return self::error($response, 403, 'user_disabled');
        }

        $auditCtx = self::auditContext($request);

        $user = $this->connection->transactional(function () use ($subject, $email, $displayName, $groups, $auditCtx) {
            $existing = $this->users->findBySubject($subject);
            $user = $this->users->upsertOidc($subject, $email, $displayName, $groups, $this->oidcDefaultRole);

            if ($existing === null) {
                // SEC_REVIEW F5: account creation is a primary SOC event;
                // emit a user.created row attributed to the service-token
                // call (kind=system) with source IP from AuditContextMiddleware.
                // SEC_REVIEW F33: fall back to displayName when the IdP
                // didn't release `email`, so the audit row carries a
                // human-readable label either way.
                $this->audit->emitOrThrow(
                    AuditAction::USER_CREATED,
                    'user',
                    $user->id,
                    [
                        'source' => 'oidc',
                        'subject' => $subject,
                        'email' => $email,
                        'display_name' => $displayName,
                        'role' => $user->role->value,
                        'groups' => $groups,
                    ],
                    $auditCtx,
                    $email ?? $displayName,
                );
            } elseif ($existing->role !== $user->role) {
                // Role drift on subsequent OIDC login (group membership change).
                $this->audit->emitOrThrow(
                    AuditAction::USER_ROLE_CHANGED,
                    'user',
                    $user->id,
                    [
                        'source' => 'oidc',
                        'subject' => $subject,
                        'changes' => ['role' => ['from' => $existing->role->value, 'to' => $user->role->value]],
                        'groups' => $groups,
                    ],
                    $auditCtx,
                    $email ?? $displayName,
                );
            }

            return $user;
        });

        return self::json_response($response, 200, [
            'user_id' => $user->id,
            'role' => $user->role->value,
            'email' => $user->email,
            'display_name' => $user->displayName,
            'is_local' => false,
        ]);
    }

    public function upsertLocal(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
    {
        if (($denied = $this->requireServiceToken($request, $response)) !== null) {
            return $denied;
        }

        $body = $this->json($request);
        $username = self::str($body, 'username');
        if ($username === '') {
            return self::error($response, 400, 'invalid request body');
        }

        // SEC_REVIEW F11: refuse if the local-admin row exists but has been
        // disabled. Operator must re-enable via /admin/users before the
        // local sign-in path works again.
        $existingPre = $this->users->findLocal();
        if ($existingPre !== null && $existingPre->isDisabled()) {
            return self::error($response, 403, 'user_disabled');
        }

        $auditCtx = self::auditContext($request);

        $user = $this->connection->transactional(function () use ($username, $auditCtx) {
            $existing = $this->users->findLocal();
            $user = $this->users->upsertLocal($username);

            if ($existing === null) {
                // SEC_REVIEW F5: first local-admin bootstrap.
                $this->audit->emitOrThrow(
                    AuditAction::USER_CREATED,
                    'user',
                    $user->id,
                    [
                        'source' => 'local',
                        'display_name' => $username,
                        'role' => $user->role->value,
                    ],
                    $auditCtx,
                    $username,
                );
            }

            return $user;
        });

        return self::json_response($response, 200, [
            'user_id' => $user->id,
            'role' => $user->role->value,
            'email' => null,
            'display_name' => $user->displayName,
            'is_local' => true,
        ]);
    }

    public function getUser(
        ServerRequestInterface $request,
        ResponseInterface $response,
        string $id,
    ): ResponseInterface {
        if (($denied = $this->requireServiceToken($request, $response)) !== null) {
            return $denied;
        }

        if (preg_match('/^[1-9][0-9]*$/', $id) !== 1) {
            return self::error($response, 400, 'invalid user id');
        }

        $requestedId = (int) $id;
        $user = $this->users->findById($requestedId);
        $auditCtx = self::auditContext($request);

        // SEC_REVIEW F17: emit `user.fetched` for both 200 and 404 so
        // iterative enumeration of `/users/{id}` by a service-token
        // holder is observable in audit_log. Best-effort — a DB hiccup
        // on the audit insert must not 500 a UI session refresh.
        if ($user === null) {
            $this->audit->emit(
                AuditAction::USER_FETCHED,
                'user',
                $requestedId,
                ['outcome' => 'not_found'],
                $auditCtx,
                null,
            );

            return self::error($response, 404, 'not_found');
        }

        $this->audit->emit(
            AuditAction::USER_FETCHED,
            'user',
            $user->id,
            ['outcome' => 'found'],
            $auditCtx,
            $user->email ?? $user->displayName,
        );

        return self::json_response($response, 200, [
            'user_id' => $user->id,
            'role' => $user->role->value,
            'email' => $user->email,
            'display_name' => $user->displayName,
            'is_local' => $user->isLocal,
        ]);
    }

    private static function auditContext(ServerRequestInterface $request): AuditContext
    {
        $ctx = $request->getAttribute(AuditContextMiddleware::ATTR_AUDIT_CONTEXT);

        return $ctx instanceof AuditContext ? $ctx : AuditContext::system();
    }

    private function requireServiceToken(
        ServerRequestInterface $request,
        ResponseInterface $response,
    ): ?ResponseInterface {
        $record = $request->getAttribute(\App\Infrastructure\Http\Middleware\TokenAuthenticationMiddleware::ATTR_TOKEN_RECORD);
        if (
            !$record instanceof \App\Infrastructure\Auth\TokenRecord
            || $record->kind !== \App\Domain\Auth\TokenKind::Service
        ) {
            return self::error($response, 403, 'forbidden');
        }

        return null;
    }

    /**
     * @return array<string, mixed>
     */
    private function json(ServerRequestInterface $request): array
    {
        $parsed = $request->getParsedBody();
        if (is_array($parsed)) {
            return $parsed;
        }
        $raw = (string) $request->getBody();
        if ($raw === '') {
            return [];
        }
        $decoded = json_decode($raw, true);

        return is_array($decoded) ? $decoded : [];
    }

    /**
     * @param array<string, mixed> $body
     */
    private static function str(array $body, string $key): string
    {
        return isset($body[$key]) && is_string($body[$key]) ? $body[$key] : '';
    }

    /**
     * @param array<string, mixed> $body
     */
    private static function nullableStr(array $body, string $key): ?string
    {
        if (!isset($body[$key]) || !is_string($body[$key])) {
            return null;
        }
        $value = $body[$key];

        return $value === '' ? null : $value;
    }

    /**
     * @param array<string, mixed> $body
     * @return list<string>
     */
    private static function stringList(array $body, string $key): array
    {
        if (!isset($body[$key]) || !is_array($body[$key])) {
            return [];
        }

        $out = [];
        foreach ($body[$key] as $item) {
            if (is_string($item) && $item !== '') {
                $out[] = $item;
            }
        }

        return $out;
    }

    private static function error(ResponseInterface $response, int $status, string $message): ResponseInterface
    {
        return self::json_response($response, $status, ['error' => $message]);
    }

    /**
     * @param array<string, mixed> $payload
     */
    private static function json_response(ResponseInterface $response, int $status, array $payload): ResponseInterface
    {
        $response = $response->withStatus($status)->withHeader('Content-Type', 'application/json');
        $response->getBody()->write((string) json_encode($payload));

        return $response;
    }
}
