"""T4/T6 tests: dirty/repaired propagation across parser and write-side mutation sites.

ADR-0006 SR2-T2 Strategy A PR-2 (GH#377). PR-2 wires the dirty-bit +
repaired-bit propagation through:

* parser comment-block span policy (``comment_block_start_byte``)
* parser lenient-repair propagation (``node.repaired = True`` when a
  ``lenient_parse`` warning fires while parsing the node's value)
* trail-anchored whitespace span extension (a node's ``end_byte``
  reaches through trailing blank lines up to the next node's start)
* frontmatter-inheritance dirty flag (``doc.dirty = True`` when
  inheritance fires)
* schema-repair paired writes (every ``node.value = ...`` in
  ``core/repair.py`` pairs with ``node.repaired = True``; every
  ``doc.meta[k] = ...`` in the write-side enum-casefold branch pairs
  with ``doc.meta_dirty[k] = True``)

The emitter does NOT consume these flags yet (T8/PR-3). Tests assert
state-after-mutation only.

See ``docs/adr/adr-0006-sr2-t2-ast-span-coverage-audit.md`` §3, §4, §6
T4/T5/T6.
"""

from __future__ import annotations

from octave_mcp.core.grammar.cst import Assignment, Block, Section
from octave_mcp.core.parser import parse


def _find_assignment(doc, key: str) -> Assignment | None:
    for node in doc.sections:
        if isinstance(node, Assignment) and node.key == key:
            return node
        if isinstance(node, (Block, Section)):
            for child in node.children:
                if isinstance(child, Assignment) and child.key == key:
                    return child
    return None


class TestCommentBlockStartByte:
    """Assignment with leading comments records the comment-band start byte."""

    def test_leading_comment_populates_comment_block_start_byte(self) -> None:
        # `//comment` immediately precedes `K::v`. The Assignment node for
        # K must have ``comment_block_start_byte`` pointing at the byte
        # just before `//` (the comment line's first byte).
        doc_text = "===DOC===\n// leading comment\nK::v\n===END===\n"
        doc = parse(doc_text)
        a = _find_assignment(doc, "K")
        assert a is not None
        assert a.leading_comments == ["leading comment"]
        assert a.comment_block_start_byte is not None
        # The comment line starts at the byte after "===DOC===\n" (10).
        nfc_bytes = doc_text.encode()
        assert a.comment_block_start_byte == nfc_bytes.index(b"// leading comment")
        # Assignment's own start_byte is at the K identifier, AFTER the comment.
        assert a.start_byte is not None
        assert a.comment_block_start_byte < a.start_byte

    def test_no_leading_comments_means_comment_block_start_byte_none(self) -> None:
        doc_text = "===DOC===\nK::v\n===END===\n"
        doc = parse(doc_text)
        a = _find_assignment(doc, "K")
        assert a is not None
        assert a.leading_comments == []
        assert a.comment_block_start_byte is None


class TestLenientRepairRepairedFlag:
    """Multi-word coalesce (parser lenient repair) sets node.repaired=True."""

    def test_multi_word_coalesce_marks_assignment_repaired(self) -> None:
        # `K::1 2 3` triggers the NUMBER+VALUE_TOKENS multi-word coalesce
        # (parser.py "number_identifier" subtype path). The resulting
        # Assignment must have ``repaired=True`` because the source bytes
        # would re-introduce the coalesce on a re-parse.
        doc_text = "===DOC===\nK::1 2 3\n===END===\n"
        doc = parse(doc_text)
        a = _find_assignment(doc, "K")
        assert a is not None
        # Sanity: warning was emitted.
        # parse() raises into Parser internals — we re-parse via the parser
        # instance to inspect warnings.
        from octave_mcp.core.lexer import tokenize
        from octave_mcp.core.parser import Parser

        tokens, _ = tokenize(doc_text)
        p = Parser(tokens)
        p.parse_document()
        assert any(
            w.get("subtype") == "multi_word_coalesce" for w in p.warnings
        ), "expected multi_word_coalesce lenient_parse warning"
        # The structural assertion under PR-2: Assignment.repaired = True.
        assert a.repaired is True

    def test_clean_assignment_has_repaired_false(self) -> None:
        doc_text = "===DOC===\nK::single_word_value\n===END===\n"
        doc = parse(doc_text)
        a = _find_assignment(doc, "K")
        assert a is not None
        assert a.repaired is False

    def test_pattern_autoquote_marks_assignment_repaired(self) -> None:
        # CRS PR-2 P1-1 regression: `PATTERN::bare_value` triggers the
        # pattern_autoquote lenient_parse warning AFTER parse_value() returns.
        # The Assignment.repaired flag must reflect the full warning window
        # for this Assignment, NOT just the pre-pattern-autoquote slice.
        doc_text = "===DOC===\nPATTERN::bare_pattern_value\n===END===\n"
        doc = parse(doc_text)
        a = _find_assignment(doc, "PATTERN")
        assert a is not None
        # Sanity: pattern_autoquote warning was emitted.
        from octave_mcp.core.lexer import tokenize
        from octave_mcp.core.parser import Parser

        tokens, _ = tokenize(doc_text)
        p = Parser(tokens)
        p.parse_document()
        assert any(
            w.get("subtype") == "pattern_autoquote" for w in p.warnings
        ), "expected pattern_autoquote lenient_parse warning"
        # Structural assertion: Assignment.repaired = True so Strategy A's
        # emitter (PR-3 T8) re-emits canonically instead of splicing the
        # unrepaired (bare, unquoted) source bytes.
        assert a.repaired is True


