"""Tests for parsedmarc.config"""

import dataclasses
import pickle
import unittest

import parsedmarc
import parsedmarc.config as parsedmarc_config


class TestParserConfigCaches(unittest.TestCase):
    """Covers per-instance cache isolation for `ParserConfig`."""

    def test_each_instance_gets_isolated_caches(self):
        """Two independently constructed ParserConfig instances must never
        share cache objects with each other or with the module defaults, and
        mutating one instance's caches must not leak into the others."""
        cfg_a = parsedmarc_config.ParserConfig()
        cfg_b = parsedmarc_config.ParserConfig()

        # All distinct objects.
        self.assertIsNot(cfg_a.ip_address_cache, cfg_b.ip_address_cache)
        self.assertIsNot(
            cfg_a.seen_aggregate_report_ids, cfg_b.seen_aggregate_report_ids
        )
        self.assertIsNot(cfg_a.reverse_dns_map, cfg_b.reverse_dns_map)

        self.assertIsNot(cfg_a.ip_address_cache, parsedmarc_config.IP_ADDRESS_CACHE)
        self.assertIsNot(
            cfg_a.seen_aggregate_report_ids,
            parsedmarc_config.SEEN_AGGREGATE_REPORT_IDS,
        )
        self.assertIsNot(cfg_a.reverse_dns_map, parsedmarc_config.REVERSE_DNS_MAP)

        self.assertIsNot(cfg_b.ip_address_cache, parsedmarc_config.IP_ADDRESS_CACHE)
        self.assertIsNot(
            cfg_b.seen_aggregate_report_ids,
            parsedmarc_config.SEEN_AGGREGATE_REPORT_IDS,
        )
        self.assertIsNot(cfg_b.reverse_dns_map, parsedmarc_config.REVERSE_DNS_MAP)

        # Mutating one instance's caches must not affect the other, nor the
        # module defaults.
        cfg_a.ip_address_cache["1.2.3.4"] = {"ip_address": "1.2.3.4"}
        cfg_a.seen_aggregate_report_ids["report-id-a"] = True
        cfg_a.reverse_dns_map["example.com"] = {"name": "Example", "type": None}

        self.assertNotIn("1.2.3.4", cfg_b.ip_address_cache)
        self.assertNotIn("report-id-a", cfg_b.seen_aggregate_report_ids)
        self.assertNotIn("example.com", cfg_b.reverse_dns_map)

        self.assertNotIn("1.2.3.4", parsedmarc_config.IP_ADDRESS_CACHE)
        self.assertNotIn("report-id-a", parsedmarc_config.SEEN_AGGREGATE_REPORT_IDS)
        self.assertNotIn("example.com", parsedmarc_config.REVERSE_DNS_MAP)

    def test_module_default_caches_are_the_public_globals(self):
        """The three module-default caches in `parsedmarc.config` must be the
        very same objects re-exported as `parsedmarc.IP_ADDRESS_CACHE`,
        `parsedmarc.SEEN_AGGREGATE_REPORT_IDS`, and
        `parsedmarc.REVERSE_DNS_MAP`, so that pre-refactor callers who
        imported these names directly from the top-level package keep
        observing the same cache objects as code that goes through
        ParserConfig.
        """
        self.assertIs(parsedmarc.IP_ADDRESS_CACHE, parsedmarc_config.IP_ADDRESS_CACHE)
        self.assertIs(
            parsedmarc.SEEN_AGGREGATE_REPORT_IDS,
            parsedmarc_config.SEEN_AGGREGATE_REPORT_IDS,
        )
        self.assertIs(parsedmarc.REVERSE_DNS_MAP, parsedmarc_config.REVERSE_DNS_MAP)


