"""Tests for ISTAT country fallback resolution.

Covers:
- Direct lookup returns correct mapping (no fallback triggered)
- Fallback code resolves via canonical ISO-2 (AX → FI → "032")
- All entries in FALLBACK_COUNTRY_MAPPING resolve successfully
- Truly unknown code returns None from get_country
- Truly unknown code raises XmlPayloadValidationError in resolve_c59_residence_code
- Fallback does NOT override a code already in COUNTRY_MAPPINGS
- Case-insensitive input is normalised correctly
- get_country_code convenience method works through fallback
- FALLBACK_COUNTRY_MAPPING targets all exist in COUNTRY_MAPPINGS (data integrity)
- Warning is logged when fallback is used
"""

from __future__ import annotations

import logging
from unittest.mock import patch

from django.test import TestCase

from istat.xml_export.exceptions import XmlPayloadValidationError
from istat.xml_export.mappings.countries import COUNTRY_MAPPINGS, FALLBACK_COUNTRY_MAPPING
from istat.xml_export.services.c59_aggregation_service import resolve_c59_residence_code
from istat.xml_export.services.lookup_service import IstatLookupService


class FallbackMappingDataIntegrityTests(TestCase):
    """Validate the FALLBACK_COUNTRY_MAPPING data itself before testing behaviour."""

    def test_fallback_targets_exist_in_country_mappings(self):
        """Every canonical ISO-2 in FALLBACK_COUNTRY_MAPPING must exist in
        COUNTRY_MAPPINGS, otherwise the fallback would always fail."""
        for original, canonical in FALLBACK_COUNTRY_MAPPING.items():
            self.assertIn(
                canonical,
                COUNTRY_MAPPINGS,
                f"FALLBACK_COUNTRY_MAPPING['{original}'] = '{canonical}' "
                f"but '{canonical}' is not in COUNTRY_MAPPINGS",
            )

    def test_fallback_codes_not_already_in_primary_mappings(self):
        """Codes in FALLBACK_COUNTRY_MAPPING must NOT already be in
        COUNTRY_MAPPINGS — if they were, the fallback would be dead code."""
        for original in FALLBACK_COUNTRY_MAPPING:
            self.assertNotIn(
                original,
                COUNTRY_MAPPINGS,
                f"'{original}' is in both COUNTRY_MAPPINGS and "
                f"FALLBACK_COUNTRY_MAPPING — remove it from the fallback",
            )

    def test_fallback_values_are_uppercase_iso2(self):
        """Canonical codes must be uppercase 2-letter strings."""
        for original, canonical in FALLBACK_COUNTRY_MAPPING.items():
            self.assertEqual(
                canonical,
                canonical.upper(),
                f"FALLBACK_COUNTRY_MAPPING['{original}'] = '{canonical}' is not uppercase",
            )
            self.assertEqual(
                len(canonical),
                2,
                f"FALLBACK_COUNTRY_MAPPING['{original}'] = '{canonical}' is not 2 chars",
            )


class GetCountryDirectLookupTests(TestCase):
    """Primary path: codes present in COUNTRY_MAPPINGS resolve directly."""

    def test_known_code_returns_correct_mapping(self):
        result = IstatLookupService.get_country("DE")
        self.assertIsNotNone(result)
        self.assertEqual(result["istat_code"], "004")
        self.assertEqual(result["name"], "Germany")

    def test_known_code_lowercase_is_normalised(self):
        result = IstatLookupService.get_country("de")
        self.assertIsNotNone(result)
        self.assertEqual(result["istat_code"], "004")

    def test_known_code_with_whitespace_is_normalised(self):
        result = IstatLookupService.get_country("  FR  ")
        self.assertIsNotNone(result)
        self.assertEqual(result["istat_code"], "001")

    def test_finland_direct_lookup(self):
        """FI must resolve directly — it is the fallback target for AX."""
        result = IstatLookupService.get_country("FI")
        self.assertIsNotNone(result)
        self.assertEqual(result["istat_code"], "032")

    def test_none_input_returns_none(self):
        self.assertIsNone(IstatLookupService.get_country(None))

    def test_empty_string_returns_none(self):
        self.assertIsNone(IstatLookupService.get_country(""))

    def test_whitespace_only_returns_none(self):
        self.assertIsNone(IstatLookupService.get_country("   "))