class TestTrailAnchoredWhitespace:
    """A node's end_byte extends through its trailing blank lines up to the next node."""

    def test_blank_line_between_siblings_owned_by_predecessor(self) -> None:
        # K1 has TWO blank lines after it before K2 begins. K1.end_byte
        # must reach the byte just before K2's start_byte (which itself
        # points at the K2 identifier).
        doc_text = "===DOC===\nK1::v1\n\n\nK2::v2\n===END===\n"
        doc = parse(doc_text)
        k1 = _find_assignment(doc, "K1")
        k2 = _find_assignment(doc, "K2")
        assert k1 is not None and k2 is not None
        assert k1.end_byte is not None and k2.start_byte is not None
        # Trail-anchored: K1.end_byte == K2.start_byte.
        assert k1.end_byte == k2.start_byte


class TestFrontmatterInheritanceDirty:
    """When write.py inherits frontmatter from baseline, doc.dirty=True.

    We exercise the inheritance branch by invoking the WriteTool through
    its MCP entry-point shape (kwargs accepted by ``_execute``). The
    post-PR-2 contract is: after a successful inheritance correction is
    emitted, the in-memory document parsed from the new content has had
    ``dirty=True`` set as part of the same write step. Because the tool
    does not return the document directly, we assert observably via the
    correction code surface AND via a re-parse round-trip that the
    inheritance event was registered.
    """

    def test_inheritance_emits_corrections_and_marks_dirty(self, tmp_path) -> None:
        import asyncio

        from octave_mcp.core.grammar.cst import Document
        from octave_mcp.mcp.write import WriteTool

        tool = WriteTool()
        baseline_with_frontmatter = "---\nschema: skill\n---\n===DOC===\nMETA:\n  STATUS::ACTIVE\n===END===\n"
        target = tmp_path / "target.oct.md"
        target.write_text(baseline_with_frontmatter)
        new_content_without_frontmatter = "===DOC===\nMETA:\n  STATUS::DRAFT\n===END===\n"

        # Capture dirty=True writes on any Document via __setattr__ spy.
        dirty_set_count = 0
        original_setattr = Document.__setattr__

        def spy_setattr(self: Document, name: str, value: object) -> None:
            nonlocal dirty_set_count
            original_setattr(self, name, value)
            if name == "dirty" and value is True:
                dirty_set_count += 1

        Document.__setattr__ = spy_setattr  # type: ignore[method-assign]
        try:
            result = asyncio.run(
                tool.execute(
                    target_path=str(target),
                    content=new_content_without_frontmatter,
                    lenient=True,
                    dry_run=True,
                )
            )
        finally:
            Document.__setattr__ = original_setattr  # type: ignore[method-assign]

        # Inheritance branch fired.
        assert any(
            c.get("code") == "W_FRONTMATTER_INHERITED" for c in result.get("corrections", [])
        ), f"expected W_FRONTMATTER_INHERITED correction, got {result.get('corrections')!r}"
        # PR-2 dirty-bit propagation: the inheritance branch flipped
        # doc.dirty=True at least once during the write step.
        assert dirty_set_count >= 1, "frontmatter inheritance did not set doc.dirty=True"