class TestParserConfigPickling(unittest.TestCase):
    """Covers the pickle strategy documented on `ParserConfig`: option fields
    round-trip, but cache contents never cross process boundaries and the
    unpickled object's caches rebind to the unpickling process's module
    defaults."""

    def test_pickle_drops_cache_contents_and_binds_process_defaults(self):
        """Pickling and unpickling a ParserConfig must preserve option
        fields, but must NOT carry cache contents across the round-trip, and
        must leave the unpickled instance's caches bound to this module's
        (the "unpickling process's") default cache objects rather than
        fresh, empty ones. Fresh-per-unpickle caches would silently defeat
        per-worker caching, since a functools.partial payload carrying a
        ParserConfig is re-pickled for every task submitted to a
        multiprocessing worker."""
        cfg = parsedmarc_config.ParserConfig(
            dns_timeout=9.5, nameservers=["192.0.2.53"]
        )
        cfg.ip_address_cache["sentinel-ip"] = "sentinel-ip-value"
        cfg.seen_aggregate_report_ids["sentinel-report-id"] = True
        cfg.reverse_dns_map["sentinel.example"] = {
            "name": "Sentinel",
            "type": None,
        }

        restored = pickle.loads(pickle.dumps(cfg))

        # Option fields compare equal (cache fields are compare=False).
        self.assertEqual(cfg, restored)
        self.assertEqual(restored.dns_timeout, 9.5)
        self.assertEqual(restored.nameservers, ["192.0.2.53"])

        # Caches rebind to this process's module defaults, not fresh copies.
        self.assertIs(restored.ip_address_cache, parsedmarc_config.IP_ADDRESS_CACHE)
        self.assertIs(
            restored.seen_aggregate_report_ids,
            parsedmarc_config.SEEN_AGGREGATE_REPORT_IDS,
        )
        self.assertIs(restored.reverse_dns_map, parsedmarc_config.REVERSE_DNS_MAP)

        # Sentinel entries placed on the original instance's caches must NOT
        # have crossed into the module defaults.
        self.assertNotIn("sentinel-ip", parsedmarc_config.IP_ADDRESS_CACHE)
        self.assertNotIn(
            "sentinel-report-id", parsedmarc_config.SEEN_AGGREGATE_REPORT_IDS
        )
        self.assertNotIn("sentinel.example", parsedmarc_config.REVERSE_DNS_MAP)

    def test_setstate_defaults_fields_missing_from_older_pickles(self):
        """__setstate__ must initialize every non-cache field to its class
        default before applying the pickled state, so a ParserConfig
        serialized by an older parsedmarc version (whose state lacks fields
        added since) unpickles with the newer fields at their defaults
        instead of unset — __init__ never runs during unpickling, so an
        absent field would otherwise raise AttributeError on first access.
        Simulated by calling __setstate__ directly with a partial state
        dict, exactly what pickle.loads does with an old payload."""
        restored = object.__new__(parsedmarc_config.ParserConfig)
        restored.__setstate__({"offline": True, "dns_timeout": 7.5})

        self.assertTrue(restored.offline)
        self.assertEqual(restored.dns_timeout, 7.5)
        # Fields absent from the old state get their class defaults.
        self.assertIsNone(restored.ip_db_path)
        self.assertIsNone(restored.nameservers)
        self.assertEqual(restored.normalize_timespan_threshold_hours, 24.0)
        # Caches still rebind to the module defaults.
        self.assertIs(restored.ip_address_cache, parsedmarc_config.IP_ADDRESS_CACHE)
        self.assertIs(
            restored.seen_aggregate_report_ids,
            parsedmarc_config.SEEN_AGGREGATE_REPORT_IDS,
        )
        self.assertIs(restored.reverse_dns_map, parsedmarc_config.REVERSE_DNS_MAP)


class TestParserConfigFrozenAndReplace(unittest.TestCase):
    """Covers frozen-dataclass immutability and the `dataclasses.replace`
    escape hatch for deriving variants that keep sharing caches."""

    def test_frozen_and_replace_shares_caches(self):
        """ParserConfig is frozen, so attribute assignment must raise
        FrozenInstanceError. `dataclasses.replace(cfg, ...)` must return a
        new config that shares the SAME cache objects as the source (all
        fields, including the three cache fields, are init fields), since
        that is the documented way to derive a variant that keeps warm
        caches."""
        cfg = parsedmarc_config.ParserConfig()

        with self.assertRaises(dataclasses.FrozenInstanceError):
            cfg.offline = True  # type: ignore[misc]

        variant = dataclasses.replace(cfg, offline=True)

        self.assertTrue(variant.offline)
        self.assertIs(variant.ip_address_cache, cfg.ip_address_cache)
        self.assertIs(variant.seen_aggregate_report_ids, cfg.seen_aggregate_report_ids)
        self.assertIs(variant.reverse_dns_map, cfg.reverse_dns_map)
