"""
Tests for uca.py. Every example in the UCA 1.0 specification must parse exactly one way,
and every address the prose forbids must be refused with a reason.

    python -m unittest test_uca -v
"""

import tempfile
import unittest
from pathlib import Path

import uca
from uca import Kind, Tier, UCAError, parse


def kinds(text: str, **kw) -> list[str]:
    return [s.kind.value for s in parse(text, **kw).segments]


class SpecificationExamples(unittest.TestCase):
    """Every address the specification itself uses as an example."""

    def test_bases(self):
        a = parse("MA20")
        self.assertEqual((a.cognate, a.number, a.tier), ("MA", 20, Tier.CANON))
        self.assertTrue(a.is_leaf and a.is_constitutional)
        self.assertEqual(a.title, "Competitive Position")
        self.assertEqual(parse("ID01").tier, Tier.SYSTEM)  # a cognate's method node
        self.assertEqual(parse("SY50").tier, Tier.PROJECTS)
        self.assertEqual(parse("BR85").tier, Tier.PATTERNS)

    def test_numeric_parts(self):
        self.assertEqual(kinds("SY50_10"), ["part"])
        self.assertEqual(kinds("SY50_10_01"), ["part", "part"])
        self.assertEqual(kinds("IN50_01"), ["part"])
        self.assertEqual(kinds("IN50_03"), ["part"])

    def test_eats(self):
        self.assertEqual(kinds("IN50_2026_06_A01"), ["year", "month", "answer"])
        self.assertEqual(kinds("MA20_E01"), ["exploration"])
        self.assertEqual(kinds("BR20_T02"), ["template"])

    def test_temporal_grains(self):
        self.assertEqual(kinds("IN50_2026"), ["year"])
        self.assertEqual(kinds("IN50_2026_Q2"), ["year", "quarter"])
        self.assertEqual(kinds("IN50_2026_06"), ["year", "month"])
        self.assertEqual(kinds("IN50_2026_09"), ["year", "month"])
        self.assertEqual(kinds("IN50_2026_W27"), ["year", "week"])
        self.assertEqual(kinds("IN50_2026_06_15"), ["year", "month", "day"])

    def test_sprint_is_always_a_sprint(self):
        # Reading rule 2: beneath a year, that year's Nth sprint; elsewhere, the parent's.
        self.assertEqual(kinds("IN50_2026_S04"), ["year", "sprint"])
        self.assertEqual(kinds("IN50_S04"), ["sprint"])

    def test_trackers(self):
        self.assertEqual(kinds("SY50_INC_2026"), ["tracker", "year"])
        self.assertEqual(kinds("SY50_REL_2026"), ["tracker", "year"])
        self.assertEqual(kinds("SY50_INC_2027"), ["tracker", "year"])
        # A tracker may stand as the stable root beneath which years hang.
        self.assertEqual(kinds("SY50_INC"), ["tracker"])

    def test_hosted_address(self):
        a = parse("MA50_01_SY20")
        self.assertEqual(kinds("MA50_01_SY20"), ["part", "foreign"])
        self.assertIsNotNone(a.host)
        self.assertEqual(a.host.base, "SY20")
        self.assertEqual(a.host.title, "Context Node")

    def test_hosted_address_may_carry_its_own_extensions(self):
        # The guest's address must be valid in the guest's own library: SY20 is a canon
        # leaf, so SY20_01 (a structural part) is refused, while SY20_A01 (an Answer) stands.
        a = parse("MA50_01_SY20_A01")
        self.assertEqual(kinds("MA50_01_SY20_A01"), ["part", "foreign"])
        self.assertEqual(a.host.text, "SY20_A01")
        with self.assertRaises(UCAError):
            parse("MA50_01_SY20_01")


class ReadingRules(unittest.TestCase):
    """The two prose rules that make the grammar deterministic."""

    def test_two_digits_are_a_part_unless_under_a_date(self):
        self.assertEqual(kinds("SY50_10"), ["part"])
        self.assertEqual(kinds("SY50_2026_10"), ["year", "month"])
        self.assertEqual(kinds("SY50_2026_10_10"), ["year", "month", "day"])

    def test_every_example_parses_exactly_one_way(self):
        # Determinism is structural: parse() returns one Address, never a set. This
        # test pins the behaviour so a future grammar change can't reintroduce ambiguity.
        for text in ["SY50_10_01", "IN50_2026_06_15", "MA50_01_SY20", "MA50_01_SY20_A01"]:
            a = parse(text)
            self.assertEqual(a.text, text)
            self.assertEqual("_".join([a.base] + [s.text for s in a.segments]), text)


