<?php

namespace App\Service;

/**
 * @codeCoverageIgnore
 */
class DnsCheckService
{
    private const EXPECTED_SPF_INCLUDES = ['amazonses.com', 'mail.esy-web.dev'];

    private const DKIM_SELECTORS = ['ses1', 'ses2', 'ses3', 'default', 'mail', 'k1', 'google', 'selector1', 'selector2', 'dkim'];

    /**
     * @return array{type: string, label: string, status: string, detail: string, expected: string, dig: string, cloudflare: string, cf_status: string}
     */
    public static function check(string $type, string $label, string $status, string $detail, string $expected = '', string $dig = '', string $cloudflare = '', string $cfStatus = ''): array
    {
        return [
            'type' => $type,
            'label' => $label,
            'status' => $status,
            'detail' => $detail,
            'expected' => $expected,
            'dig' => $dig,
            'cloudflare' => $cloudflare,
            'cf_status' => $cfStatus,
        ];
    }

    /**
     * @param list<array>  $checks
     * @param list<string> $errors
     * @param list<string> $warnings
     * @param list<string> $successes
     */
    public function checkSpf(string $domain, array &$checks, array &$errors, array &$warnings, array &$successes): void
    {
        $records = $this->getTxtRecords($domain);
        $spf = null;

        foreach ($records as $record) {
            if (str_starts_with($record, 'v=spf1')) {
                $spf = $record;
                break;
            }
        }

        if (null === $spf) {
            $errors[] = "[$domain] SPF : absent";
            $checks[] = self::check('SPF', 'Enregistrement SPF', 'error', 'Absent', 'v=spf1 include:amazonses.com include:mail.esy-web.dev -all', 'Non trouve');

            return;
        }

        foreach (self::EXPECTED_SPF_INCLUDES as $include) {
            $expected = "include:$include dans le SPF";
            if (!str_contains($spf, 'include:'.$include)) {
                $errors[] = "[$domain] SPF : include:$include manquant";
                $checks[] = self::check('SPF', "include:$include", 'error', 'Manquant', $expected, $spf);
            } else {
                $successes[] = "[$domain] SPF : include:$include present";
                $checks[] = self::check('SPF', "include:$include", 'ok', 'Present', $expected, $spf);
            }
        }

        if (!str_contains($spf, '-all') && !str_contains($spf, '~all')) {
            $warnings[] = "[$domain] SPF : pas de -all ou ~all";
        }
    }

    /**
     * @param list<array>  $checks
     * @param list<string> $errors
     * @param list<string> $successes
     */
    public function checkDmarc(string $domain, array &$checks, array &$errors, array &$successes): void
    {
        $records = $this->getTxtRecords('_dmarc.'.$domain);
        $dmarc = null;

        foreach ($records as $record) {
            if (str_starts_with($record, 'v=DMARC1')) {
                $dmarc = $record;
                break;
            }
        }

        if (null === $dmarc) {
            $errors[] = "[$domain] DMARC : absent";
            $checks[] = self::check('DMARC', 'Enregistrement DMARC', 'error', 'Absent', 'v=DMARC1; p=reject; ...', 'Non trouve sur _dmarc.'.$domain);

            return;
        }

        $policy = 'inconnue';
        if (str_contains($dmarc, 'p=reject')) {
            $policy = 'reject';
        } elseif (str_contains($dmarc, 'p=quarantine')) {
            $policy = 'quarantine';
        } elseif (str_contains($dmarc, 'p=none')) {
            $policy = 'none';
        }

        $successes[] = "[$domain] DMARC : politique p=$policy";
        $checks[] = self::check('DMARC', 'Politique', 'none' === $policy ? 'warning' : 'ok', "p=$policy", 'p=reject ou p=quarantine', $dmarc);

        if (str_contains($dmarc, 'rua=')) {
            $successes[] = "[$domain] DMARC : rapport rua configure";
            $checks[] = self::check('DMARC', 'Rapports (rua)', 'ok', 'Configure', 'rua=mailto:...', 'Present dans '.$dmarc);
        } else {
            $errors[] = "[$domain] DMARC : rua manquant";
            $checks[] = self::check('DMARC', 'Rapports (rua)', 'error', 'Absent', 'rua=mailto:...', 'Absent du DMARC');
        }
    }

