<?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\Controllers;

use App\Auth\BootstrapAdmin;
use App\Auth\LocalAdmin;
use App\Auth\OidcClaims;
use App\Auth\OidcClient;
use App\Auth\SessionGuard;
use App\Http\Request;
use App\Http\Response;
use App\Http\View;
use App\Repositories\AuthThrottleRepository;
use App\Repositories\UserRepository;
use App\Services\AuditLogger;
use DateTimeImmutable;
use DateTimeZone;
use PDO;
use Throwable;

final class AuthController
{
    public function __construct(
        private readonly PDO                    $pdo,
        private readonly UserRepository         $users,
        private readonly AuditLogger            $audit,
        private readonly View                   $view,
        private readonly AuthThrottleRepository $throttle,
    ) {
    }

    /** GET /auth/login — kick off the OIDC flow. */
    public function login(Request $req): Response
    {
        SessionGuard::start();

        if (!OidcClient::isConfigured()) {
            return Response::html(
                $this->configErrorPage(),
                503
            );
        }

        try {
            $oidc = OidcClient::create();
            // No 'code' in the query on this route → authenticate() builds the
            // Entra authorize URL and redirects via header() + exit. The line
            // below is only reached if the library ever changes that contract.
            $oidc->authenticate();
        } catch (Throwable $e) {
            $this->logFailure($req, 'login_redirect_failed: ' . $e->getMessage());
            return Response::redirect('/?auth_error=1');
        }

        return Response::redirect('/');
    }

    /** GET /auth/callback — exchange code for tokens, upsert user, start session. */
    public function callback(Request $req): Response
    {
        SessionGuard::start();

        // Mirror the kill-switch from /auth/login: when OIDC is unconfigured
        // or explicitly disabled (`OIDC_ENABLED=false`), the callback path is
        // inert — render the same operator-facing config page instead of
        // letting the OIDC library try to validate state we'll never accept.
        if (!OidcClient::isConfigured()) {
            return Response::html($this->configErrorPage(), 503);
        }

        // Entra can redirect back with an explicit error (e.g. user denied consent).
        if (isset($req->query['error'])) {
            $desc = (string) ($req->query['error_description'] ?? $req->query['error']);
            $this->logFailure($req, 'entra_error: ' . $desc);
            return Response::redirect('/?auth_error=1');
        }

        try {
            $oidc    = OidcClient::create();
            $oidc->authenticate();
            $claims = $oidc->getVerifiedClaims();
        } catch (Throwable $e) {
            $this->logFailure($req, 'token_validation_failed: ' . $e->getMessage());
            return Response::redirect('/?auth_error=1');
        }

        $oid = (string) ($claims->oid ?? $claims->sub ?? '');

        if ($oid === '') {
            $this->logFailure($req, 'missing_oid_or_sub_claim');
            return Response::redirect('/?auth_error=1');
        }

        // R01-N18: never fall back to `preferred_username` (user-controlled
        // in some Entra tenants) and reject `email` when the issuer marks
        // it unverified. Identity is still keyed by `oid`; this only
        // governs the human-readable label that ends up in the audit log.
        $email = OidcClaims::resolveEmail($claims, $oid);
        $name  = (string) ($claims->name ?? $email);

        // R01-N03: explicit env-bootstrap. The OIDC path no longer auto-
        // promotes the first user to land — an attacker with a valid
        // tenant account could otherwise win the race to /auth/callback.
        // The signing principal must match BOOTSTRAP_ADMIN_OID or
        // BOOTSTRAP_ADMIN_EMAIL, AND no admin must yet exist.
        $shouldBootstrap = $this->users->countAdmins() === 0
            && BootstrapAdmin::matches($oid, $email);

        $this->pdo->beginTransaction();
        try {
            $result = $this->users->upsertFromOidc($oid, $email, $name, $shouldBootstrap);
            $user   = $result['user'];
            $before = $result['before']?->toAuditSnapshot();

            $action = $before === null ? 'CREATE' : 'UPDATE';
            $this->audit->record(
                action:     $action,
                entityType: 'user',
                entityId:   $user->id,
                before:     $before,
                after:      $user->toAuditSnapshot(),
                userId:     $user->id,
                userEmail:  $user->email,
                ipAddress:  $req->ip(),
                userAgent:  $req->userAgent(),
            );

            if ($shouldBootstrap) {
                $this->audit->record(
                    action:     'BOOTSTRAP_ADMIN',
                    entityType: 'user',
                    entityId:   $user->id,
                    before:     null,
                    after:      ['is_admin' => 1, 'via' => 'oidc'],
                    userId:     $user->id,
                    userEmail:  $user->email,
                    ipAddress:  $req->ip(),
                    userAgent:  $req->userAgent(),
                );
            }

            $this->audit->record(
                action:     'LOGIN',
                entityType: 'user',
                entityId:   $user->id,
                before:     null,
                after:      null,
                userId:     $user->id,
                userEmail:  $user->email,
                ipAddress:  $req->ip(),
                userAgent:  $req->userAgent(),
            );

            $this->pdo->commit();
        } catch (Throwable $e) {
            $this->pdo->rollBack();
            $this->logFailure($req, 'user_upsert_failed: ' . $e->getMessage());
            return Response::redirect('/?auth_error=1');
        }

        SessionGuard::login($user);
        return Response::redirect('/');
    }