class Refusals(unittest.TestCase):
    """What the prose forbids, the parser refuses — with the rule named."""

    def refuse(self, text: str, fragment: str, **kw):
        with self.assertRaises(UCAError) as cm:
            parse(text, **kw)
        self.assertIn(fragment, str(cm.exception))

    def test_unknown_cognate(self):
        self.refuse("ZZ99", "not one of the eleven")

    def test_illustrative_prefixes_are_off_by_default(self):
        self.refuse("LP20", "illustrative")
        self.assertTrue(parse("LP20", allow_illustrative=True).illustrative)
        self.assertEqual(parse("CS50", allow_illustrative=True).cognate_name, "Customer (Marketplace)")

    def test_quarter_needs_a_year(self):
        self.refuse("MA20_Q3", "hangs from a year")

    def test_week_needs_a_year(self):
        self.refuse("MA50_W12", "hangs from a year")

    def test_tracker_never_in_canon(self):
        self.refuse("ID20_INC_2026", "canon (20–49) never takes one")

    def test_tracker_only_at_root(self):
        self.refuse("MA50_2026_INC", "between the base")
        self.refuse("MA50_INC_INC_INC", "between the base")

    def test_tracker_takes_a_year_not_a_part(self):
        self.refuse("SY50_INC_01", "takes a year")

    def test_hosting_only_in_active_work(self):
        self.refuse("ID35_01_SY20", "hosting applies in active work")
        self.refuse("BR85_01_SY20", "hosting applies in active work")

    def test_hosting_not_under_a_date(self):
        self.refuse("MA50_2026_SY20", "not from a date")

    def test_canon_leaf_takes_no_parts(self):
        self.refuse("MA20_01", "promoted to a master")
        self.assertEqual(kinds("MA35_01"), ["part"])  # a master may

    def test_year_is_the_spine(self):
        self.refuse("SY50_2026_2027", "spine")

    def test_ranges(self):
        self.refuse("IN50_2026_13", "months are 01–12")
        self.refuse("IN50_2026_06_32", "days are 01–31")
        self.refuse("IN50_2026_W54", "W01–W53")
        self.refuse("IN50_2026_00", "months are 01–12")    # 00 is never a month
        self.refuse("IN50_2026_06_00", "days are 01–31")   # or a day

    def test_malformed(self):
        self.refuse("", "empty")
        self.refuse("MA20__01", "empty segment")
        self.refuse("ma20", "only A–Z")
        self.refuse("MA2", "XX##")
        self.refuse("MA20_XYZW", "matches no pattern")


class FoundationSlot(unittest.TestCase):
    """In any numbered segment, 00 is the foundation slot."""

    def test_part_zero_is_the_foundation(self):
        a = parse("MA50_00")
        self.assertEqual(kinds("MA50_00"), ["part"])
        self.assertTrue(a.is_foundation)
        self.assertIn("foundation slot", a.explain())
        self.assertEqual(kinds("MA50_10_01_00_01"), ["part", "part", "part", "part"])
        self.assertFalse(parse("MA50_10_01_00_01").is_foundation)

    def test_eats_zero_is_the_series_foundation(self):
        self.assertEqual(kinds("IN50_2026_S04_A00"), ["year", "sprint", "answer"])
        self.assertTrue(parse("SY50_20_03_A00").is_foundation)

    def test_zero_is_still_not_a_date(self):
        with self.assertRaises(UCAError):
            parse("IN50_2026_00")
        with self.assertRaises(UCAError):
            parse("IN50_2026_06_00")


class DatedStreams(unittest.TestCase):
    """A record of what occurred files beneath its date by E/A/T, never by ordinal."""

    def test_transcript_record_and_punchlist_beneath_a_day(self):
        self.assertEqual(kinds("SY50_TRA_2026_09_22_E01"), ["tracker", "year", "month", "day", "exploration"])
        self.assertEqual(kinds("SY50_TRA_2026_09_22_A01"), ["tracker", "year", "month", "day", "answer"])
        self.assertEqual(kinds("SY50_TRA_2026_09_22_T01"), ["tracker", "year", "month", "day", "template"])
        self.assertEqual(kinds("SY50_TRA_2026_09_22_E02")[-1], "exploration")

    def test_no_ordinal_beneath_a_day(self):
        with self.assertRaises(UCAError) as cm:
            parse("SY50_TRA_2026_09_22_01")
        self.assertIn("nothing numeric hangs from that grain", str(cm.exception))


class Facts(unittest.TestCase):
    def test_reserved_prefix_ai(self):
        a = uca.parse("AI00")
        self.assertEqual(a.cognate, "AI")
        self.assertEqual(a.tier, uca.Tier.SYSTEM)
        self.assertEqual(uca.parse("AI03_A01").segments[-1].kind, uca.Kind.ANSWER)
        self.assertEqual(uca.parse("AI00_01").segments[-1].kind, uca.Kind.PART)
        with self.assertRaises(uca.UCAError):
            uca.parse("AI20")
        with self.assertRaises(uca.UCAError):
            uca.parse("AI00_SSL_2026")

    def test_first_fifteen_is_fifteen(self):
        self.assertEqual(len(uca.FIRST_FIFTEEN), 15)
        self.assertTrue(set(uca.MINIMUM_VIABLE_INTELLIGENCE) <= set(uca.FIRST_FIFTEEN))
        for base in uca.FIRST_FIFTEEN:
            self.assertTrue(parse(base).is_leaf, base)  # all leaves in 20–34

    def test_canon_partition(self):
        self.assertTrue(parse("MA34").is_leaf)
        self.assertTrue(parse("MA35").is_master)

    def test_explain_and_json_round_trip(self):
        a = parse("MA50_01_SY20")
        self.assertIn("hosted address", a.explain())
        d = a.to_dict()
        self.assertEqual(d["host"]["base"], "SY20")
        self.assertEqual(d["tier"], "Projects")

    def test_check(self):
        good, bad = uca.check(["MA20", "# comment", "", "ZZ99  # bad", "IN50_2026_Q2"])
        self.assertEqual([a.text for a in good], ["MA20", "IN50_2026_Q2"])
        self.assertEqual(bad[0][0], "ZZ99")

    def test_init_writes_fifteen_and_never_overwrites(self):
        with tempfile.TemporaryDirectory() as d:
            written = uca.init(Path(d))
            self.assertEqual(len(written), 15)
            marker = Path(d) / "MA20.md"
            marker.write_text("mine", encoding="utf-8")
            self.assertEqual(uca.init(Path(d)), [])
            self.assertEqual(marker.read_text(encoding="utf-8"), "mine")


if __name__ == "__main__":
    unittest.main()
