<?php

declare(strict_types=1);

namespace App\Application\Admin;

use App\Application\Jobs\RecomputeScoresJob;
use App\Domain\Audit\AuditAction;
use App\Domain\Audit\AuditEmitter;
use App\Domain\Auth\TokenKind;
use App\Domain\Enrichment\EnrichmentResult;
use App\Domain\Ip\Cidr;
use App\Domain\Ip\IpAddress;
use App\Domain\Time\Clock;
use App\Infrastructure\Allowlist\AllowlistRepository;
use App\Infrastructure\Category\CategoryRepository;
use App\Infrastructure\Consumer\ConsumerRepository;
use App\Infrastructure\Jobs\JobRunner;
use App\Infrastructure\ManualBlock\ManualBlockRepository;
use App\Infrastructure\Policy\PolicyRepository;
use App\Infrastructure\Reporter\ReporterRepository;
use App\Infrastructure\Reputation\IpEnrichmentRepository;
use App\Infrastructure\Reputation\ReportRepository;
use Doctrine\DBAL\Connection;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

/**
 * Admin-only "wipe everything" + "load demo dataset" surface.
 *
 *  - `POST /api/v1/admin/maintenance/purge` — wipes operational data
 *    (reports, scores, blocks, audit, etc.) plus reporters/consumers/
 *    tokens/policies. Preserves users, OIDC role mappings, categories,
 *    and the `service`-kind token. Body must include `confirm: "PURGE"`
 *    to execute.
 *  - `POST /api/v1/admin/maintenance/seed-demo` — populates the database
 *    with realistic-looking sample reporters, consumers, IPs, reports,
 *    manual blocks, allowlist entries, and synthetic GeoIP. Triggers a
 *    full score recompute on completion. Rejects with 409 if demo data
 *    is already present.
 */
final class MaintenanceController
{
    use AdminControllerSupport;

    private const DEMO_REPORTERS = [
        ['name' => 'demo-web-edge',     'description' => 'Demo: edge webserver',           'trust_weight' => 1.0],
        ['name' => 'demo-web-eu',       'description' => 'Demo: EU edge webserver',        'trust_weight' => 1.0],
        ['name' => 'demo-web-us',       'description' => 'Demo: US edge webserver',        'trust_weight' => 1.0],
        ['name' => 'demo-fail2ban',     'description' => 'Demo: fail2ban agents',          'trust_weight' => 1.2],
        ['name' => 'demo-fail2ban-prod','description' => 'Demo: fail2ban prod fleet',      'trust_weight' => 1.3],
        ['name' => 'demo-honeypot',     'description' => 'Demo: research honeypot',        'trust_weight' => 1.5],
        ['name' => 'demo-honeypot-asia','description' => 'Demo: APAC honeypot mesh',       'trust_weight' => 1.6],
        ['name' => 'demo-ids-suricata', 'description' => 'Demo: Suricata IDS sensors',     'trust_weight' => 1.4],
        ['name' => 'demo-mail-gateway', 'description' => 'Demo: anti-spam mail gateway',   'trust_weight' => 1.1],
        ['name' => 'demo-waf-cloud',    'description' => 'Demo: cloud WAF aggregator',     'trust_weight' => 1.3],
    ];

    private const DEMO_CONSUMERS = [
        ['name' => 'demo-firewall-prod',  'description' => 'Demo: production edge firewall', 'policy' => 'moderate'],
        ['name' => 'demo-firewall-dmz',   'description' => 'Demo: DMZ perimeter firewall',   'policy' => 'paranoid'],
        ['name' => 'demo-proxy-staging',  'description' => 'Demo: staging proxy',            'policy' => 'strict'],
        ['name' => 'demo-haproxy-public', 'description' => 'Demo: public HAProxy LB',        'policy' => 'moderate'],
        ['name' => 'demo-nginx-api',      'description' => 'Demo: API nginx ingress',        'policy' => 'strict'],
        ['name' => 'demo-vpn-gateway',    'description' => 'Demo: VPN gateway',              'policy' => 'paranoid'],
        ['name' => 'demo-mail-relay',     'description' => 'Demo: outbound mail relay',      'policy' => 'moderate'],
    ];