    /**
     * @param list<array>  $checks
     * @param list<string> $errors
     * @param list<string> $warnings
     * @param list<string> $successes
     */
    public function checkDkim(string $domain, array &$checks, array &$errors, array &$warnings, array &$successes): void
    {
        $found = 0;

        foreach (self::DKIM_SELECTORS as $selector) {
            $fqdn = $selector.'._domainkey.'.$domain;
            $cname = $this->getCnameRecord($fqdn);
            $txt = $this->getDkimTxtRecord($fqdn);

            if (null !== $cname) {
                $checks[] = self::check('DKIM', "Selecteur $selector", 'ok', 'CNAME', $fqdn.' CNAME', $cname);
                ++$found;
            } elseif (null !== $txt) {
                $checks[] = self::check('DKIM', "Selecteur $selector", 'ok', 'TXT', $fqdn.' TXT v=DKIM1', substr($txt, 0, 60).'...');
                ++$found;
            }
        }

        if (0 === $found) {
            $warnings[] = "[$domain] DKIM : aucun selecteur trouve";
            $checks[] = self::check('DKIM', 'DKIM', 'warning', 'Aucun selecteur', 'Au moins 1 selecteur DKIM', 'Aucun trouve');
        } else {
            $successes[] = "[$domain] DKIM : $found selecteur(s) trouve(s)";
        }
    }

    /**
     * @param list<array>  $checks
     * @param list<string> $errors
     * @param list<string> $successes
     */
    public function checkMx(string $domain, string $expectedMx, array &$checks, array &$errors, array &$successes): void
    {
        $mxRecords = $this->getMxRecords($domain);

        if ([] === $mxRecords) {
            $errors[] = "[$domain] MX : absent";
            $checks[] = self::check('MX', 'Enregistrement MX', 'error', 'Absent', $expectedMx, 'Aucun MX trouve');

            return;
        }

        $found = false;
        $digValues = [];
        foreach ($mxRecords as $mx) {
            $digValues[] = $mx['target'].' (pri: '.$mx['pri'].')';
            if (str_contains($mx['target'], $expectedMx)) {
                $found = true;
            }
        }
        $digStr = implode(', ', $digValues);

        $checks[] = self::check('MX', $domain, $found ? 'ok' : 'error', $found ? 'Present' : 'Absent', $expectedMx, $digStr);

        if ($found) {
            $successes[] = "[$domain] MX : $expectedMx present";
        } else {
            $errors[] = "[$domain] MX : $expectedMx attendu mais absent";
        }
    }

    /**
     * @param list<array>  $checks
     * @param list<string> $errors
     * @param list<string> $warnings
     * @param list<string> $successes
     */
    public function checkBounce(string $domain, array &$checks, array &$errors, array &$warnings, array &$successes): void
    {
        $bounceDomain = 'bounce.'.$domain;
        $expected = 'feedback-smtp.*.amazonses.com';
        $mxRecords = $this->getMxRecords($bounceDomain);

        if ([] !== $mxRecords) {
            $target = $mxRecords[0]['target'];
            $successes[] = "[$domain] Bounce MX : $target";
            $checks[] = self::check('Bounce', 'MX '.$bounceDomain, 'ok', $target, $expected, $target);
        } else {
            $cname = $this->getCnameRecord($bounceDomain);
            if (null !== $cname) {
                $successes[] = "[$domain] Bounce CNAME : $cname";
                $checks[] = self::check('Bounce', 'CNAME '.$bounceDomain, 'ok', $cname, $expected, $cname);
            } else {
                $warnings[] = "[$domain] Bounce : aucun enregistrement sur $bounceDomain";
                $checks[] = self::check('Bounce', $bounceDomain, 'warning', 'Aucun', $expected, 'Non trouve');
            }
        }
    }

    /** @var array<string, string> TLD → RDAP server */
    private const RDAP_SERVERS = [
        'fr' => 'https://rdap.nic.fr/domain/',
        'com' => 'https://rdap.verisign.com/com/v1/domain/',
        'net' => 'https://rdap.verisign.com/net/v1/domain/',
        'org' => 'https://rdap.org/domain/',
        'dev' => 'https://pubapi.registry.google/rdap/domain/',
        'eu' => 'https://rdap.org/domain/',
    ];

    /**
     * Verifie les NS et l'expiration du domaine via RDAP.
     */
    public function getExpirationDate(string $domain): ?\DateTimeImmutable
    {
        $rdapData = $this->queryRdap($domain);

        if (null === $rdapData) {
            return null;
        }

        foreach ($rdapData['events'] ?? [] as $event) {
            if ('expiration' === ($event['eventAction'] ?? '')) {
                try {
                    return new \DateTimeImmutable($event['eventDate']);
                } catch (\Throwable) {
                    return null;
                }
            }
        }

        return null;
    }

