<?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\Infrastructure\Enrichment\Downloaders\DownloaderException;
use App\Infrastructure\Enrichment\Downloaders\GeoIpDownloader;
use App\Infrastructure\Enrichment\MmdbEnrichmentService;
use App\Infrastructure\Reputation\IpEnrichmentRepository;
use MaxMind\Db\Reader;
use RuntimeException;
use Throwable;

/**
 * Provider-agnostic refresh job. The provider-specific bits live in
 * `GeoIpDownloader` impls; this class owns: temp dir prep, download
 * orchestration, atomic file replace onto the configured paths, and
 * (optional) re-enrichment trigger.
 *
 * The HTTP-handler short-circuits 412 when an opt-in provider has no
 * credential; this class assumes a usable downloader and proceeds.
 */
final class RefreshGeoipJob implements Job
{
    public const NAME = 'refresh-geoip';
    private const MAX_RUNTIME_SECONDS = 300; // 5 minutes — large downloads
    private const TEMP_DIR_PREFIX = 'geoip-refresh-';

    public function __construct(
        private readonly GeoIpDownloader $downloader,
        private readonly MmdbEnrichmentService $service,
        private readonly IpEnrichmentRepository $repo,
        private readonly string $countryDbPath,
        private readonly string $asnDbPath,
        private readonly int $intervalDays,
    ) {
    }

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

    public function defaultIntervalSeconds(): int
    {
        return $this->intervalDays * 86_400;
    }

    public function maxRuntimeSeconds(): int
    {
        return self::MAX_RUNTIME_SECONDS;
    }

    public function run(JobContext $context): JobResult
    {
        $reenrich = (bool) $context->param('reenrich', false);
        $tempDir = $this->makeTempDir();

        try {
            $paths = $this->downloader->download($tempDir);

            $this->ensureTargetDirs();
            $this->atomicReplace($paths['country'], $this->countryDbPath);
            $this->atomicReplace($paths['asn'], $this->asnDbPath);

            $this->service->reloadReaders();

            $countryNodes = $this->readNodeCount($this->countryDbPath);
            $asnNodes = $this->readNodeCount($this->asnDbPath);

            $cleared = 0;
            if ($reenrich) {
                $cleared = $this->repo->clearAllEnrichedAt();
            }

            return new JobResult(
                itemsProcessed: $countryNodes + $asnNodes,
                details: [
                    'provider' => $this->downloader->name(),
                    'country_nodes' => $countryNodes,
                    'asn_nodes' => $asnNodes,
                    'reenriched' => $cleared,
                ],
            );
        } catch (DownloaderException $e) {
            $context->logger->error('refresh_geoip_download_failed', [
                'provider' => $this->downloader->name(),
                'error' => $e->getMessage(),
            ]);

            throw $e;
        } finally {
            $this->cleanup($tempDir);
        }
    }

    private function makeTempDir(): string
    {
        $dir = sys_get_temp_dir() . '/' . self::TEMP_DIR_PREFIX . bin2hex(random_bytes(6));
        if (!mkdir($dir, 0o700, true) && !is_dir($dir)) {
            throw new RuntimeException(sprintf('cannot mkdir temp %s', $dir));
        }

        return $dir;
    }

    private function ensureTargetDirs(): void
    {
        foreach ([$this->countryDbPath, $this->asnDbPath] as $path) {
            $dir = dirname($path);
            if (!is_dir($dir) && !@mkdir($dir, 0o755, true) && !is_dir($dir)) {
                throw new RuntimeException(sprintf('cannot mkdir target %s', $dir));
            }
        }
    }

    private function atomicReplace(string $sourcePath, string $targetPath): void
    {
        $targetDir = dirname($targetPath);
        // Use tempnam in the SAME directory as the target so rename() is
        // atomic (POSIX rename across filesystems is not).
        $stagedPath = @tempnam($targetDir, 'geoip-stage-');
        if ($stagedPath === false) {
            throw new RuntimeException(sprintf('tempnam in %s failed', $targetDir));
        }

        if (!@copy($sourcePath, $stagedPath)) {
            @unlink($stagedPath);

            throw new RuntimeException(sprintf('copy %s -> %s failed', $sourcePath, $stagedPath));
        }
        // tempnam creates a 0600 file; relax to readable so other procs can open it.
        @chmod($stagedPath, 0o644);

        if (!@rename($stagedPath, $targetPath)) {
            @unlink($stagedPath);

            throw new RuntimeException(sprintf('rename %s -> %s failed', $stagedPath, $targetPath));
        }
    }

    private function readNodeCount(string $path): int
    {
        try {
            $reader = new Reader($path);
            $count = $reader->metadata()->nodeCount;
            $reader->close();

            return (int) $count;
        } catch (Throwable) {
            return 0;
        }
    }

    private function cleanup(string $dir): void
    {
        if (!is_dir($dir)) {
            return;
        }
        foreach (glob($dir . '/*') ?: [] as $entry) {
            if (is_dir($entry)) {
                $this->cleanup($entry);
            } else {
                @unlink($entry);
            }
        }
        @rmdir($dir);
    }
}