    private const DEMO_MANUAL_BLOCKS = [
        ['kind' => 'ip',     'value' => '198.51.100.42',  'reason' => 'Demo: known malicious actor',    'age_days' => 1],
        ['kind' => 'ip',     'value' => '198.51.100.55',  'reason' => 'Demo: persistent brute-force',   'age_days' => 2],
        ['kind' => 'ip',     'value' => '203.0.113.190',  'reason' => 'Demo: WAF auto-block',           'age_days' => 3],
        ['kind' => 'ip',     'value' => '192.0.2.244',    'reason' => 'Demo: spam relay',               'age_days' => 5],
        ['kind' => 'ip',     'value' => '198.51.100.140', 'reason' => 'Demo: scanner cluster member',   'age_days' => 7],
        ['kind' => 'ip',     'value' => '203.0.113.230',  'reason' => 'Demo: confirmed C2 endpoint',    'age_days' => 10],
        ['kind' => 'ip',     'value' => '192.0.2.99',     'reason' => 'Demo: malware dropper host',     'age_days' => 14],
        ['kind' => 'ip',     'value' => '198.51.100.250', 'reason' => 'Demo: botnet member',            'age_days' => 21],
        ['kind' => 'ip',     'value' => '203.0.113.120',  'reason' => 'Demo: credential stuffing',      'age_days' => 30],
        ['kind' => 'ip',     'value' => '192.0.2.42',     'reason' => 'Demo: reflective DDoS source',   'age_days' => 45],
        ['kind' => 'subnet', 'value' => '198.51.100.0/24','reason' => 'Demo: blanket block on test net','age_days' => 60],
        ['kind' => 'subnet', 'value' => '192.0.2.128/26', 'reason' => 'Demo: hostile subnet range',     'age_days' => 75],
    ];

    private const DEMO_ALLOWLIST = [
        ['kind' => 'ip',     'value' => '203.0.113.10',   'reason' => 'Demo: trusted partner'],
        ['kind' => 'ip',     'value' => '203.0.113.50',   'reason' => 'Demo: monitoring probe'],
        ['kind' => 'ip',     'value' => '203.0.113.77',   'reason' => 'Demo: corporate VPN exit'],
        ['kind' => 'subnet', 'value' => '203.0.113.0/28', 'reason' => 'Demo: office NAT range'],
        ['kind' => 'subnet', 'value' => '198.18.0.0/16',  'reason' => 'Demo: lab benchmark range'],
    ];

