<?php

declare(strict_types=1);

namespace App\Application\Jobs;

use App\Domain\Jobs\Job;
use App\Domain\Jobs\JobContext;
use App\Domain\Jobs\JobResult;
use App\Domain\Reputation\PairScorer;
use App\Infrastructure\Reputation\IpScoreRepository;
use App\Infrastructure\Reputation\ReportRepository;

/**
 * Bulk recompute of `ip_scores`. Two modes:
 *
 *  - **Incremental** (default): pairs touched by `reports` since the last
 *    interval, UNION pairs in `ip_scores` whose `recomputed_at` is older
 *    than the freshness window. Capped at `JOB_RECOMPUTE_MAX_ROWS_PER_TICK`
 *    so a single tick can't run forever.
 *
 *  - **Full** (`full=true`): every pair in `ip_scores` plus every pair in
 *    `reports`. No row cap; the job's `maxRuntimeSeconds` is the only
 *    bound. Used by the "Rebuild scores" UI button and the CLI
 *    `scores:rebuild`.
 *
 * After scoring it drops rows where `score < 0.01 AND last_report_at < now
 * - 90 days` per SPEC §5.
 */
final class RecomputeScoresJob implements Job
{
    public const NAME = 'recompute-scores';

    private const FRESHNESS_WINDOW_SECONDS = 3600;
    private const INCREMENTAL_LOOKBACK_SECONDS = 600;
    private const DROP_MIN_SCORE = 0.01;
    private const DROP_STALE_DAYS = 90;

    public function __construct(
        private readonly ReportRepository $reports,
        private readonly IpScoreRepository $ipScores,
        private readonly PairScorer $scorer,
        private readonly int $maxRuntimeSeconds = 240,
        private readonly int $maxRowsPerTick = 5000,
    ) {
    }

    public function name(): string
    {
        return self::NAME;
    }

    public function defaultIntervalSeconds(): int
    {
        return 300;
    }

    public function maxRuntimeSeconds(): int
    {
        return $this->maxRuntimeSeconds;
    }

    public function run(JobContext $context): JobResult
    {
        $now = $context->clock->now();
        $full = (bool) $context->param('full', false);
        $maxRowsParam = $context->param('max_rows');
        $maxRows = is_int($maxRowsParam) && $maxRowsParam > 0
            ? $maxRowsParam
            : $this->maxRowsPerTick;

        if ($full) {
            $pairs = $this->collectAllPairs();
        } else {
            $pairs = $this->collectIncrementalPairs($now, $maxRows);
        }

        $processed = 0;
        $deadline = $now->modify(sprintf('+%d seconds', max(1, $this->maxRuntimeSeconds - 5)));

        foreach ($pairs as $pair) {
            // Time-bound full mode (no row cap). Incremental mode is also
            // protected — `pairs` is already capped by maxRows, but a
            // very slow DB could still exceed runtime.
            $heartbeat = $context->clock->now();
            if ($heartbeat >= $deadline) {
                $context->logger->warning('recompute_scores_deadline', [
                    'processed' => $processed,
                    'remaining' => count($pairs) - $processed,
                ]);
                break;
            }

            $score = $this->scorer->score($pair['ip_bin'], $pair['category_id'], $now);
            $count30d = $this->reports->recentCount(
                $pair['ip_bin'],
                $pair['category_id'],
                $now->modify('-30 days'),
            );
            // Use the actual most-recent report's received_at so the
            // drop-stale rule can fire correctly. If a pair has no reports
            // at all (only an orphan ip_scores row), back-date enough that
            // the drop step prunes it on this same run.
            $lastReportAt = $this->reports->lastReceivedAt(
                $pair['ip_bin'],
                $pair['category_id'],
            ) ?? $now->modify('-366 days');

            $this->ipScores->upsert(
                ipBin: $pair['ip_bin'],
                ipText: $pair['ip_text'],
                categoryId: $pair['category_id'],
                score: $score,
                reportCount30d: $count30d,
                lastReportAt: $lastReportAt,
                recomputedAt: $now,
            );
            ++$processed;
        }

        $dropped = $this->ipScores->dropStale(
            self::DROP_MIN_SCORE,
            $now->modify(sprintf('-%d days', self::DROP_STALE_DAYS)),
        );

        return new JobResult(
            itemsProcessed: $processed,
            details: [
                'mode' => $full ? 'full' : 'incremental',
                'dropped' => $dropped,
                'considered' => count($pairs),
            ],
        );
    }

    /**
     * @return list<array{ip_bin: string, ip_text: string, category_id: int}>
     */
    private function collectAllPairs(): array
    {
        $all = $this->ipScores->allPairs();
        $reports = $this->reports->distinctPairsSince(
            new \DateTimeImmutable('@0', new \DateTimeZone('UTC')),
        );

        return self::dedup(array_merge($all, $reports));
    }

    /**
     * @return list<array{ip_bin: string, ip_text: string, category_id: int}>
     */
    private function collectIncrementalPairs(\DateTimeImmutable $now, int $maxRows): array
    {
        $touched = $this->reports->distinctPairsSince(
            $now->modify(sprintf('-%d seconds', self::INCREMENTAL_LOOKBACK_SECONDS)),
            $maxRows,
        );
        $stale = $this->ipScores->pairsStaleBefore(
            $now->modify(sprintf('-%d seconds', self::FRESHNESS_WINDOW_SECONDS)),
        );

        $merged = self::dedup(array_merge($touched, $stale));
        if (count($merged) > $maxRows) {
            $merged = array_slice($merged, 0, $maxRows);
        }

        return $merged;
    }

    /**
     * Stable dedup keyed by (ip_bin, category_id). Preserves first-seen
     * order so callers can reason about cap behavior.
     *
     * @param list<array{ip_bin: string, ip_text: string, category_id: int}> $pairs
     * @return list<array{ip_bin: string, ip_text: string, category_id: int}>
     */
    private static function dedup(array $pairs): array
    {
        $seen = [];
        $out = [];
        foreach ($pairs as $p) {
            $key = bin2hex($p['ip_bin']) . '|' . $p['category_id'];
            if (isset($seen[$key])) {
                continue;
            }
            $seen[$key] = true;
            $out[] = $p;
        }

        return $out;
    }
}