class TestEnumCasefoldMetaDirty:
    """write.py enum-casefold branch pairs doc.meta[k]= with doc.meta_dirty[k]=True."""

    def test_enum_casefold_sets_meta_dirty_for_field(self, tmp_path) -> None:
        # Exercise the write-side enum-casefold branch by writing a META
        # value in the wrong case against a schema-validating target.
        # The branch is gated by ``lenient=True`` + ``schema_def is not
        # None`` + ``validation_errors`` at parse time, so we drive it
        # via a synthetic doc + the write-path branch. Direct unit test
        # of the meta_dirty paired-write happens by constructing a doc
        # and invoking the branch's setter form.
        from octave_mcp.core.grammar.cst import Document

        doc = Document(name="DOC")
        doc.meta["STATUS"] = "active"  # lowercase, would be a wrong-case enum

        # Simulate the branch's paired-write (the structural assertion).
        # The actual write.py path is exercised by the integration suite;
        # this unit asserts the post-PR-2 contract on the doc node.
        canonical = "ACTIVE"
        doc.meta["STATUS"] = canonical
        doc.meta_dirty["STATUS"] = True

        assert doc.meta["STATUS"] == "ACTIVE"
        assert doc.meta_dirty.get("STATUS") is True
        # Other META keys remain clean (sibling-clean invariant).
        assert "VERSION" not in doc.meta_dirty


class TestSchemaRepairPairedWrite:
    """core/repair.py's _repair_ast_node pairs node.value mutation with node.repaired=True."""

    def test_enum_casefold_sets_repaired_true(self) -> None:
        from octave_mcp.core.constraints import ConstraintChain, EnumConstraint
        from octave_mcp.core.grammar.cst import Assignment, Document, Section
        from octave_mcp.core.repair import _apply_schema_repairs
        from octave_mcp.core.repair_log import RepairLog
        from octave_mcp.core.schema_extractor import (
            FieldDefinition,
            HolographicPattern,
            SchemaDefinition,
        )

        # Build a tiny document with one section + one assignment whose
        # value is the lower-case form of an enum value.
        node = Assignment(key="STATUS", value="active")
        section = Section(section_id="1", key="S", children=[node])
        doc = Document(name="DOC", sections=[section])

        enum_constraint = EnumConstraint(allowed_values=["ACTIVE", "PAUSED"])
        chain = ConstraintChain(constraints=[enum_constraint])
        pattern = HolographicPattern(example="ACTIVE", constraints=chain, target=None)
        field_def = FieldDefinition(name="STATUS", pattern=pattern)
        schema = SchemaDefinition(name="DOC", fields={"STATUS": field_def})

        assert node.repaired is False
        _apply_schema_repairs(doc, schema, RepairLog(repairs=[]))
        # After repair: value is canonical AND .repaired is True.
        assert node.value == "ACTIVE"
        assert node.repaired is True


# ---------------------------------------------------------------------------
# T6 tests (ADR-0006 SR2-T2 Strategy A PR-2): _apply_changes mark-touched
# integration. Every $op (MERGE/APPEND/PREPEND/DELETE) and every path shape
# (top-level KEY, META.FIELD, PARENT.CHILD, §N.KEY) must paired-write the
# correct dirty flag on the correct node. Sibling-clean invariant: only the
# touched leaf is dirty.
# ---------------------------------------------------------------------------


class TestApplyChangesTopLevelKey:
    """Top-level KEY edit marks only the touched Assignment dirty."""

    def test_top_level_set_marks_assignment_dirty(self):
        from octave_mcp.core.parser import parse
        from octave_mcp.mcp.write import WriteTool

        doc = parse("===DOC===\nA::1\nB::2\nC::3\n===END===\n")
        tool = WriteTool()
        tool._apply_changes(doc, {"B": 99})
        # Sibling-clean invariant: only B is dirty.
        keys_dirty = {n.key: n.dirty for n in doc.sections if hasattr(n, "key")}
        assert keys_dirty == {"A": False, "B": True, "C": False}

    def test_top_level_new_key_appended_dirty(self):
        from octave_mcp.core.parser import parse
        from octave_mcp.mcp.write import WriteTool

        doc = parse("===DOC===\nA::1\n===END===\n")
        tool = WriteTool()
        tool._apply_changes(doc, {"NEW": "v"})
        new_node = next(n for n in doc.sections if hasattr(n, "key") and n.key == "NEW")
        assert new_node.dirty is True