    /** POST /auth/logout — end the session; CSRF-protected. */
    public function logout(Request $req): Response
    {
        SessionGuard::start();

        if (!SessionGuard::verifyCsrf($req)) {
            return Response::text('CSRF token invalid', 403);
        }

        $user = SessionGuard::currentUser($this->users);
        if ($user !== null) {
            $this->pdo->beginTransaction();
            try {
                $this->audit->record(
                    action:     'LOGOUT',
                    entityType: 'user',
                    entityId:   $user->id,
                    before:     null,
                    after:      null,
                    userId:     $user->id,
                    userEmail:  $user->email,
                    ipAddress:  $req->ip(),
                    userAgent:  $req->userAgent(),
                );
                $this->pdo->commit();
            } catch (Throwable) {
                $this->pdo->rollBack();
                // audit failure shouldn't block logout
            }
        }

        SessionGuard::logout();
        return Response::redirect('/');
    }

    /** GET /auth/local — render the local-admin login form. 404 when disabled. */
    public function loginLocalForm(Request $req): Response
    {
        if (!LocalAdmin::isEnabled()) {
            return Response::text('Not Found', 404);
        }
        SessionGuard::start();
        $error     = $req->queryString('error') === '1';
        $throttled = $req->queryString('throttled') === '1';
        return Response::html($this->view->render('auth/local', [
            'title'        => 'Local sign-in',
            'currentUser'  => null,
            'csrfToken'    => SessionGuard::csrfToken(),
            'email'        => LocalAdmin::email(),
            'error'        => $error,
            'throttled'    => $throttled,
        ]));
    }