    /**
     * @param array<int, array<string, mixed>> $checks
     * @param array<int, string>               $errors
     * @param array<int, string>               $warnings
     * @param array<int, string>               $successes
     */
    public function checkWhois(string $domain, array &$checks, array &$errors, array &$warnings, array &$successes): void
    {
        // Nameservers via dig NS
        $nsRecords = $this->getNsRecords($domain);
        $nsStr = implode(', ', $nsRecords);
        $isCloudflare = !empty(array_filter($nsRecords, fn (string $ns) => str_contains($ns, 'cloudflare.com')));

        $checks[] = self::check(
            'WHOIS', 'Nameservers', $isCloudflare ? 'ok' : 'error',
            $isCloudflare ? 'Cloudflare' : 'Non Cloudflare',
            '*.ns.cloudflare.com', $nsStr ?: 'Non trouve'
        );

        if ($isCloudflare) {
            $successes[] = "[$domain] WHOIS NS : Cloudflare ($nsStr)";
        } else {
            $errors[] = "[$domain] WHOIS NS : pas Cloudflare ($nsStr)";
        }

        // Expiration via RDAP
        $rdapData = $this->queryRdap($domain);

        if (null === $rdapData) {
            $warnings[] = "[$domain] WHOIS : RDAP inaccessible, expiration non verifiee";
            $checks[] = self::check('WHOIS', 'Expiration', 'warning', 'RDAP inaccessible', 'Plus de 30 jours', 'N/A');

            return;
        }

        $expirationDate = null;
        foreach ($rdapData['events'] ?? [] as $event) {
            if ('expiration' === ($event['eventAction'] ?? '')) {
                try {
                    $expirationDate = new \DateTimeImmutable($event['eventDate']);
                } catch (\Throwable) {
                }
                break;
            }
        }

        if (null === $expirationDate) {
            $warnings[] = "[$domain] WHOIS : date d'expiration introuvable";
            $checks[] = self::check('WHOIS', 'Expiration', 'warning', 'Introuvable', 'Plus de 30 jours', 'N/A');

            return;
        }

        $now = new \DateTimeImmutable();
        $daysLeft = (int) $now->diff($expirationDate)->format('%r%a');
        $expStr = $expirationDate->format('d/m/Y').' ('.$daysLeft.'j restants)';

        if ($daysLeft < 0) {
            $errors[] = "[$domain] WHOIS : domaine expire depuis ".abs($daysLeft).' jours !';
            $checks[] = self::check('WHOIS', 'Expiration', 'error', 'EXPIRE', 'Plus de 30 jours', $expStr);
        } elseif ($daysLeft <= 30) {
            $errors[] = "[$domain] WHOIS : domaine expire dans $daysLeft jours !";
            $checks[] = self::check('WHOIS', 'Expiration', 'error', "$daysLeft jours restants", 'Plus de 30 jours', $expStr);
        } elseif ($daysLeft <= 90) {
            $warnings[] = "[$domain] WHOIS : domaine expire dans $daysLeft jours";
            $checks[] = self::check('WHOIS', 'Expiration', 'warning', "$daysLeft jours restants", 'Plus de 30 jours', $expStr);
        } else {
            $successes[] = "[$domain] WHOIS : expire le ".$expirationDate->format('d/m/Y')." ($daysLeft jours)";
            $checks[] = self::check('WHOIS', 'Expiration', 'ok', "$daysLeft jours restants", 'Plus de 30 jours', $expStr);
        }
    }

    /**
     * @return array<string, mixed>|null
     */
    private function queryRdap(string $domain): ?array
    {
        $tld = substr($domain, strrpos($domain, '.') + 1);
        $rdapUrl = self::RDAP_SERVERS[$tld] ?? null;

        if (null === $rdapUrl) {
            // Fallback : essayer rdap.org
            $rdapUrl = 'https://rdap.org/domain/';
        }

        $ctx = stream_context_create([
            'http' => [
                'header' => 'Accept: application/rdap+json',
                'timeout' => 10,
            ],
        ]);

        $content = @file_get_contents($rdapUrl.$domain, false, $ctx);

        if (false === $content) {
            return null;
        }

        return json_decode($content, true);
    }

    private const RESOLVER = '1.1.1.1';

