<?php

declare(strict_types=1);

namespace App\App;

use App\Application\Admin\AllowlistController;
use App\Application\Admin\AppSettingsController;
use App\Application\Admin\AuditController;
use App\Application\Admin\CategoriesController;
use App\Application\Admin\ConfigController;
use App\Application\Admin\ConsumersController;
use App\Application\Admin\IpsController;
use App\Application\Admin\JobsAdminController;
use App\Application\Admin\MaintenanceController;
use App\Application\Admin\ManualBlocksController;
use App\Application\Admin\MeController;
use App\Application\Admin\PoliciesController;
use App\Application\Admin\ReportersController;
use App\Application\Admin\StatsController;
use App\Application\Admin\TokensController;
use App\Application\Admin\UsersController;
use App\Application\Auth\AuthController;
use App\Application\Internal\JobsController;
use App\Application\Public\BlocklistController;
use App\Application\Public\DocsController;
use App\Application\Public\ReportController;
use App\Domain\Auth\Role;
use App\Infrastructure\Http\JsonErrorHandler;
use App\Infrastructure\Http\Middleware\AuditContextMiddleware;
use App\Infrastructure\Http\Middleware\ImpersonationMiddleware;
use App\Infrastructure\Http\Middleware\InternalNetworkMiddleware;
use App\Infrastructure\Http\Middleware\InternalTokenMiddleware;
use App\Infrastructure\Http\Middleware\RateLimitMiddleware;
use App\Infrastructure\Http\Middleware\RbacMiddleware;
use App\Infrastructure\Http\Middleware\TokenAuthenticationMiddleware;
use Psr\Container\ContainerInterface;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Slim\App;
use Slim\Factory\AppFactory as SlimAppFactory;
use Slim\Routing\RouteCollectorProxy;

/**
 * Builds the configured Slim app.
 *
 * Slim middleware is LIFO. To get "TokenAuth → Impersonation → Rbac" we
 * add them in reverse on each route group.
 *
 * Route groups in M04:
 *  - Public    /api/v1/report               RateLimit(ip) → TokenAuth → AuditContext → RateLimit(token) → controller (kind check inside)
 *  - Admin     /api/v1/admin/*              RateLimit(ip) → TokenAuth → Impersonation → AuditContext → RateLimit(token) → Rbac(role)
 *  - Auth      /api/v1/auth/*               RateLimit(ip) → TokenAuth → AuditContext → RateLimit(token) → controller (kind=service inside)
 *
 * The two RateLimit positions (SEC_REVIEW F27, F29) draw on independent
 * `ip:` and `token:` buckets so an invalid-bearer-token flood is
 * throttled before TokenAuthenticationMiddleware ever queries the DB,
 * and an authenticated Viewer cannot drive pathological admin-side
 * queries (F30 IP-search full-table scan, F31 deep-offset audit,
 * F32 N+1 enrichment) without hitting the post-auth token bucket.
 */
