<?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);

use App\Auth\LocalAdmin;
use App\Auth\OidcClient;
use App\Auth\SessionGuard;
use App\Controllers\AuditController;
use App\Controllers\AuthController;
use App\Controllers\CspReportController;
use App\Controllers\ImportController;
use App\Controllers\SettingsController;
use App\Controllers\SprintController;
use App\Controllers\TaskController;
use App\Controllers\UserController;
use App\Controllers\WorkerController;
use App\Db\Connection;
use App\Db\Migrator;
use App\Meta;
use App\Http\FatalErrorHandler;
use App\Http\Request;
use App\Http\Response;
use App\Http\Router;
use App\Http\TrustedProxies;
use App\Http\View;
use App\Repositories\AppSettingsRepository;
use App\Repositories\AuditRepository;
use App\Repositories\AuthThrottleRepository;
use App\Repositories\SprintRepository;
use App\Repositories\SprintWeekRepository;
use App\Repositories\SprintWorkerDayRepository;
use App\Repositories\SprintWorkerRepository;
use App\Repositories\TaskAssignmentRepository;
use App\Repositories\TaskRepository;
use App\Repositories\UserRepository;
use App\Repositories\WorkerRepository;
use App\Services\AuditLogger;
use App\Services\Import\SprintImporter;
use App\Services\Import\XlsxSprintImporter;

// Buffer output so a stray warning/notice can't send headers before
// Response::send() gets a chance to set them. send() will flush.
ob_start();

define('APP_ROOT', dirname(__DIR__));

// ---------------------------------------------------------------------------
// Autoload
// ---------------------------------------------------------------------------
$autoload = APP_ROOT . '/vendor/autoload.php';
if (!is_file($autoload)) {
    http_response_code(500);
    header('Content-Type: text/plain; charset=utf-8');
    echo "Composer dependencies are not installed.\n";
    echo "Run: composer install (or rebuild the container).\n";
    exit;
}
require $autoload;

// ---------------------------------------------------------------------------
// Environment
// ---------------------------------------------------------------------------
if (is_file(APP_ROOT . '/.env')) {
    $dotenv = Dotenv\Dotenv::createImmutable(APP_ROOT);
    $dotenv->safeLoad();
}

$appEnv = getenv('APP_ENV') ?: 'production';
if ($appEnv !== 'production') {
    ini_set('display_errors', '1');
    error_reporting(E_ALL);
} else {
    ini_set('display_errors', '0');
}

// Refuse to boot in production with no working sign-in method. `OIDC_ENABLED=
// false` (or simply blank ENTRA_* vars) is fine in dev/testing on top of a
// local-admin fallback, but in prod the operator almost certainly didn't mean
// to ship a fully unreachable instance. Fail fast with a clear log line so the
// misconfiguration surfaces at deploy time, not after the first user tries to
// sign in. Same posture as the schema-pending check below: 503 + Retry-After.
if ($appEnv === 'production'
    && !OidcClient::isConfigured()
    && !LocalAdmin::isEnabled()
) {
    error_log(
        'refusing to start: APP_ENV=production but no sign-in method is '
        . 'configured (set ENTRA_* with OIDC_ENABLED!=false, or '
        . 'LOCAL_ADMIN_EMAIL + LOCAL_ADMIN_PASSWORD_HASH).'
    );
    http_response_code(503);
    header('Content-Type: text/plain; charset=utf-8');
    header('Retry-After: 30');
    echo "Service Unavailable: no sign-in method configured.\n";
    exit;
}

// ---------------------------------------------------------------------------
// R01-N13: install the fatal-error safety net AS EARLY AS POSSIBLE — before
// migrations, before service wiring. An uncaught throwable from anywhere
// below now produces a minimal 500 page with full security headers instead
// of leaking whatever was buffered. We re-register later (with the resolved
// $isHttps) to flip the HSTS bit, but having the handler installed up-front
// covers fatals during bootstrap (e.g. broken migration, missing class).
FatalErrorHandler::register($appEnv, false);