class GetCountryFallbackTests(TestCase):
    """Fallback path: codes absent from COUNTRY_MAPPINGS but present in
    FALLBACK_COUNTRY_MAPPING resolve via their canonical alias."""

    def test_ax_resolves_via_finland(self):
        """AX (Åland Islands) → FI (Finland) → istat_code '032'."""
        result = IstatLookupService.get_country("AX")
        self.assertIsNotNone(result)
        self.assertEqual(result["istat_code"], "032")
        self.assertEqual(result["name"], "Finland")

    def test_ax_lowercase_resolves_via_fallback(self):
        result = IstatLookupService.get_country("ax")
        self.assertIsNotNone(result)
        self.assertEqual(result["istat_code"], "032")

    def test_all_fallback_codes_resolve_successfully(self):
        """Every entry in FALLBACK_COUNTRY_MAPPING must produce a non-None
        result from get_country."""
        for original in FALLBACK_COUNTRY_MAPPING:
            with self.subTest(iso_code=original):
                result = IstatLookupService.get_country(original)
                self.assertIsNotNone(
                    result,
                    f"get_country('{original}') returned None — "
                    f"fallback chain is broken",
                )
                self.assertIn("istat_code", result)
                self.assertTrue(
                    result["istat_code"].isdigit(),
                    f"istat_code for '{original}' is not numeric: {result['istat_code']}",
                )

    def test_fallback_does_not_affect_primary_codes(self):
        """Introducing a fallback must not change the result for codes that
        already exist in COUNTRY_MAPPINGS."""
        direct = IstatLookupService.get_country("FI")
        # FI is the canonical target for AX; resolving AX must return the
        # same mapping object as resolving FI directly.
        via_fallback = IstatLookupService.get_country("AX")
        self.assertEqual(direct, via_fallback)

    def test_unknown_code_returns_none(self):
        """A code absent from both mappings must return None."""
        self.assertIsNone(IstatLookupService.get_country("XX"))

    def test_unknown_code_zz_returns_none(self):
        self.assertIsNone(IstatLookupService.get_country("ZZ"))


class GetCountryFallbackLoggingTests(TestCase):
    """A WARNING must be emitted whenever the fallback path is taken."""

    def test_fallback_emits_warning(self):
        with self.assertLogs(
            "istat.xml_export.services.lookup_service", level=logging.WARNING
        ) as log_ctx:
            IstatLookupService.get_country("AX")

        # At least one WARNING message must mention the original code and the
        # canonical fallback code.
        messages = "\n".join(log_ctx.output)
        self.assertIn("AX", messages)
        self.assertIn("FI", messages)

    def test_direct_lookup_does_not_emit_warning(self):
        """No warning must be logged for codes that resolve directly."""
        with self.assertNoLogs(
            "istat.xml_export.services.lookup_service", level=logging.WARNING
        ):
            IstatLookupService.get_country("DE")

    def test_unknown_code_does_not_emit_warning(self):
        """Unknown codes return None silently; the caller raises the error."""
        with self.assertNoLogs(
            "istat.xml_export.services.lookup_service", level=logging.WARNING
        ):
            IstatLookupService.get_country("XX")


class GetCountryCodeConvenienceTests(TestCase):
    """get_country_code() must work through the fallback chain."""

    def test_direct_code(self):
        self.assertEqual(IstatLookupService.get_country_code("DE"), "004")

    def test_fallback_code(self):
        self.assertEqual(IstatLookupService.get_country_code("AX"), "032")

    def test_unknown_returns_none(self):
        self.assertIsNone(IstatLookupService.get_country_code("XX"))


class ResolveC59ResidenceCodeFallbackTests(TestCase):
    """resolve_c59_residence_code() must honour the fallback for foreign guests."""

    def test_ax_resolves_to_finland_istat_code(self):
        """AX guest → fallback to FI → ISTAT code '032' (Finland)."""
        nazione, residenza = resolve_c59_residence_code("AX", None)
        self.assertEqual(nazione, "e")
        self.assertEqual(residenza, "032")

    def test_known_foreign_code_unaffected(self):
        nazione, residenza = resolve_c59_residence_code("DE", None)
        self.assertEqual(nazione, "e")
        self.assertEqual(residenza, "004")

    def test_unknown_code_raises_validation_error(self):
        with self.assertRaises(XmlPayloadValidationError) as ctx:
            resolve_c59_residence_code("XX", None)
        self.assertIn("Missing ISTAT country mapping", str(ctx.exception))
        self.assertIn("XX", str(ctx.exception))

    def test_unknown_code_error_message_is_informative(self):
        """The error must name the offending code so operators can act."""
        with self.assertRaises(XmlPayloadValidationError) as ctx:
            resolve_c59_residence_code("ZZ", None)
        self.assertIn("ZZ", str(ctx.exception))

    def test_italian_resident_unaffected_by_fallback(self):
        """IT residents use province resolution — fallback must not interfere."""
        nazione, residenza = resolve_c59_residence_code("IT", "MI")
        self.assertEqual(nazione, "i")
        self.assertEqual(residenza, "015")