    /**
     * @return list<string>
     */
    private function getTxtRecords(string $domain): array
    {
        $output = $this->dig($domain, 'TXT');
        $records = [];

        foreach (explode("\n", $output) as $line) {
            if (preg_match('/\bIN\s+TXT\s+"(.+)"/', $line, $m)) {
                $records[] = str_replace('" "', '', $m[1]);
            }
        }

        return $records;
    }

    public function getDkimTxtRecord(string $domain): ?string
    {
        foreach ($this->getTxtRecords($domain) as $txt) {
            if (str_starts_with($txt, 'v=DKIM1')) {
                return $txt;
            }
        }

        return null;
    }

    /**
     * @return list<string>
     */
    public function getNsRecords(string $domain): array
    {
        $output = $this->dig($domain, 'NS');
        $records = [];

        foreach (explode("\n", $output) as $line) {
            if (preg_match('/\bIN\s+NS\s+(\S+)/', $line, $m)) {
                $records[] = rtrim($m[1], '.');
            }
        }

        return $records;
    }

    public function getCnameRecord(string $domain): ?string
    {
        $output = $this->dig($domain, 'CNAME');

        if (preg_match('/\bIN\s+CNAME\s+(\S+)/', $output, $m)) {
            return rtrim($m[1], '.');
        }

        return null;
    }

    /**
     * @return list<array{target: string, pri: int}>
     */
    public function getMxRecords(string $domain): array
    {
        $output = $this->dig($domain, 'MX');
        $records = [];

        foreach (explode("\n", $output) as $line) {
            if (preg_match('/\bIN\s+MX\s+(\d+)\s+(\S+)/', $line, $m)) {
                $records[] = ['target' => rtrim($m[2], '.'), 'pri' => (int) $m[1]];
            }
        }

        return $records;
    }

    /**
     * @return list<array{target: string, port: int, priority: int}>
     */
    public function getSrvRecords(string $domain): array
    {
        $output = $this->dig($domain, 'SRV');
        $records = [];

        foreach (explode("\n", $output) as $line) {
            if (preg_match('/\bIN\s+SRV\s+(\d+)\s+\d+\s+(\d+)\s+(\S+)/', $line, $m)) {
                $records[] = ['target' => rtrim($m[3], '.'), 'port' => (int) $m[2], 'priority' => (int) $m[1]];
            }
        }

        return $records;
    }

    private function isDigAvailable(): bool
    {
        static $available = null;
        if (null === $available) {
            $available = null !== @shell_exec('which dig 2>/dev/null');
        }

        return $available;
    }

    public function dig(string $domain, string $type): string
    {
        if (!$this->isDigAvailable()) {
            return $this->fallbackDnsGetRecord($domain, $type);
        }

        $cmd = sprintf('dig @%s %s %s +noall +answer 2>/dev/null', self::RESOLVER, escapeshellarg($domain), escapeshellarg($type));
        $output = @shell_exec($cmd);

        return $output ?? '';
    }

    /**
     * Fallback quand dig n'est pas installe : utilise dns_get_record()
     * et formate la sortie comme dig +noall +answer.
     */
    private function fallbackDnsGetRecord(string $domain, string $type): string
    {
        $dnsType = match (strtoupper($type)) {
            'TXT' => \DNS_TXT,
            'MX' => \DNS_MX,
            'CNAME' => \DNS_CNAME,
            'SRV' => \DNS_SRV,
            'NS' => \DNS_NS,
            default => \DNS_ANY,
        };

        $records = @dns_get_record($domain, $dnsType) ?: [];
        $lines = [];

        foreach ($records as $r) {
            $t = $r['type'] ?? '';
            match ($t) {
                'TXT' => $lines[] = "$domain.\tIN\tTXT\t\"".($r['txt'] ?? '').'"',
                'MX' => $lines[] = "$domain.\tIN\tMX\t".($r['pri'] ?? 0).' '.($r['target'] ?? ''),
                'CNAME' => $lines[] = "$domain.\tIN\tCNAME\t".($r['target'] ?? ''),
                'SRV' => $lines[] = "$domain.\tIN\tSRV\t".($r['pri'] ?? 0).' '.($r['weight'] ?? 0).' '.($r['port'] ?? 0).' '.($r['target'] ?? ''),
                'NS' => $lines[] = "$domain.\tIN\tNS\t".($r['target'] ?? ''),
                default => null,
            };
        }

        return implode("\n", $lines);
    }
}