// ---------------------------------------------------------------------------
// R01-N22: schema sanity check (NOT migration).
//
// The request path never applies SQL — `bin/migrate.php` (run by the Docker
// entrypoint or by the operator at deploy time) is the only path that can
// move the schema forward. Here we only OPEN the connection and verify there
// are no pending migration files. If there are, refuse to serve with 503 so
// stale-schema serving cannot happen silently after a forgotten deploy step.
// ---------------------------------------------------------------------------
try {
    $pdo = Connection::pdo();
    $pending = (new Migrator($pdo))->pendingFiles();
} catch (\Throwable $e) {
    error_log('database bootstrap failed: ' . $e->getMessage());
    http_response_code(500);
    header('Content-Type: text/plain; charset=utf-8');
    echo "Database bootstrap failed.\n";
    if ($appEnv !== 'production') {
        echo $e->getMessage() . "\n";
    }
    exit;
}

if ($pending !== []) {
    error_log(
        'schema out of date — pending migrations: '
        . implode(', ', $pending)
        . '. Run `php bin/migrate.php` to apply.'
    );
    http_response_code(503);
    header('Content-Type: text/plain; charset=utf-8');
    header('Retry-After: 30');
    echo "Service Unavailable: database schema is out of date.\n";
    if ($appEnv !== 'production') {
        echo "Pending migrations: " . implode(', ', $pending) . "\n";
        echo "Run: php bin/migrate.php\n";
    }
    exit;
}

// ---------------------------------------------------------------------------
// Shared services
// ---------------------------------------------------------------------------
$twigCacheDir = APP_ROOT . '/data/twig-cache';
if (!is_dir($twigCacheDir)) {
    @mkdir($twigCacheDir, 0775, true);
}
$view          = new View(APP_ROOT . '/views', $twigCacheDir);
$users         = new UserRepository($pdo);
$workers       = new WorkerRepository($pdo);
$sprints       = new SprintRepository($pdo);
$sprintWeeks    = new SprintWeekRepository($pdo);
$sprintWorkers  = new SprintWorkerRepository($pdo);
$swDays         = new SprintWorkerDayRepository($pdo);
$tasks          = new TaskRepository($pdo);
$taskAssign     = new TaskAssignmentRepository($pdo);
$auditRepo      = new AuditRepository($pdo);
$appSettings    = new AppSettingsRepository($pdo);
$authThrottle   = new AuthThrottleRepository($pdo);
$audit          = new AuditLogger($pdo);
$auth           = new AuthController($pdo, $users, $audit, $view, $authThrottle);
$workerCtrl     = new WorkerController($pdo, $users, $workers, $audit, $view);
$sprintCtrl     = new SprintController(
    $pdo, $users, $sprints, $sprintWeeks, $sprintWorkers, $swDays,
    $tasks, $taskAssign, $workers, $audit, $view, $appSettings,
);
$taskCtrl       = new TaskController(
    $pdo, $users, $sprints, $sprintWorkers, $swDays,
    $tasks, $taskAssign, $workers, $audit, $appSettings,
);
$auditCtrl      = new AuditController($users, $auditRepo, $view);
$userCtrl       = new UserController($pdo, $users, $audit, $view);
$settingsCtrl   = new SettingsController($pdo, $users, $appSettings, $audit, $view);
$xlsxParser     = new XlsxSprintImporter();
$importCommit   = new SprintImporter(
    $pdo, $sprints, $sprintWeeks, $sprintWorkers, $swDays,
    $tasks, $taskAssign, $workers, $audit,
);
$importCtrl     = new ImportController(
    $pdo, $users, $sprints, $xlsxParser, $importCommit, $view, $audit,
);
$cspReportCtrl  = new CspReportController($audit);

// ---------------------------------------------------------------------------
// Routing
// ---------------------------------------------------------------------------
$router = new Router();