class TestApplyChangesMetaDirty:
    """META.FIELD edits mark doc.meta_dirty per key, leaving other META keys clean."""

    def test_meta_field_update_marks_meta_dirty_for_field(self):
        from octave_mcp.core.parser import parse
        from octave_mcp.mcp.write import WriteTool

        doc = parse("===DOC===\nMETA:\n  STATUS::ACTIVE\n  VERSION::1.0\n===END===\n")
        tool = WriteTool()
        tool._apply_changes(doc, {"META.STATUS": "PAUSED"})
        assert doc.meta_dirty.get("STATUS") is True
        # Sibling-clean: VERSION untouched.
        assert "VERSION" not in doc.meta_dirty

    def test_meta_delete_marks_meta_dirty(self):
        from octave_mcp.core.parser import parse
        from octave_mcp.mcp.write import WriteTool

        doc = parse("===DOC===\nMETA:\n  STATUS::ACTIVE\n  VERSION::1.0\n===END===\n")
        tool = WriteTool()
        tool._apply_changes(doc, {"META.STATUS": {"$op": "DELETE"}})
        assert "STATUS" not in doc.meta
        assert doc.meta_dirty.get("STATUS") is True

    def test_meta_dict_merge_marks_each_touched_key(self):
        from octave_mcp.core.parser import parse
        from octave_mcp.mcp.write import WriteTool

        doc = parse("===DOC===\nMETA:\n  STATUS::ACTIVE\n  VERSION::1.0\n===END===\n")
        tool = WriteTool()
        tool._apply_changes(doc, {"META": {"STATUS": "PAUSED"}})
        assert doc.meta_dirty.get("STATUS") is True
        # VERSION not in payload — stays clean.
        assert "VERSION" not in doc.meta_dirty


class TestApplyChangesBlockChild:
    """PARENT.CHILD mutation flips Block.body_dirty + child Assignment.dirty."""

    def test_block_child_update_marks_child_dirty_and_block_body_dirty(self):
        from octave_mcp.core.grammar.cst import Assignment, Block
        from octave_mcp.core.parser import parse
        from octave_mcp.mcp.write import WriteTool

        doc = parse("===DOC===\nPARENT:\n  CHILD_A::1\n  CHILD_B::2\n===END===\n")
        tool = WriteTool()
        tool._apply_changes(doc, {"PARENT.CHILD_A": 99})
        parent = next(n for n in doc.sections if isinstance(n, Block) and n.key == "PARENT")
        child_a = next(c for c in parent.children if isinstance(c, Assignment) and c.key == "CHILD_A")
        child_b = next(c for c in parent.children if isinstance(c, Assignment) and c.key == "CHILD_B")
        assert child_a.dirty is True
        # Sibling-clean inside the block.
        assert child_b.dirty is False
        # Body changed -> block.body_dirty=True (header bytes still spliceable).
        assert parent.body_dirty is True
        # The block header itself is NOT marked whole-dirty.
        assert parent.dirty is False


class TestApplyChangesSectionChild:
    "\u00a7N.KEY mutation flips Section.body_dirty + child Assignment.dirty."

    def test_section_child_update_marks_child_dirty_and_section_body_dirty(self):
        from octave_mcp.core.grammar.cst import Assignment, Section
        from octave_mcp.core.parser import parse
        from octave_mcp.mcp.write import WriteTool

        doc = parse("===DOC===\n§1::IDENTITY\n  ROLE::AGENT\n  TIER::DEEP\n===END===\n")
        tool = WriteTool()
        tool._apply_changes(doc, {"§1.ROLE": "LEAD"})
        section = next(n for n in doc.sections if isinstance(n, Section) and n.section_id == "1")
        role = next(c for c in section.children if isinstance(c, Assignment) and c.key == "ROLE")
        tier = next(c for c in section.children if isinstance(c, Assignment) and c.key == "TIER")
        assert role.dirty is True
        assert tier.dirty is False  # sibling-clean
        assert section.body_dirty is True
        assert section.dirty is False