    /** POST /auth/local — verify credentials, upsert user, start session. */
    public function loginLocal(Request $req): Response
    {
        if (!LocalAdmin::isEnabled()) {
            return Response::text('Not Found', 404);
        }
        SessionGuard::start();

        if (!SessionGuard::verifyCsrf($req)) {
            return Response::text('CSRF token invalid', 403);
        }

        $email    = $req->postString('email');
        $password = isset($req->post['password']) && is_scalar($req->post['password'])
            ? (string) $req->post['password']
            : '';

        $ip  = $req->ip();
        $now = new DateTimeImmutable('now', new DateTimeZone('UTC'));

        // R01-N06: refuse and audit while a lockout is active. We do this
        // BEFORE password verification so a slow `password_verify()` can't
        // be turned into an oracle by a still-locked attacker.
        $lockedUntil = $this->throttle->lockoutFor($ip, $email, $now);
        if ($lockedUntil !== null) {
            $this->logFailure(
                $req,
                'local_admin_throttled_until_'
                    . $lockedUntil->format('Y-m-d\TH:i:s\Z')
            );
            return Response::redirect('/auth/local?throttled=1');
        }

        if (!LocalAdmin::verify($email, $password)) {
            $this->throttle->recordFailure($ip, $email, $now);
            $this->logFailure($req, 'local_admin_credential_mismatch');
            return Response::redirect('/auth/local?error=1');
        }

        // Clear the (ip, email) bucket on success so the next bad password
        // doesn't lock out a legitimate operator who just signed in.
        $this->throttle->clear($ip, $email);

        $this->pdo->beginTransaction();
        try {
            $isFirstUser = $this->users->count() === 0;
            $result      = $this->users->upsertFromOidc(
                oid:            LocalAdmin::oid(),
                email:          LocalAdmin::email(),
                name:           LocalAdmin::displayName(),
                promoteToAdmin: $isFirstUser,
                forceAdmin:     true,
            );
            $user   = $result['user'];
            $before = $result['before']?->toAuditSnapshot();

            $action = $before === null ? 'CREATE' : 'UPDATE';
            $this->audit->record(
                action:     $action,
                entityType: 'user',
                entityId:   $user->id,
                before:     $before,
                after:      $user->toAuditSnapshot(),
                userId:     $user->id,
                userEmail:  $user->email,
                ipAddress:  $req->ip(),
                userAgent:  $req->userAgent(),
            );

            if ($isFirstUser) {
                $this->audit->record(
                    action:     'BOOTSTRAP_ADMIN',
                    entityType: 'user',
                    entityId:   $user->id,
                    before:     null,
                    after:      ['is_admin' => 1, 'via' => 'local'],
                    userId:     $user->id,
                    userEmail:  $user->email,
                    ipAddress:  $req->ip(),
                    userAgent:  $req->userAgent(),
                );
            }

            $this->audit->record(
                action:     'LOGIN',
                entityType: 'user',
                entityId:   $user->id,
                before:     null,
                after:      ['via' => 'local'],
                userId:     $user->id,
                userEmail:  $user->email,
                ipAddress:  $req->ip(),
                userAgent:  $req->userAgent(),
            );

            $this->pdo->commit();
        } catch (Throwable $e) {
            $this->pdo->rollBack();
            $this->logFailure($req, 'local_admin_upsert_failed: ' . $e->getMessage());
            return Response::redirect('/auth/local?error=1');
        }

        SessionGuard::login($user);
        return Response::redirect('/');
    }

    /** Write a LOGIN_FAILED audit row in its own tx; never throws. */
    private function logFailure(Request $req, string $reason): void
    {
        try {
            $this->pdo->beginTransaction();
            $this->audit->record(
                action:     'LOGIN_FAILED',
                entityType: 'user',
                entityId:   null,
                before:     null,
                after:      ['reason' => $reason],
                userId:     null,
                userEmail:  null,
                ipAddress:  $req->ip(),
                userAgent:  $req->userAgent(),
            );
            $this->pdo->commit();
        } catch (Throwable) {
            if ($this->pdo->inTransaction()) {
                $this->pdo->rollBack();
            }
        }
    }

    private function configErrorPage(): string
    {
        $disabled = OidcClient::isExplicitlyDisabled();
        $title    = $disabled ? 'OIDC disabled' : 'OIDC not configured';
        $body     = $disabled
            ? '<code>OIDC_ENABLED</code> is set to <code>false</code> in <code>.env</code>. '
                . 'Use the local admin sign-in (<code>/auth/local</code>) or re-enable OIDC '
                . 'and restart the container.'
            : 'Set <code>ENTRA_TENANT_ID</code>, <code>ENTRA_CLIENT_ID</code>, '
                . '<code>ENTRA_CLIENT_SECRET</code> and <code>APP_BASE_URL</code> in '
                . '<code>.env</code> and restart the container.';
        return <<<HTML
            <!doctype html><meta charset="utf-8">
            <title>{$title}</title>
            <div style="font-family:system-ui;max-width:560px;margin:4rem auto;padding:1rem;border:1px solid #e2e8f0;border-radius:8px">
              <h1 style="margin:0 0 .5rem;font-size:1.1rem">Sign-in is not available</h1>
              <p style="color:#475569;line-height:1.5">{$body}</p>
            </div>
            HTML;
    }
}