final class AppFactory
{
    /**
     * @return App<ContainerInterface|null>
     */
    public static function build(ContainerInterface $container): App
    {
        SlimAppFactory::setContainer($container);
        $app = SlimAppFactory::create();
        $app->addRoutingMiddleware();
        $app->addBodyParsingMiddleware();
        // SEC_REVIEW F69: bound the request body size BEFORE Slim's
        // BodyParsingMiddleware reads it into memory. Slim middleware
        // order is LIFO — adding here AFTER `addBodyParsingMiddleware`
        // means we run BEFORE it on the inbound path, so a 100 MB
        // Reporter spam body never reaches `getContents()`. 256 KiB
        // is generous enough for every legitimate admin payload
        // (largest is a multi-line description, ≤16 KiB) while
        // bounding the worst-case at "no single request can fill
        // memory by itself". Per-endpoint reductions (e.g. the
        // 4 KiB `metadata` cap inside `ReportController`) layer on
        // top.
        /** @var \App\Infrastructure\Http\Middleware\RequestBodySizeLimitMiddleware $bodyLimit */
        $bodyLimit = $container->get(\App\Infrastructure\Http\Middleware\RequestBodySizeLimitMiddleware::class);
        $app->add($bodyLimit);

        /** @var array{app_env: string} $settings */
        $settings = $container->get('settings');
        $isDev = $settings['app_env'] === 'development';

        /** @var LoggerInterface $logger */
        $logger = $container->get(LoggerInterface::class);

        $errorMiddleware = $app->addErrorMiddleware($isDev, true, true, $logger);
        /** @var JsonErrorHandler $handler */
        $handler = $container->get(JsonErrorHandler::class);
        $errorMiddleware->setDefaultErrorHandler($handler);

        /** @var ResponseFactoryInterface $rf */
        $rf = $container->get(ResponseFactoryInterface::class);
        /** @var TokenAuthenticationMiddleware $tokenAuth */
        $tokenAuth = $container->get(TokenAuthenticationMiddleware::class);
        /** @var ImpersonationMiddleware $impersonation */
        $impersonation = $container->get(ImpersonationMiddleware::class);
        /** @var AuditContextMiddleware $auditContext */
        $auditContext = $container->get(AuditContextMiddleware::class);
        /** @var RateLimitMiddleware $rateLimit */
        $rateLimit = $container->get(RateLimitMiddleware::class);
        /** @var InternalNetworkMiddleware $internalNetwork */
        $internalNetwork = $container->get(InternalNetworkMiddleware::class);
        /** @var InternalTokenMiddleware $internalToken */
        $internalToken = $container->get(InternalTokenMiddleware::class);

        // Public docs viewer + OpenAPI spec. SEC_REVIEW F68: gated
        // behind the `API_DOCS_PUBLIC` env flag (off by default) so
        // a recon attacker can't pull the full admin/internal route
        // surface from an unauthenticated endpoint. Operators who
        // want public docs (open APIs, dev environments) set
        // `API_DOCS_PUBLIC=true`; otherwise both routes are simply
        // never registered, so they 404 like any other unmapped path.
        if ((bool) $container->get('settings.api_docs_public')) {
            /** @var DocsController $docs */
            $docs = $container->get(DocsController::class);
            $app->get('/api/v1/openapi.yaml', [$docs, 'spec']);
            $app->get('/api/docs', [$docs, 'viewer']);
        }

        $app->get('/healthz', function (ServerRequestInterface $request, ResponseInterface $response) use ($container): ResponseInterface {
            /** @var array{driver: string} $dbSettings */
            $dbSettings = $container->get('settings.db');
            $dbConnected = false;
            try {
                /** @var \Doctrine\DBAL\Connection $conn */
                $conn = $container->get(\Doctrine\DBAL\Connection::class);
                $conn->executeQuery('SELECT 1')->fetchOne();
                $dbConnected = true;
            } catch (\Throwable) {
                $dbConnected = false;
            }

            /** @var array{enabled: bool, provider: string, country_db: string, asn_db: string, maxmind_license_key: string, ipinfo_token: string} $geoip */
            $geoip = $container->get('settings.geoip');
            $providerConfigured = match ($geoip['provider']) {
                'maxmind' => $geoip['maxmind_license_key'] !== '',
                'ipinfo' => $geoip['ipinfo_token'] !== '',
                default => true,
            };

            $countryPresent = is_file($geoip['country_db']) && is_readable($geoip['country_db']);
            $asnPresent = is_file($geoip['asn_db']) && is_readable($geoip['asn_db']);

            $countryMtime = $countryPresent
                ? gmdate('Y-m-d\TH:i:s\Z', (int) filemtime($geoip['country_db']))
                : null;
            $asnMtime = $asnPresent
                ? gmdate('Y-m-d\TH:i:s\Z', (int) filemtime($geoip['asn_db']))
                : null;

            $response->getBody()->write((string) json_encode([
                'status' => 'ok',
                'db' => [
                    'connected' => $dbConnected,
                    'driver' => $dbSettings['driver'],
                ],
                'geoip' => [
                    'provider' => $geoip['provider'],
                    'provider_configured' => $providerConfigured,
                    'country_db_present' => $countryPresent,
                    'asn_db_present' => $asnPresent,
                    'country_db_modified' => $countryMtime,
                    'asn_db_modified' => $asnMtime,
                ],
            ]));

            return $response->withHeader('Content-Type', 'application/json');
        });

        // Auth API: service-token-only. No impersonation — these endpoints
        // exist to *produce* user_ids the ui can later impersonate. The
        // controller enforces kind=service on each call. Audit context is
        // attached so user.created / user.role_changed audit rows
        // (SEC_REVIEW F5) carry source IP and request id. Rate limit is
        // attached so a leaked service token cannot brute-force-enumerate
        // users via GET /users/{id} (SEC_REVIEW F14, capping the
        // enumeration speed flagged separately by F17).
        $app->group('/api/v1/auth', function (RouteCollectorProxy $auth) use ($container): void {
            /** @var AuthController $controller */
            $controller = $container->get(AuthController::class);
            $auth->post('/users/upsert-oidc', [$controller, 'upsertOidc']);
            $auth->post('/users/upsert-local', [$controller, 'upsertLocal']);
            $auth->get('/users/{id}', function (
                ServerRequestInterface $request,
                ResponseInterface $response,
                array $args,
            ) use ($controller): ResponseInterface {
                /** @var array{id: string} $args */
                return $controller->getUser($request, $response, $args['id']);
            });
        })
            ->add($rateLimit)
            ->add($auditContext)
            ->add($tokenAuth)
            ->add($rateLimit);

        // Public API: ingest endpoint. Auth → rate limit → controller. The
        // controller rejects non-reporter kinds itself (uniform 401 per
        // SPEC).
        $app->group('/api/v1', function (RouteCollectorProxy $public) use ($container): void {
            /** @var ReportController $report */
            $report = $container->get(ReportController::class);
            $public->post('/report', $report);

            /** @var BlocklistController $blocklist */
            $blocklist = $container->get(BlocklistController::class);
            $public->get('/blocklist', $blocklist);
        })
            ->add($rateLimit)
            ->add($auditContext)
            ->add($tokenAuth)
            ->add($rateLimit);

        // Admin API: token auth → impersonation → role check.
        $app->group('/api/v1/admin', function (RouteCollectorProxy $admin) use ($container, $rf): void {
            /** @var MeController $me */
            $me = $container->get(MeController::class);
            $admin->get('/me', $me)
                ->add(RbacMiddleware::require($rf, Role::Viewer));

            /** @var ReportersController $reporters */
            $reporters = $container->get(ReportersController::class);
            $admin->group('/reporters', function (RouteCollectorProxy $r) use ($reporters): void {
                $r->get('', [$reporters, 'list']);
                $r->post('', [$reporters, 'create']);
                $r->get('/{id}', [$reporters, 'show']);
                $r->patch('/{id}', [$reporters, 'update']);
                $r->delete('/{id}', [$reporters, 'delete']);
            })->add(RbacMiddleware::require($rf, Role::Admin));

            /** @var ConsumersController $consumers */
            $consumers = $container->get(ConsumersController::class);
            $admin->group('/consumers', function (RouteCollectorProxy $r) use ($consumers): void {
                $r->get('', [$consumers, 'list']);
                $r->post('', [$consumers, 'create']);
                $r->get('/{id}', [$consumers, 'show']);
                $r->patch('/{id}', [$consumers, 'update']);
                $r->delete('/{id}', [$consumers, 'delete']);
            })->add(RbacMiddleware::require($rf, Role::Admin));

            /** @var TokensController $tokens */
            $tokens = $container->get(TokensController::class);
            $admin->group('/tokens', function (RouteCollectorProxy $r) use ($tokens): void {
                $r->get('', [$tokens, 'list']);
                $r->post('', [$tokens, 'create']);
                $r->delete('/{id}', [$tokens, 'delete']);
                $r->delete('/{id}/purge', [$tokens, 'purge']);
            })->add(RbacMiddleware::require($rf, Role::Admin));

            // Manual blocks: list/show require Viewer, create/delete require Operator.
            // Per-route middleware lets us split read vs write at one URL group.
            /** @var ManualBlocksController $manualBlocks */
            $manualBlocks = $container->get(ManualBlocksController::class);
            $admin->get('/manual-blocks', [$manualBlocks, 'list'])
                ->add(RbacMiddleware::require($rf, Role::Viewer));
            $admin->get('/manual-blocks/{id}', [$manualBlocks, 'show'])
                ->add(RbacMiddleware::require($rf, Role::Viewer));
            $admin->post('/manual-blocks', [$manualBlocks, 'create'])
                ->add(RbacMiddleware::require($rf, Role::Operator));
            $admin->delete('/manual-blocks/{id}', [$manualBlocks, 'delete'])
                ->add(RbacMiddleware::require($rf, Role::Operator));

            /** @var AllowlistController $allowlist */
            $allowlist = $container->get(AllowlistController::class);
            $admin->get('/allowlist', [$allowlist, 'list'])
                ->add(RbacMiddleware::require($rf, Role::Viewer));
            $admin->get('/allowlist/{id}', [$allowlist, 'show'])
                ->add(RbacMiddleware::require($rf, Role::Viewer));
            $admin->post('/allowlist', [$allowlist, 'create'])
                ->add(RbacMiddleware::require($rf, Role::Operator));
            $admin->delete('/allowlist/{id}', [$allowlist, 'delete'])
                ->add(RbacMiddleware::require($rf, Role::Operator));

            // IPs: list, detail, stats — all Viewer (read-only this milestone).
            /** @var IpsController $ips */
            $ips = $container->get(IpsController::class);
            $admin->get('/ips', [$ips, 'list'])
                ->add(RbacMiddleware::require($rf, Role::Viewer));
            // /ips/countries must come BEFORE the {ip} route or it'd be
            // matched as an IP.
            $admin->get('/ips/countries', [$ips, 'countries'])
                ->add(RbacMiddleware::require($rf, Role::Viewer));
            // SEC_REVIEW F43 + F72: pin to an IP-shaped charset AND
            // cap the length so a Viewer can't hand the handler a
            // multi-megabyte string. 0-9, a-f/A-F (IPv6 hex), `.`
            // (IPv4), `:` (IPv6), and `%` (so a UI
            // `rawurlencode('2001:db8::1')` form like
            // `2001%3Adb8%3A%3A1` still matches before the
            // controller's `rawurldecode`). Length cap 80: an
            // IPv4 dotted-quad is ≤ 15 chars, a canonical IPv6 is
            // ≤ 39 chars, and the same IPv6 with every colon
            // urlencoded (`%3A`) is ≤ 53 chars. 80 leaves ~50%
            // headroom; anything past that is malformed-or-malicious.
            // Anything else — `/`, `..`, `?`, spaces, dashes,
            // multi-megabyte strings — fails to match the route
            // and 404s before the handler reads `$args['ip']`.
            $admin->get('/ips/{ip:[0-9a-fA-F.:%]{1,80}}', [$ips, 'show'])
                ->add(RbacMiddleware::require($rf, Role::Viewer));

            /** @var StatsController $stats */
            $stats = $container->get(StatsController::class);
            $admin->get('/stats/dashboard', [$stats, 'dashboard'])
                ->add(RbacMiddleware::require($rf, Role::Viewer));

            // Categories: list/show = Viewer; write = Admin (per SPEC §M10.1).
            /** @var CategoriesController $categories */
            $categories = $container->get(CategoriesController::class);
            $admin->get('/categories', [$categories, 'list'])
                ->add(RbacMiddleware::require($rf, Role::Viewer));
            $admin->get('/categories/{id}', [$categories, 'show'])
                ->add(RbacMiddleware::require($rf, Role::Viewer));
            $admin->post('/categories', [$categories, 'create'])
                ->add(RbacMiddleware::require($rf, Role::Admin));
            $admin->patch('/categories/{id}', [$categories, 'update'])
                ->add(RbacMiddleware::require($rf, Role::Admin));
            $admin->delete('/categories/{id}', [$categories, 'delete'])
                ->add(RbacMiddleware::require($rf, Role::Admin));

            // Users: Admin only — list + show + PATCH disable/enable.
            /** @var UsersController $users */
            $users = $container->get(UsersController::class);
            $admin->group('/users', function (RouteCollectorProxy $r) use ($users): void {
                $r->get('', [$users, 'list']);
                $r->get('/{id}', [$users, 'show']);
                $r->patch('/{id}', [$users, 'update']);
            })->add(RbacMiddleware::require($rf, Role::Admin));

            // Audit log: Viewer.
            /** @var AuditController $audit */
            $audit = $container->get(AuditController::class);
            $admin->get('/audit-log', [$audit, 'list'])
                ->add(RbacMiddleware::require($rf, Role::Viewer));

            // Jobs admin (Viewer for status, Admin for trigger).
            /** @var JobsAdminController $jobsAdmin */
            $jobsAdmin = $container->get(JobsAdminController::class);
            $admin->get('/jobs/status', [$jobsAdmin, 'status'])
                ->add(RbacMiddleware::require($rf, Role::Viewer));
            $admin->post('/jobs/trigger/{name}', [$jobsAdmin, 'trigger'])
                ->add(RbacMiddleware::require($rf, Role::Admin));

            // Effective config (secrets masked) — Admin only.
            /** @var ConfigController $config */
            $config = $container->get(ConfigController::class);
            $admin->get('/config', [$config, 'show'])
                ->add(RbacMiddleware::require($rf, Role::Admin));

            // Runtime feature flags (audit-emission toggles). Admin only.
            /** @var AppSettingsController $appSettings */
            $appSettings = $container->get(AppSettingsController::class);
            $admin->get('/app-settings', [$appSettings, 'show'])
                ->add(RbacMiddleware::require($rf, Role::Admin));
            $admin->patch('/app-settings', [$appSettings, 'update'])
                ->add(RbacMiddleware::require($rf, Role::Admin));

            // Demo / maintenance — Admin only. Both wipe and seed are
            // destructive in different directions; the UI guards each with a
            // confirmation modal and the purge body must include the literal
            // "PURGE" string.
            /** @var MaintenanceController $maintenance */
            $maintenance = $container->get(MaintenanceController::class);
            $admin->post('/maintenance/purge', [$maintenance, 'purge'])
                ->add(RbacMiddleware::require($rf, Role::Admin));
            $admin->post('/maintenance/seed-demo', [$maintenance, 'seedDemo'])
                ->add(RbacMiddleware::require($rf, Role::Admin));

            // Policies: list/show/preview = Viewer; write = Admin.
            /** @var PoliciesController $policies */
            $policies = $container->get(PoliciesController::class);
            $admin->get('/policies', [$policies, 'list'])
                ->add(RbacMiddleware::require($rf, Role::Viewer));
            $admin->get('/policies/{id}', [$policies, 'show'])
                ->add(RbacMiddleware::require($rf, Role::Viewer));
            $admin->get('/policies/{id}/preview', [$policies, 'preview'])
                ->add(RbacMiddleware::require($rf, Role::Viewer));
            $admin->get('/policies/{id}/score-distribution', [$policies, 'scoreDistribution'])
                ->add(RbacMiddleware::require($rf, Role::Viewer));
            $admin->post('/policies', [$policies, 'create'])
                ->add(RbacMiddleware::require($rf, Role::Admin));
            $admin->patch('/policies/{id}', [$policies, 'update'])
                ->add(RbacMiddleware::require($rf, Role::Admin));
            $admin->delete('/policies/{id}', [$policies, 'delete'])
                ->add(RbacMiddleware::require($rf, Role::Admin));
        })
            ->add($rateLimit)
            ->add($auditContext)
            ->add($impersonation)
            ->add($tokenAuth)
            ->add($rateLimit);

        // Internal jobs API: scheduler-only. Network gate (404 outside
        // RFC1918) → token gate (401) → controller. Order matters:
        // network rejection must not leak through token-attempt logs.
        $app->group('/internal/jobs', function (RouteCollectorProxy $internal) use ($container): void {
            /** @var JobsController $jobs */
            $jobs = $container->get(JobsController::class);
            $internal->post('/recompute-scores', [$jobs, 'recomputeScores']);
            $internal->post('/cleanup-audit', [$jobs, 'cleanupAudit']);
            $internal->post('/cleanup-expired-manual-blocks', [$jobs, 'cleanupExpiredManualBlocks']);
            $internal->post('/enrich-pending', [$jobs, 'enrichPending']);
            $internal->post('/tick', [$jobs, 'tick']);
            $internal->post('/refresh-geoip', [$jobs, 'refreshGeoip']);
            $internal->get('/status', [$jobs, 'status']);
        })
            ->add($internalToken)
            ->add($internalNetwork);

        $app->map(
            ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
            '/{routes:.+}',
            function (ServerRequestInterface $request, ResponseInterface $response): ResponseInterface {
                $response->getBody()->write((string) json_encode(['error' => 'not_found']));

                return $response
                    ->withHeader('Content-Type', 'application/json')
                    ->withStatus(404);
            }
        );

        return $app;
    }
}