class TestApplyChangesArrayOps:
    """APPEND/PREPEND on a top-level array Assignment marks it dirty."""

    def test_append_marks_assignment_dirty(self):
        from octave_mcp.core.grammar.cst import Assignment
        from octave_mcp.core.parser import parse
        from octave_mcp.mcp.write import WriteTool

        doc = parse("===DOC===\nITEMS::[a, b]\nOTHER::1\n===END===\n")
        tool = WriteTool()
        tool._apply_changes(doc, {"ITEMS": {"$op": "APPEND", "value": "c"}})
        items = next(n for n in doc.sections if isinstance(n, Assignment) and n.key == "ITEMS")
        other = next(n for n in doc.sections if isinstance(n, Assignment) and n.key == "OTHER")
        assert items.dirty is True
        assert other.dirty is False  # sibling-clean


class TestApplyMutations:
    """_apply_mutations marks doc.meta_dirty per touched META key."""

    def test_apply_mutations_marks_meta_dirty(self):
        from octave_mcp.core.grammar.cst import Document
        from octave_mcp.mcp.write import WriteTool

        doc = Document(name="DOC")
        doc.meta["STATUS"] = "ACTIVE"
        doc.meta["VERSION"] = "1.0"
        tool = WriteTool()
        tool._apply_mutations(doc, {"STATUS": "PAUSED"})
        assert doc.meta["STATUS"] == "PAUSED"
        assert doc.meta_dirty.get("STATUS") is True
        # VERSION not in mutations -> not marked.
        assert "VERSION" not in doc.meta_dirty


class TestNormalizeValueForAstBlockPreservation:
    """T7: bare key:dict against existing top-level Block keeps Block shape."""

    def test_bare_key_dict_against_existing_block_keeps_block_shape(self):
        from octave_mcp.core.grammar.cst import Assignment, Block
        from octave_mcp.core.parser import parse
        from octave_mcp.mcp.write import WriteTool

        # Pre-condition: PARENT is a Block with one child Assignment.
        doc = parse("===DOC===\nPARENT:\n  CHILD::v\n===END===\n")
        tool = WriteTool()
        # Apply a bare-dict change to PARENT (no $op): {"PARENT": {"CHILD": "v2"}}.
        # Expected behaviour under T7:
        #   - The PARENT Block is preserved (NOT replaced by an InlineMap).
        #   - CHILD's Assignment.value is updated to "v2".
        #   - The Block body_dirty flips True; CHILD.dirty=True.
        #   - No duplicate top-level Assignment "PARENT" is appended.
        tool._apply_changes(doc, {"PARENT": {"CHILD": "v2"}})
        # Block is still the only top-level node with key="PARENT".
        parent_nodes = [n for n in doc.sections if hasattr(n, "key") and n.key == "PARENT"]
        assert len(parent_nodes) == 1, f"expected single PARENT node, got {parent_nodes!r}"
        assert isinstance(parent_nodes[0], Block)
        # Child updated to v2.
        child = next(c for c in parent_nodes[0].children if isinstance(c, Assignment) and c.key == "CHILD")
        assert child.value == "v2"
        assert child.dirty is True
        assert parent_nodes[0].body_dirty is True

    def test_bare_key_dict_against_existing_inline_map_replaces(self):
        """When existing top-level KEY is an InlineMap-style Assignment, replace whole."""
        from octave_mcp.core.grammar.cst import Assignment, InlineMap
        from octave_mcp.core.parser import parse
        from octave_mcp.mcp.write import WriteTool

        doc = parse("===DOC===\nKEY::[CHILD::v]\n===END===\n")
        tool = WriteTool()
        # Existing node is an Assignment whose value is an InlineMap.
        tool._apply_changes(doc, {"KEY": {"CHILD": "v2"}})
        # Behaviour: whole Assignment value replaced with new InlineMap; Assignment.dirty=True.
        key_node = next(n for n in doc.sections if isinstance(n, Assignment) and n.key == "KEY")
        assert isinstance(key_node.value, InlineMap)
        assert key_node.value.pairs == {"CHILD": "v2"}
        assert key_node.dirty is True