$router->get('/', function (Request $req) use ($view, $pdo, $users, $sprints, $appEnv): Response {
    $currentUser   = SessionGuard::currentUser($users);
    $schemaVersion = (int) $pdo->query(
        'SELECT COALESCE(MAX(version), 0) FROM schema_version'
    )->fetchColumn();

    $sprintRows = $currentUser === null ? [] : $sprints->allWithCounts();

    // R01-N26: one-shot session flash for the post-delete chip. Set by
    // `SprintController::delete`; consumed (and cleared) here so a manual
    // refresh of `/` cannot re-show the green banner. `currentUser` already
    // forced `SessionGuard::start()`, so $_SESSION is live by this point.
    $deletedSprintName = '';
    if (isset($_SESSION['flash_deleted_sprint_name'])
        && is_string($_SESSION['flash_deleted_sprint_name'])
    ) {
        $deletedSprintName = $_SESSION['flash_deleted_sprint_name'];
        unset($_SESSION['flash_deleted_sprint_name']);
    }

    return Response::html($view->render('home', [
        'title'             => 'Sprint Planner',
        'currentUser'       => $currentUser,
        'schemaVersion'     => $schemaVersion,
        'dbPath'            => Connection::path(),
        'appEnv'            => $appEnv,
        'appVersion'        => Meta::VERSION,
        'appCreator'        => Meta::CREATOR,
        'oidcConfigured'    => OidcClient::isConfigured(),
        'localAdminEnabled' => LocalAdmin::isEnabled(),
        'authError'         => isset($req->query['auth_error']),
        'deletedSprintName' => $deletedSprintName,
        'csrfToken'         => SessionGuard::csrfToken(),
        'sprintRows'        => $sprintRows,
    ]));
});

$router->get('/healthz', fn() => Response::text('ok'));

// R01-N19: browser-fired CSP violation reports. Public POST (no auth, no
// CSRF — see CspReportController). Body capped at 16 KiB; one audit row
// per accepted report.
$router->post('/csp-report', $cspReportCtrl->report(...));

$router->get('/auth/login',     $auth->login(...));
$router->get('/auth/callback',  $auth->callback(...));
$router->post('/auth/logout',   $auth->logout(...));
$router->get('/auth/local',     $auth->loginLocalForm(...));
$router->post('/auth/local',    $auth->loginLocal(...));

$router->get('/workers',         $workerCtrl->index(...));
$router->post('/workers',        $workerCtrl->create(...));
$router->post('/workers/{id}',   $workerCtrl->update(...));

$router->get('/users',                   $userCtrl->index(...));
$router->post('/users/{id}',             $userCtrl->update(...));
$router->post('/users/{id}/tombstone',   $userCtrl->tombstone(...));

$router->get('/sprints/import',           $importCtrl->newForm(...));
$router->post('/sprints/import',          $importCtrl->upload(...));
$router->get('/sprints/import/{token}',   $importCtrl->preview(...));
$router->post('/sprints/import/{token}',  $importCtrl->commit(...));

$router->get('/sprints/new',              $sprintCtrl->newForm(...));
$router->post('/sprints',                 $sprintCtrl->create(...));
$router->get('/sprints/{id}',             $sprintCtrl->show(...));
$router->get('/sprints/{id}/present',     $sprintCtrl->present(...));
$router->get('/sprints/{id}/settings',    $sprintCtrl->settings(...));
$router->post('/sprints/{id}/delete',     $sprintCtrl->delete(...));

// JSON mutation endpoints (admin, CSRF via X-CSRF-Token header):
$router->patch('/sprints/{id}',                       $sprintCtrl->updateMeta(...));
$router->post('/sprints/{id}/weeks',                  $sprintCtrl->replaceWeeks(...));
$router->post('/sprints/{id}/workers',                $sprintCtrl->addWorker(...));
$router->delete('/sprints/{id}/workers/{sw_id}',      $sprintCtrl->removeWorker(...));
$router->post('/sprints/{id}/workers/reorder',        $sprintCtrl->reorderWorkers(...));
$router->patch('/sprints/{id}/workers/{sw_id}',       $sprintCtrl->updateWorker(...));

// Phase 5 — Arbeitstage grid:
$router->patch('/sprints/{id}/week-cells',            $sprintCtrl->updateWeekCells(...));
$router->patch('/sprints/{id}/week/{week_id}',        $sprintCtrl->updateWeekDays(...));

// Phase 6 — Task list:
$router->get('/audit',                                $auditCtrl->index(...));
$router->post('/sprints/{id}/tasks',                  $taskCtrl->create(...));
$router->post('/sprints/{id}/tasks/reorder',          $taskCtrl->reorder(...));
$router->patch('/tasks/{id}',                         $taskCtrl->update(...));
$router->delete('/tasks/{id}',                        $taskCtrl->delete(...));
$router->patch('/tasks/{id}/assignments',             $taskCtrl->updateAssignments(...));
// Phase 18 — task-cell status (any signed-in user, gated by global flag):
$router->patch('/tasks/{id}/assignments/status',      $taskCtrl->updateAssignmentsStatus(...));
// Phase 22 — task move/copy across sprints (admin):
$router->post('/tasks/{id}/move',                     $taskCtrl->moveToSprint(...));
$router->post('/tasks/{id}/copy',                     $taskCtrl->copyToSprint(...));