    /**
     * 70 demo IPs spanning many countries / ASNs / address blocks. RFC 5737
     * documentation prefixes plus the 100.64.0.0/10 CGNAT space and a
     * handful of 2001:db8::/32 doc IPv6 endpoints. These IPs are not
     * routable on the internet so they're safe to seed.
     */
    private const DEMO_IPS = [
        ['ip' => '192.0.2.5',     'country' => 'US', 'asn' => 13335, 'org' => 'Cloudflare, Inc.'],
        ['ip' => '192.0.2.17',    'country' => 'DE', 'asn' => 24940, 'org' => 'Hetzner Online GmbH'],
        ['ip' => '192.0.2.31',    'country' => 'DE', 'asn' => 8881,  'org' => '1&1 Versatel Deutschland'],
        ['ip' => '192.0.2.42',    'country' => 'RU', 'asn' => 12389, 'org' => 'Rostelecom'],
        ['ip' => '192.0.2.58',    'country' => 'RU', 'asn' => 8359,  'org' => 'MTS PJSC'],
        ['ip' => '192.0.2.71',    'country' => 'BY', 'asn' => 6697,  'org' => 'Beltelecom'],
        ['ip' => '192.0.2.86',    'country' => 'CN', 'asn' => 4134,  'org' => 'China Telecom'],
        ['ip' => '192.0.2.99',    'country' => 'CN', 'asn' => 4837,  'org' => 'China Unicom'],
        ['ip' => '192.0.2.114',   'country' => 'CN', 'asn' => 9808,  'org' => 'China Mobile'],
        ['ip' => '192.0.2.130',   'country' => 'BR', 'asn' => 8167,  'org' => 'V.tal'],
        ['ip' => '192.0.2.142',   'country' => 'BR', 'asn' => 26599, 'org' => 'TELEFONICA BRASIL'],
        ['ip' => '192.0.2.156',   'country' => 'AR', 'asn' => 10481, 'org' => 'Telefonica de Argentina'],
        ['ip' => '192.0.2.170',   'country' => 'MX', 'asn' => 8151,  'org' => 'Uninet S.A. de C.V.'],
        ['ip' => '192.0.2.180',   'country' => 'FR', 'asn' => 16276, 'org' => 'OVH SAS'],
        ['ip' => '192.0.2.196',   'country' => 'FR', 'asn' => 3215,  'org' => 'Orange S.A.'],
        ['ip' => '192.0.2.211',   'country' => 'NL', 'asn' => 60781, 'org' => 'LeaseWeb Netherlands'],
        ['ip' => '192.0.2.225',   'country' => 'NL', 'asn' => 1136,  'org' => 'KPN B.V.'],
        ['ip' => '192.0.2.239',   'country' => 'BE', 'asn' => 5432,  'org' => 'Proximus NV'],
        ['ip' => '192.0.2.244',   'country' => 'VN', 'asn' => 7552,  'org' => 'Viettel Group'],
        ['ip' => '192.0.2.250',   'country' => 'TH', 'asn' => 23969, 'org' => 'TOT Public Company'],

        ['ip' => '198.51.100.7',   'country' => 'IN', 'asn' => 9498,  'org' => 'BHARTI Airtel'],
        ['ip' => '198.51.100.21',  'country' => 'IN', 'asn' => 55836, 'org' => 'Reliance Jio'],
        ['ip' => '198.51.100.34',  'country' => 'PK', 'asn' => 17557, 'org' => 'Pakistan Telecom'],
        ['ip' => '198.51.100.42',  'country' => 'BD', 'asn' => 24389, 'org' => 'Grameenphone Ltd.'],
        ['ip' => '198.51.100.55',  'country' => 'GB', 'asn' => 5089,  'org' => 'Virgin Media'],
        ['ip' => '198.51.100.66',  'country' => 'GB', 'asn' => 2856,  'org' => 'BT Group'],
        ['ip' => '198.51.100.79',  'country' => 'IE', 'asn' => 5466,  'org' => 'eircom Limited'],
        ['ip' => '198.51.100.88',  'country' => 'JP', 'asn' => 4713,  'org' => 'NTT Communications'],
        ['ip' => '198.51.100.103', 'country' => 'JP', 'asn' => 17676, 'org' => 'SoftBank Corp.'],
        ['ip' => '198.51.100.117', 'country' => 'KR', 'asn' => 4766,  'org' => 'Korea Telecom'],
        ['ip' => '198.51.100.140', 'country' => 'KR', 'asn' => 9318,  'org' => 'SK Broadband'],
        ['ip' => '198.51.100.155', 'country' => 'TW', 'asn' => 3462,  'org' => 'Chunghwa Telecom'],
        ['ip' => '198.51.100.168', 'country' => 'HK', 'asn' => 4760,  'org' => 'HKT Limited'],
        ['ip' => '198.51.100.180', 'country' => 'SG', 'asn' => 3758,  'org' => 'Singtel'],
        ['ip' => '198.51.100.193', 'country' => 'MY', 'asn' => 4788,  'org' => 'TM Net'],
        ['ip' => '198.51.100.200', 'country' => 'TR', 'asn' => 9121,  'org' => 'Turk Telekom'],
        ['ip' => '198.51.100.213', 'country' => 'GR', 'asn' => 3329,  'org' => 'OTE SA'],
        ['ip' => '198.51.100.225', 'country' => 'RO', 'asn' => 8708,  'org' => 'RCS & RDS'],
        ['ip' => '198.51.100.240', 'country' => 'BG', 'asn' => 8866,  'org' => 'Vivacom'],
        ['ip' => '198.51.100.250', 'country' => 'ID', 'asn' => 17974, 'org' => 'PT Telekomunikasi Indonesia'],

        ['ip' => '203.0.113.7',   'country' => 'US', 'asn' => 7922,  'org' => 'Comcast Cable'],
        ['ip' => '203.0.113.18',  'country' => 'US', 'asn' => 20115, 'org' => 'Charter Communications'],
        ['ip' => '203.0.113.29',  'country' => 'US', 'asn' => 32934, 'org' => 'Meta Platforms, Inc.'],
        ['ip' => '203.0.113.50',  'country' => 'CA', 'asn' => 577,   'org' => 'Bell Canada'],
        ['ip' => '203.0.113.63',  'country' => 'CA', 'asn' => 812,   'org' => 'Rogers Communications'],
        ['ip' => '203.0.113.77',  'country' => 'AU', 'asn' => 7545,  'org' => 'TPG Telecom'],
        ['ip' => '203.0.113.91',  'country' => 'AU', 'asn' => 1221,  'org' => 'Telstra Corporation'],
        ['ip' => '203.0.113.105', 'country' => 'NZ', 'asn' => 4648,  'org' => 'Spark New Zealand'],
        ['ip' => '203.0.113.120', 'country' => 'IT', 'asn' => 12874, 'org' => 'Fastweb'],
        ['ip' => '203.0.113.135', 'country' => 'IT', 'asn' => 1267,  'org' => 'Wind Tre S.p.A.'],
        ['ip' => '203.0.113.150', 'country' => 'ES', 'asn' => 3352,  'org' => 'Telefonica de Espana'],
        ['ip' => '203.0.113.165', 'country' => 'ES', 'asn' => 12479, 'org' => 'Orange Espagne'],
        ['ip' => '203.0.113.178', 'country' => 'PT', 'asn' => 8657,  'org' => 'MEO'],
        ['ip' => '203.0.113.190', 'country' => 'PL', 'asn' => 5617,  'org' => 'Orange Polska'],
        ['ip' => '203.0.113.205', 'country' => 'CZ', 'asn' => 6855,  'org' => 'O2 Czech Republic'],
        ['ip' => '203.0.113.218', 'country' => 'HU', 'asn' => 5483,  'org' => 'Magyar Telekom'],
        ['ip' => '203.0.113.230', 'country' => 'UA', 'asn' => 13188, 'org' => 'Content Delivery Network'],
        ['ip' => '203.0.113.245', 'country' => 'EG', 'asn' => 8452,  'org' => 'TE Data'],

        ['ip' => '100.64.5.10',   'country' => 'ZA', 'asn' => 36937, 'org' => 'MTN South Africa'],
        ['ip' => '100.64.32.55',  'country' => 'NG', 'asn' => 36873, 'org' => 'MTN Nigeria'],
        ['ip' => '100.64.71.7',   'country' => 'KE', 'asn' => 33771, 'org' => 'Safaricom'],
        ['ip' => '100.64.99.42',  'country' => 'AE', 'asn' => 5384,  'org' => 'Emirates Telecom'],
        ['ip' => '100.64.128.3',  'country' => 'SA', 'asn' => 25019, 'org' => 'Saudi Telecom'],
        ['ip' => '100.64.155.88', 'country' => 'IL', 'asn' => 8551,  'org' => 'Bezeq International'],
        ['ip' => '100.64.180.21', 'country' => 'CL', 'asn' => 7418,  'org' => 'Movistar Chile'],

        ['ip' => '2001:db8::1',     'country' => 'US', 'asn' => 15169, 'org' => 'Google LLC'],
        ['ip' => '2001:db8::42',    'country' => 'US', 'asn' => 8075,  'org' => 'Microsoft Corporation'],
        ['ip' => '2001:db8::abcd',  'country' => 'IE', 'asn' => 16509, 'org' => 'Amazon.com, Inc.'],
        ['ip' => '2001:db8::cafe',  'country' => 'NL', 'asn' => 14061, 'org' => 'DigitalOcean LLC'],
        ['ip' => '2001:db8::beef',  'country' => 'SG', 'asn' => 16509, 'org' => 'Amazon AWS Asia'],
        ['ip' => '2001:db8:1::55',  'country' => 'JP', 'asn' => 2497,  'org' => 'IIJ Inc.'],
    ];

