<?php

declare(strict_types=1);

namespace App\Infrastructure\Enrichment;

use App\Domain\Enrichment\EnrichmentResult;
use App\Domain\Enrichment\EnrichmentService;
use App\Domain\Ip\IpAddress;
use App\Domain\Time\Clock;
use MaxMind\Db\Reader;
use Psr\Log\LoggerInterface;
use Throwable;

/**
 * Reads two MMDB files (country + ASN) using the lower-level
 * `MaxMind\Db\Reader`. The reader handles MaxMind, DB-IP, and IPinfo
 * MMDBs equally — only the record shape differs, and the configured
 * `RecordAdapter` smooths that over.
 *
 * Readers are lazy. If a DB file is missing or unreadable, the service
 * logs ONE warning per process lifetime (so a missing-DB never floods
 * logs from a 200-IP-per-tick batch) and returns an empty result.
 *
 * `reloadReaders()` is called by the refresh-geoip job after atomic
 * replacement so subsequent lookups pick up the new files without a
 * process restart.
 */
final class MmdbEnrichmentService implements EnrichmentService
{
    private ?Reader $countryReader = null;
    private ?Reader $asnReader = null;
    private bool $countryAttempted = false;
    private bool $asnAttempted = false;
    private bool $missingWarningEmitted = false;

    public function __construct(
        private readonly string $countryDbPath,
        private readonly string $asnDbPath,
        private readonly RecordAdapter $adapter,
        private readonly Clock $clock,
        private readonly LoggerInterface $logger,
    ) {
    }

    public function enrich(IpAddress $ip): EnrichmentResult
    {
        $now = $this->clock->now();
        $countryReader = $this->countryReader();
        $asnReader = $this->asnReader();

        if ($countryReader === null && $asnReader === null) {
            return new EnrichmentResult(null, null, null, $now);
        }

        $countryCode = null;
        if ($countryReader !== null) {
            $record = $this->lookup($countryReader, $ip->text());
            if ($record !== null) {
                $countryCode = $this->adapter->extractCountryCode($record);
            }
        }

        $asn = null;
        $asOrg = null;
        if ($asnReader !== null) {
            $record = $this->lookup($asnReader, $ip->text());
            if ($record !== null) {
                $asn = $this->adapter->extractAsn($record);
                $asOrg = $this->adapter->extractAsOrg($record);
            }
        }

        return new EnrichmentResult($countryCode, $asn, $asOrg, $now);
    }

    /**
     * Force a fresh open on the next lookup. Called by RefreshGeoipJob
     * after atomic-replacing the files on disk.
     */
    public function reloadReaders(): void
    {
        $this->countryReader?->close();
        $this->asnReader?->close();
        $this->countryReader = null;
        $this->asnReader = null;
        $this->countryAttempted = false;
        $this->asnAttempted = false;
        $this->missingWarningEmitted = false;
    }

    /**
     * True if at least one of the DBs is currently loadable. The
     * EnrichPendingJob uses this as a fast-path check so it can no-op
     * cleanly when neither file is on disk yet.
     */
    public function isReady(): bool
    {
        return $this->countryReader() !== null || $this->asnReader() !== null;
    }

    private function countryReader(): ?Reader
    {
        if (!$this->countryAttempted) {
            $this->countryAttempted = true;
            $this->countryReader = $this->openReader($this->countryDbPath, 'country');
        }

        return $this->countryReader;
    }

    private function asnReader(): ?Reader
    {
        if (!$this->asnAttempted) {
            $this->asnAttempted = true;
            $this->asnReader = $this->openReader($this->asnDbPath, 'asn');
        }

        return $this->asnReader;
    }

    private function openReader(string $path, string $kind): ?Reader
    {
        if (!is_file($path) || !is_readable($path)) {
            $this->emitMissingWarningOnce($path, $kind, 'not_found_or_unreadable');

            return null;
        }
        try {
            return new Reader($path);
        } catch (Throwable $e) {
            $this->emitMissingWarningOnce($path, $kind, $e->getMessage());

            return null;
        }
    }

    private function emitMissingWarningOnce(string $path, string $kind, string $reason): void
    {
        if ($this->missingWarningEmitted) {
            return;
        }
        $this->missingWarningEmitted = true;
        $this->logger->warning('geoip_db_unavailable', [
            'kind' => $kind,
            'path' => $path,
            'reason' => $reason,
        ]);
    }

    /**
     * @return array<string, mixed>|null
     */
    private function lookup(Reader $reader, string $ip): ?array
    {
        try {
            $record = $reader->get($ip);
        } catch (Throwable) {
            return null;
        }

        return is_array($record) ? $record : null;
    }
}