// Phase 18 — global app settings (admin):
$router->get('/settings',                             $settingsCtrl->show(...));
$router->post('/settings',                            $settingsCtrl->update(...));

// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
$request = Request::fromGlobals();

// R01-N24: refuse to dispatch oversized request bodies. The cap (1 MiB,
// `Request::MAX_BODY_BYTES`) is well above any legitimate JSON payload the
// app expects — the largest batch endpoint
// (`PATCH /sprints/{id}/week-cells`) is bounded at 5000 cells in the
// controller, comfortably under that. Bodies are short-circuited inside
// `Request::fromGlobals()` so the buffer is never held in PHP memory.
// `Response::err` is the canonical JSON envelope; HTML form clients will
// see the JSON in the browser, which is acceptable for a degenerate
// >1 MiB form POST.
if ($request->bodyTooLarge) {
    Response::err(
        'request_too_large',
        'Request body exceeds the ' . (Request::MAX_BODY_BYTES >> 10) . ' KiB cap',
        413,
    )->send();
    if (ob_get_level() > 0) {
        @ob_end_flush();
    }
    exit;
}

// R01-N05: when `APP_BASE_URL` declares HTTPS, refuse to serve sensitive
// flows over plain HTTP — redirect the user to the canonical scheme before
// any controller, session, or auth logic runs. `/healthz` is exempt so
// liveness probes continue to work over either scheme. The decision uses
// the trusted-proxy helper so a TLS-terminating reverse proxy can pass
// `X-Forwarded-Proto: https` and the app will treat the request as secure.
$baseUrl       = (string) (getenv('APP_BASE_URL') ?: '');
$baseIsHttps   = str_starts_with($baseUrl, 'https://');
$proxies       = TrustedProxies::fromEnv();
$requestIsHttps = $proxies->isHttps($_SERVER);

// Only redirect when we can be SURE the live request is genuinely HTTP —
// otherwise a TLS proxy that forgot to set `X-Forwarded-Proto` would loop
// forever (proxy talks HTTPS to user, talks HTTP to us, we redirect, …).
// Sure cases:
//   * no `TRUSTED_PROXIES` configured → REMOTE_ADDR is the user, so the
//     server-side scheme is authoritative;
//   * `TRUSTED_PROXIES` configured AND REMOTE_ADDR is a trusted proxy AND
//     it explicitly told us `X-Forwarded-Proto: http`.
$xfpRaw  = (string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '');
$xfp     = strtolower(trim(strtok($xfpRaw, ',') ?: ''));
$remote  = (string) ($_SERVER['REMOTE_ADDR'] ?? '');
$noProxy = getenv('TRUSTED_PROXIES') === false || trim((string) getenv('TRUSTED_PROXIES')) === '';
$knownHttp = !$requestIsHttps && (
    $noProxy
    || ($remote !== '' && $proxies->isTrusted($remote) && $xfp === 'http')
);

if ($baseIsHttps && $knownHttp && $request->path !== '/healthz') {
    // Cross-origin (scheme-changing) redirect — must go through
    // Response::external() because Response::redirect() only accepts
    // path-only locations now (R01-N20).
    $target = rtrim($baseUrl, '/') . ($_SERVER['REQUEST_URI'] ?? '/');
    Response::external($target, 308)->send();
    if (ob_get_level() > 0) {
        @ob_end_flush();
    }
    exit;
}

// R01-N13: now that we know the resolved HTTPS posture, re-register the
// fatal handler so a fatal mid-dispatch lands HSTS too. Cheap; just a
// closure replacement.
FatalErrorHandler::register($appEnv, $baseIsHttps);

$response = $router->dispatch($request);

// Apply security headers to every response (spec §9). Sourced from the
// FatalErrorHandler so the happy path and the 500-fallback share a single
// CSP + header set — there's no way for a future edit to drift between
// the two paths.
foreach (FatalErrorHandler::securityHeaders($baseIsHttps) as $name => $value) {
    $response->withHeader($name, $value);
}

$response->send();

// Flush the output buffer opened at the top.
if (ob_get_level() > 0) {
    @ob_end_flush();
}