    /**
     * Reports span this many days into the past. Older reports decay more,
     * which yields a long-tail distribution in the dashboard chart.
     */
    private const DEMO_REPORT_HORIZON_DAYS = 90;

    /**
     * Per-IP report-volume tiers. Indexed by `ip_index % count`. Each tuple
     * is `[low, high]` inclusive — actual count is jittered between them.
     */
    private const DEMO_REPORT_VOLUME_TIERS = [
        [15, 22],   // mostly-quiet IPs
        [25, 40],   // moderate offenders
        [45, 70],   // heavy offenders
        [80, 110],  // hostile / persistent
    ];

    public function __construct(
        private readonly Connection $connection,
        private readonly ReporterRepository $reporters,
        private readonly ConsumerRepository $consumers,
        private readonly PolicyRepository $policies,
        private readonly CategoryRepository $categories,
        private readonly ManualBlockRepository $manualBlocks,
        private readonly AllowlistRepository $allowlist,
        private readonly ReportRepository $reports,
        private readonly IpEnrichmentRepository $enrichment,
        private readonly RecomputeScoresJob $recomputeJob,
        private readonly JobRunner $jobRunner,
        private readonly Clock $clock,
        private readonly AuditEmitter $audit,
    ) {
    }

    /**
     * Wipe operational data. Preserves users, OIDC role mappings, categories,
     * and the `service`-kind token (the UI's own credential).
     */
    public function purge(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
    {
        $body = self::jsonBody($request);
        $confirm = isset($body['confirm']) && is_string($body['confirm']) ? $body['confirm'] : '';
        if ($confirm !== 'PURGE') {
            return self::validationFailed($response, [
                'confirm' => 'must be the literal string "PURGE" to authorize the wipe',
            ]);
        }

        $auditCtx = self::auditContext($request);
        $deleted = $this->connection->transactional(function (Connection $conn) use ($auditCtx): array {
            $counts = [];
            // Order: child tables first, then parents. RESTRICT FKs:
            //   reports.reporter_id, reports.category_id, consumers.policy_id.
            // Categories are preserved, so reports must be wiped first to
            // free reporters; consumers must go before policies.
            $tables = [
                'reports',
                'ip_scores',
                'ip_enrichment',
                'manual_blocks',
                'allowlist',
                'audit_log',
                'job_runs',
                'job_locks',
            ];
            foreach ($tables as $table) {
                $counts[$table] = (int) $conn->executeStatement('DELETE FROM ' . $table);
            }

            // Tokens: keep the service token; everything else goes.
            $counts['api_tokens'] = (int) $conn->executeStatement(
                'DELETE FROM api_tokens WHERE kind != :svc',
                ['svc' => TokenKind::Service->value],
            );

            $counts['consumers'] = (int) $conn->executeStatement('DELETE FROM consumers');
            $counts['policy_category_thresholds'] = (int) $conn->executeStatement(
                'DELETE FROM policy_category_thresholds',
            );
            $counts['policies'] = (int) $conn->executeStatement('DELETE FROM policies');
            $counts['reporters'] = (int) $conn->executeStatement('DELETE FROM reporters');

            // SEC_REVIEW F4: emit inside the same transaction so that an
            // audit insert failure rolls back the wipe instead of leaving
            // an unattributed purge in the history. Note the audit_log
            // table was just truncated above — this row is the first new
            // entry in the post-purge log.
            $this->audit->emitOrThrow(
                AuditAction::MAINTENANCE_PURGED,
                'maintenance',
                null,
                ['deleted' => $counts],
                $auditCtx,
                'purge',
            );

            return $counts;
        });

        return self::json($response, 200, [
            'status' => 'purged',
            'deleted' => $deleted,
        ]);
    }

    /**
     * Populate the database with a demo dataset. Idempotent: returns 409 if
     * the marker reporter ("demo-web-edge") already exists. Body must
     * include `confirm: "SEED"` — symmetric with `purge` so a single drive-by
     * POST (or a compromised admin token) can't dump thousands of synthetic
     * rows into a production database. (SEC_REVIEW F15.)
     */
    public function seedDemo(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
    {
        $body = self::jsonBody($request);
        $confirm = isset($body['confirm']) && is_string($body['confirm']) ? $body['confirm'] : '';
        if ($confirm !== 'SEED') {
            return self::validationFailed($response, [
                'confirm' => 'must be the literal string "SEED" to authorize the demo seed',
            ]);
        }

        if ($this->reporters->findByName(self::DEMO_REPORTERS[0]['name']) !== null) {
            return self::json($response, 409, [
                'error' => 'already_seeded',
                'message' => 'Demo data already present. Purge first to re-seed.',
            ]);
        }

        $categoryIds = [];
        foreach ($this->categories->listAll() as $category) {
            $categoryIds[$category->slug] = $category->id;
        }
        if ($categoryIds === []) {
            return self::error($response, 412, 'no_categories_configured');
        }

        $now = $this->clock->now();
        $actingUserId = self::actingUserId($request);

        $summary = [
            'reporters' => 0,
            'consumers' => 0,
            'policies' => 0,
            'reports' => 0,
            'ips' => 0,
            'manual_blocks' => 0,
            'allowlist' => 0,
            'enrichment' => 0,
        ];

        // Wrap the bulk seed in a single transaction. The new dataset is
        // ~3,000 reports — without a transaction, SQLite fsyncs per insert
        // and the seed takes tens of seconds.
        $this->connection->beginTransaction();

        try {
        $reporterIds = [];
        foreach (self::DEMO_REPORTERS as $r) {
            $reporterIds[$r['name']] = $this->reporters->create(
                $r['name'],
                $r['description'],
                $r['trust_weight'],
                $actingUserId,
            );
            ++$summary['reporters'];
        }

        $policyIds = $this->ensureDefaultPolicies($categoryIds, $summary);

        foreach (self::DEMO_CONSUMERS as $c) {
            $policyId = $policyIds[$c['policy']] ?? reset($policyIds);
            if ($policyId === false) {
                continue;
            }
            $this->consumers->create($c['name'], $c['description'], (int) $policyId, $actingUserId);
            ++$summary['consumers'];
        }

        $horizonHours = self::DEMO_REPORT_HORIZON_DAYS * 24;
        $reporterCount = count(self::DEMO_REPORTERS);
        $tierCount = count(self::DEMO_REPORT_VOLUME_TIERS);
        $categorySlugs = array_keys($categoryIds);
        $catCount = count($categorySlugs);

        foreach (self::DEMO_IPS as $i => $row) {
            try {
                $ip = IpAddress::fromString($row['ip']);
            } catch (\Throwable) {
                continue;
            }

            $this->enrichment->upsert($ip->binary(), new EnrichmentResult(
                countryCode: $row['country'],
                asn: $row['asn'],
                asOrg: $row['org'],
                enrichedAt: $now,
            ));
            ++$summary['enrichment'];
            ++$summary['ips'];

            // Stagger reports across the past DEMO_REPORT_HORIZON_DAYS so
            // decay produces visually distinct scores. Each IP draws from a
            // volume tier so the dataset has a realistic long-tail shape.
            [$tierLow, $tierHigh] = self::DEMO_REPORT_VOLUME_TIERS[$i % $tierCount];
            $reportCount = $tierLow + (($i * 7 + 3) % max(1, $tierHigh - $tierLow + 1));

            // Each IP "specialises" in one or two primary categories so the
            // category breakdown isn't perfectly uniform.
            $primaryCatIdx = $i % $catCount;
            $secondaryCatIdx = ($i * 3 + 1) % $catCount;

            for ($n = 0; $n < $reportCount; ++$n) {
                // 70% primary, 20% secondary, 10% any other category.
                $rolled = ($n * 13 + $i * 5) % 10;
                if ($rolled < 7) {
                    $slugIdx = $primaryCatIdx;
                } elseif ($rolled < 9) {
                    $slugIdx = $secondaryCatIdx;
                } else {
                    $slugIdx = ($i + $n * 2) % $catCount;
                }
                $slug = $categorySlugs[$slugIdx];
                $catId = $categoryIds[$slug];

                $reporterIdx = ($n + $i) % $reporterCount;
                $reporterName = self::DEMO_REPORTERS[$reporterIdx]['name'];
                $reporterId = $reporterIds[$reporterName];
                $weight = self::DEMO_REPORTERS[$reporterIdx]['trust_weight'];

                // Ages spread across the horizon with a bias toward "recent"
                // (more activity in last 14 days), and minute-level jitter.
                $bucket = ($n * 31 + $i * 19) % 100;
                if ($bucket < 35) {
                    $ageHours = ($n * 11 + $i * 7) % (14 * 24);
                } elseif ($bucket < 70) {
                    $ageHours = (14 * 24) + (($n * 17 + $i * 23) % (30 * 24));
                } else {
                    $ageHours = (44 * 24) + (($n * 29 + $i * 13) % max(1, $horizonHours - 44 * 24));
                }
                $ageMinutes = ($n * 7 + $i * 11) % 60;
                $receivedAt = $now
                    ->modify('-' . $ageHours . ' hours')
                    ->modify('-' . $ageMinutes . ' minutes');

                $this->reports->insert(
                    $ip->binary(),
                    $ip->text(),
                    $catId,
                    $reporterId,
                    $weight,
                    json_encode([
                        'demo' => true,
                        'context' => $slug . ' attempt #' . ($n + 1),
                    ]) ?: null,
                    $receivedAt,
                );
                ++$summary['reports'];
            }
        }

        foreach (self::DEMO_MANUAL_BLOCKS as $mb) {
            try {
                if ($mb['kind'] === 'ip') {
                    $blockId = $this->manualBlocks->createIp(
                        IpAddress::fromString($mb['value']),
                        $mb['reason'],
                        null,
                        $actingUserId,
                    );
                } else {
                    $blockId = $this->manualBlocks->createSubnet(
                        Cidr::fromString($mb['value']),
                        $mb['reason'],
                        null,
                        $actingUserId,
                    );
                }
                ++$summary['manual_blocks'];

                // Backfill `created_at` so the bans-by-day chart on the
                // dashboard shows realistic spread instead of a single
                // spike at seed time.
                $ageDays = isset($mb['age_days']) ? (int) $mb['age_days'] : 0;
                if ($ageDays > 0) {
                    $createdAt = $now->modify('-' . $ageDays . ' days');
                    $this->connection->executeStatement(
                        'UPDATE manual_blocks SET created_at = :created_at WHERE id = :id',
                        [
                            'created_at' => $createdAt->format('Y-m-d H:i:s'),
                            'id' => $blockId,
                        ],
                    );
                }
            } catch (\Throwable) {
                // Ignore — demo data shouldn't block setup on a single bad row.
            }
        }

        foreach (self::DEMO_ALLOWLIST as $al) {
            try {
                if ($al['kind'] === 'ip') {
                    $this->allowlist->createIp(
                        IpAddress::fromString($al['value']),
                        $al['reason'],
                        $actingUserId,
                    );
                } else {
                    $this->allowlist->createSubnet(
                        Cidr::fromString($al['value']),
                        $al['reason'],
                        $actingUserId,
                    );
                }
                ++$summary['allowlist'];
            } catch (\Throwable) {
                // Ignore.
            }
        }

        // SEC_REVIEW F4: audit emit lives inside the seed transaction so a
        // failed audit insert rolls back the bulk inserts above instead of
        // leaving thousands of unattributed demo rows on disk.
        $this->audit->emitOrThrow(
            AuditAction::MAINTENANCE_SEEDED,
            'maintenance',
            null,
            ['summary' => $summary],
            self::auditContext($request),
            'seed-demo',
        );

        $this->connection->commit();
        } catch (\Throwable $e) {
            if ($this->connection->isTransactionActive()) {
                $this->connection->rollBack();
            }
            throw $e;
        }

        // Recompute so dashboards show the seeded scores immediately.
        $outcome = $this->jobRunner->run($this->recomputeJob, ['full' => true], 'manual');

        return self::json($response, 200, [
            'status' => 'seeded',
            'summary' => $summary,
            'recompute' => $outcome->toArray(),
        ]);
    }

    /**
     * If no policies exist after a purge, recreate the three defaults so the
     * blocklist endpoint and policies page aren't broken. Returns
     * `name => id` for every policy currently in the table.
     *
     * @param array<string, int> $categoryIds slug -> id
     * @param array<string, int> $summary     incremented in place when a row is created
     * @return array<string, int> name -> id
     */
    private function ensureDefaultPolicies(array $categoryIds, array &$summary): array
    {
        $existing = $this->policies->listAll();
        $byName = [];
        foreach ($existing as $p) {
            $byName[$p->name] = $p->id;
        }
        if ($byName !== []) {
            return $byName;
        }

        $defaults = [
            'strict' => ['desc' => 'Conservative: high-confidence blocks only.', 'threshold' => 2.5],
            'moderate' => ['desc' => 'Balanced: moderate accumulated abuse signal.', 'threshold' => 1.0],
            'paranoid' => ['desc' => 'Aggressive: block on faint signal.', 'threshold' => 0.3],
        ];
        foreach ($defaults as $name => $spec) {
            $thresholds = [];
            foreach ($categoryIds as $catId) {
                $thresholds[$catId] = $spec['threshold'];
            }
            $byName[$name] = $this->policies->create($name, $spec['desc'], true, $thresholds);
            ++$summary['policies'];
        }

        return $byName;
    }
}
