"""Tests for compiler module.

Tests written from contracts + RFC spec ONLY.
Must FAIL until implementation is complete.
"""

import pytest

from lattice.core.compiler import (
    assemble_compiler_prompt,
    parse_cot_output,
    extract_proposals,
    truncate_session,
    diff_rules,
)
from lattice.core.types.evidence import CotPhases
from lattice.core.types.log import LogEntry, Session
from lattice.core.types.enums import Role
from lattice.core.types.proposal import PatternType, Proposal, ProposalAction
from lattice.core.types.rule import Rule, RuleDiff


class TestAssembleCompilerPrompt:
    """Tests for assemble_compiler_prompt function."""

    # Base template with all required phase tags
    BASE_TEMPLATE = "<triage><cross_ref><synthesis><review>"

    def test_returns_string(self) -> None:
        """Output should be a string."""
        result = assemble_compiler_prompt([], [], 3000, 0, self.BASE_TEMPLATE)
        assert isinstance(result, str)

    def test_contains_triage_tag(self) -> None:
        """Output must contain <triage> tag."""
        result = assemble_compiler_prompt([], [], 3000, 0, self.BASE_TEMPLATE)
        assert "<triage>" in result

    def test_contains_cross_ref_tag(self) -> None:
        """Output must contain <cross_ref> tag."""
        result = assemble_compiler_prompt([], [], 3000, 0, self.BASE_TEMPLATE)
        assert "<cross_ref>" in result

    def test_contains_synthesis_tag(self) -> None:
        """Output must contain <synthesis> tag."""
        result = assemble_compiler_prompt([], [], 3000, 0, self.BASE_TEMPLATE)
        assert "<synthesis>" in result

    def test_contains_review_tag(self) -> None:
        """Output must contain <review> tag."""
        result = assemble_compiler_prompt([], [], 3000, 0, self.BASE_TEMPLATE)
        assert "<review>" in result

    def test_all_four_phase_tags_present(self) -> None:
        """Output must contain all four phase tags."""
        result = assemble_compiler_prompt([], [], 3000, 0, self.BASE_TEMPLATE)
        assert "<triage>" in result
        assert "<cross_ref>" in result
        assert "<synthesis>" in result
        assert "<review>" in result

    def test_interpolates_current_rules(self) -> None:
        """Current rules should be interpolated into template."""
        result = assemble_compiler_prompt(
            [], [], 3000, 0, f"<triage>Rules: {{current_rules}}{self.BASE_TEMPLATE}"
        )
        assert "Rules:" in result

    def test_interpolates_sessions(self) -> None:
        """Sessions should be interpolated into template."""
        result = assemble_compiler_prompt(
            [], [], 3000, 0, f"Sessions: {{sessions}}{self.BASE_TEMPLATE}"
        )
        assert "Sessions:" in result

    def test_with_actual_session(self) -> None:
        """Should handle actual Session objects."""
        session = Session(
            session_id="test-session",
            logs=[
                LogEntry(
                    external_id="1",
                    session_id="test-session",
                    timestamp="2026-01-01T00:00:00Z",
                    role=Role.USER,
                    content="Hello",
                )
            ],
        )
        result = assemble_compiler_prompt([session], [], 3000, 0, self.BASE_TEMPLATE)
        assert isinstance(result, str)
        assert "<triage>" in result

    def test_with_actual_rule(self) -> None:
        """Should handle actual Rule objects."""
        rule = Rule(
            file_path="conventions.md",
            title="Test Rule",
            content="Always write tests.",
            token_count=10,
        )
        result = assemble_compiler_prompt([], [rule], 3000, 0, self.BASE_TEMPLATE)
        assert isinstance(result, str)

    def test_rejects_invalid_alert_tokens(self) -> None:
        """@pre should reject alert_tokens <= 0."""
        with pytest.raises(Exception):  # deal.PreContractError
            assemble_compiler_prompt(
                [], [], 0, 0, "<triage><cross_ref><synthesis><review>"
            )

        with pytest.raises(Exception):
            assemble_compiler_prompt(
                [], [], -1, 0, "<triage><cross_ref><synthesis><review>"
            )

    def test_rejects_negative_current_token_count(self) -> None:
        """@pre should reject negative current_token_count."""
        with pytest.raises(Exception):  # deal.PreContractError
            assemble_compiler_prompt(
                [], [], 3000, -1, "<triage><cross_ref><synthesis><review>"
            )

    def test_rejects_invalid_sessions_type(self) -> None:
        """@pre should reject non-list sessions."""
        with pytest.raises(Exception):
            assemble_compiler_prompt(
                "not a list", [], 3000, 0, "<triage><cross_ref><synthesis><review>"
            )  # type: ignore


class TestParseCotOutput:
    """Tests for parse_cot_output function."""

    def test_returns_cot_phases(self) -> None:
        """Output should be CotPhases object."""
        result = parse_cot_output("")
        assert isinstance(result, CotPhases)

    def test_extracts_triage_phase(self) -> None:
        """Extract content from <triage> tag."""
        result = parse_cot_output("<triage>content here</triage>")
        assert result.triage == "content here"

    def test_extracts_cross_ref_phase(self) -> None:
        """Extract content from <cross_ref> tag."""
        result = parse_cot_output("<cross_ref>cross ref content</cross_ref>")
        assert result.cross_ref == "cross ref content"

    def test_extracts_synthesis_phase(self) -> None:
        """Extract content from <synthesis> tag."""
        result = parse_cot_output("<synthesis>synthesis content</synthesis>")
        assert result.synthesis == "synthesis content"

    def test_extracts_review_phase(self) -> None:
        """Extract content from <review> tag."""
        result = parse_cot_output("<review>review content</review>")
        assert result.review == "review content"

    def test_extracts_all_four_phases(self) -> None:
        """Extract all four phases from combined output."""
        raw = """
        <triage>triage content</triage>
        <cross_ref>cross ref content</cross_ref>
        <synthesis>synthesis content</synthesis>
        <review>review content</review>
        """
        result = parse_cot_output(raw)
        assert result.triage == "triage content"
        assert result.cross_ref == "cross ref content"
        assert result.synthesis == "synthesis content"
        assert result.review == "review content"

    def test_handles_missing_phases(self) -> None:
        """Missing phases should return empty string."""
        result = parse_cot_output("<triage>only triage</triage>")
        assert result.triage == "only triage"
        assert result.cross_ref == ""
        assert result.synthesis == ""
        assert result.review == ""

    def test_handles_empty_output(self) -> None:
        """Empty input should return empty phases."""
        result = parse_cot_output("")
        assert result.triage == ""
        assert result.cross_ref == ""
        assert result.synthesis == ""
        assert result.review == ""

    def test_tolerates_extra_whitespace(self) -> None:
        """Should handle extra whitespace in tags."""
        result = parse_cot_output("<triage>  spaced content  </triage>")
        assert result.triage.strip() == "spaced content"

    def test_handles_malformed_output(self) -> None:
        """Malformed output should not crash."""
        # Missing closing tag
        result = parse_cot_output("<triage>unclosed")
        assert isinstance(result, CotPhases)

    def test_extracts_with_nested_content(self) -> None:
        """Should handle nested brackets in content."""
        result = parse_cot_output(
            "<triage>Content with [brackets] and {braces}</triage>"
        )
        assert "[brackets]" in result.triage


class TestExtractProposals:
    """Tests for extract_proposals function."""

    def test_returns_list(self) -> None:
        """Output should be a list."""
        phases = CotPhases(triage="", cross_ref="", synthesis="", review="")
        result = extract_proposals(phases)
        assert isinstance(result, list)

    def test_empty_phases_returns_empty_list(self) -> None:
        """Empty phases should return empty list."""
        phases = CotPhases(triage="", cross_ref="", synthesis="", review="")
        result = extract_proposals(phases)
        assert result == []

    def test_returns_proposal_objects(self) -> None:
        """Each item should be a Proposal object."""
        phases = CotPhases(
            triage="",
            cross_ref="",
            synthesis="## PROPOSAL: ADD - Test\nAction: ADD",
            review="",
        )
        result = extract_proposals(phases)
        # All items should be Proposal objects
        for item in result:
            assert isinstance(item, Proposal)

    def test_zero_proposal_run_is_valid(self) -> None:
        """Zero proposals is a valid output."""
        phases = CotPhases(
            triage="Scanned sessions...",
            cross_ref="No convergent patterns found...",
            synthesis="",
            review="All rules kept...",
        )
        result = extract_proposals(phases)
        assert result == []

    def test_rejects_invalid_phases_type(self) -> None:
        """@pre should reject non-CotPhases input."""
        with pytest.raises(Exception):
            extract_proposals("not phases")  # type: ignore


class TestTruncateSession:
    """Tests for truncate_session function."""

    def test_returns_session(self) -> None:
        """Output should be a Session object."""
        session = Session(session_id="test", logs=[])
        result = truncate_session(session, max_tokens=100)
        assert isinstance(result, Session)

    def test_returns_new_session(self) -> None:
        """Should return a new Session, not modify original."""
        session = Session(session_id="test", logs=[])
        result = truncate_session(session, max_tokens=100)
        assert result is not session

    def test_empty_session_unchanged(self) -> None:
        """Empty session should pass through."""
        session = Session(session_id="test", logs=[])
        result = truncate_session(session, max_tokens=100)
        assert len(result.logs) == 0

    def test_keeps_most_recent_messages(self) -> None:
        """Should keep messages from the end."""
        session = Session(
            session_id="test",
            logs=[
                LogEntry(
                    external_id=str(i),
                    session_id="test",
                    timestamp=f"2026-01-01T0{i}:00:00Z",
                    role=Role.USER,
                    content=f"Message {i}",
                )
                for i in range(10)
            ],
        )
        result = truncate_session(session, max_tokens=100)
        # Should have fewer logs than original
        assert len(result.logs) <= 10

    def test_truncation_removes_oldest_first(self) -> None:
        """Oldest messages should be removed first when truncation is needed."""
        # Create a session with multiple logs where truncation should keep the newest
        session = Session(
            session_id="test",
            logs=[
                LogEntry(
                    external_id=f"log_{i}",
                    session_id="test",
                    timestamp=f"2026-01-01T{i:02d}:00:00Z",
                    role=Role.USER,
                    content=f"Message {i}",  # Short messages
                )
                for i in range(10)
            ],
        )
        # Use a max_tokens that will keep some but not all
        # Each "Message X" is ~10 chars = 2.5 tokens + 10 overhead = ~12 tokens
        # With max_tokens=50, should keep ~4 messages from the end
        result = truncate_session(session, max_tokens=50)

        # Should have fewer logs than original (truncation happened)
        assert len(result.logs) < len(session.logs)
        # Should still have at least one log
        assert len(result.logs) >= 1
        # The newest messages should be kept (from the end)
        # Check that the last message from original is in result
        assert any("Message 9" in log.content for log in result.logs)

    def test_rejects_zero_max_tokens(self) -> None:
        """@pre should reject max_tokens <= 0."""
        session = Session(session_id="test", logs=[])
        with pytest.raises(Exception):
            truncate_session(session, max_tokens=0)

        with pytest.raises(Exception):
            truncate_session(session, max_tokens=-1)

    def test_rejects_invalid_session_type(self) -> None:
        """@pre should reject non-Session input."""
        with pytest.raises(Exception):
            truncate_session("not a session", max_tokens=100)  # type: ignore


class TestDiffRules:
    """Tests for diff_rules function."""

    def test_returns_rule_diff(self) -> None:
        """Output should be a RuleDiff object."""
        result = diff_rules([], [])
        assert isinstance(result, RuleDiff)

    def test_empty_inputs_returns_valid_diff(self) -> None:
        """Empty rules and proposals should return valid diff."""
        result = diff_rules([], [])
        assert isinstance(result, RuleDiff)

    def test_with_rules_no_proposals(self) -> None:
        """Existing rules with no proposals should show no changes."""
        rules = [
            Rule(
                file_path="conventions.md",
                title="Test",
                content="A rule",
                token_count=10,
            )
        ]
        result = diff_rules(rules, [])
        assert isinstance(result, RuleDiff)

    def test_with_proposals_no_rules(self) -> None:
        """Proposals with no existing rules should show additions."""
        proposals = [
            Proposal(
                proposal_id="test",
                pattern=PatternType.CONVENTION,
                action=ProposalAction.ADD,
                title="New Rule",
                content="A new rule",
                evidence_session_ids=["session-1"],
            )
        ]
        result = diff_rules([], proposals)
        assert isinstance(result, RuleDiff)

    def test_rejects_invalid_rules_type(self) -> None:
        """@pre should reject non-list rules."""
        with pytest.raises(Exception):
            diff_rules("not rules", [])  # type: ignore

    def test_rejects_invalid_proposals_type(self) -> None:
        """@pre should reject non-list proposals."""
        with pytest.raises(Exception):
            diff_rules([], "not proposals")  # type: ignore

    def test_rejects_invalid_rule_objects(self) -> None:
        """@pre should reject non-Rule objects in list."""
        with pytest.raises(Exception):
            diff_rules(["not a rule"], [])  # type: ignore

    def test_rejects_invalid_proposal_objects(self) -> None:
        """@pre should reject non-Proposal objects in list."""
        with pytest.raises(Exception):
            diff_rules([], ["not a proposal"])  # type: ignore

    def test_remove_proposal_matches_existing_rule(self) -> None:
        """REMOVE proposal should match rule by title and set old_content."""
        rules = [
            Rule(
                file_path="conventions.md",
                title="Use Explicit Imports",
                content="Always use explicit imports.",
                token_count=10,
            )
        ]
        proposal = Proposal(
            proposal_id="test",
            pattern=PatternType.CONVENTION,
            action=ProposalAction.REMOVE,
            title="Use Explicit Imports",
            content="Removed for brevity.",
            evidence_session_ids=["s1"],
        )
        result = diff_rules(rules, [proposal])
        # old_content should contain matched rule content
        assert result.old_content is not None
        assert "Use Explicit Imports" in result.old_content
        assert "explicit imports" in result.old_content
        # file_path should come from matched rule
        assert result.file_path == "conventions.md"

    def test_merge_proposal_matches_existing_rule(self) -> None:
        """MERGE proposal should match rule by title and set old_content."""
        rules = [
            Rule(
                file_path="conventions.md",
                title="Snake Case",
                content="Use snake_case for variables.",
                token_count=10,
            )
        ]
        proposal = Proposal(
            proposal_id="test",
            pattern=PatternType.CONVENTION,
            action=ProposalAction.MERGE,
            title="Snake Case",
            content="Use snake_case for all identifiers.",
            evidence_session_ids=["s1"],
        )
        result = diff_rules(rules, [proposal])
        assert result.old_content is not None
        assert "snake_case" in result.old_content
        assert "Snake Case" in result.new_content

    def test_update_proposal_matches_existing_rule(self) -> None:
        """UPDATE proposal should match rule by title and set old_content."""
        rules = [
            Rule(
                file_path="preferences.md",
                title="Docstrings",
                content="Write Google-style docstrings.",
                token_count=10,
            )
        ]
        proposal = Proposal(
            proposal_id="test",
            pattern=PatternType.PREFERENCE,
            action=ProposalAction.UPDATE,
            title="Docstrings",
            content="Write NumPy-style docstrings.",
            evidence_session_ids=["s1"],
        )
        result = diff_rules(rules, [proposal])
        assert result.old_content is not None
        assert "Google-style" in result.old_content
        assert "NumPy-style" in result.new_content

    def test_add_proposal_no_match_sets_old_content_none(self) -> None:
        """ADD proposal with no existing rule should have old_content=None."""
        proposal = Proposal(
            proposal_id="test",
            pattern=PatternType.CONVENTION,
            action=ProposalAction.ADD,
            title="Brand New Rule",
            content="A brand new rule.",
            evidence_session_ids=["s1"],
        )
        result = diff_rules([], [proposal])
        assert result.old_content is None
        assert "Brand New Rule" in result.new_content
        assert result.file_path == "rules.md"  # Default

    def test_case_insensitive_title_matching(self) -> None:
        """Matching should be case-insensitive for titles."""
        rules = [
            Rule(
                file_path="conventions.md",
                title="USE SNAKE CASE",
                content="Always use snake_case.",
                token_count=10,
            )
        ]
        proposal = Proposal(
            proposal_id="test",
            pattern=PatternType.CONVENTION,
            action=ProposalAction.UPDATE,
            title="use snake case",  # Different case
            content="Updated content.",
            evidence_session_ids=["s1"],
        )
        result = diff_rules(rules, [proposal])
        # Should match despite case difference
        assert result.old_content is not None
        assert "snake_case" in result.old_content


class TestContractValidation:
    """Tests that validate @pre/@post contracts are enforced."""

    def test_assemble_compiler_prompt_rejects_invalid_types(self) -> None:
        """assemble_compiler_prompt should reject invalid types."""
        # Non-string template
        with pytest.raises(Exception):
            assemble_compiler_prompt([], [], 3000, 0, 123)  # type: ignore

    def test_parse_cot_output_rejects_non_string(self) -> None:
        """parse_cot_output should reject non-string input."""
        with pytest.raises(Exception):
            parse_cot_output(123)  # type: ignore

    def test_truncate_session_default_max_tokens(self) -> None:
        """truncate_session should work with default max_tokens."""
        session = Session(session_id="test", logs=[])
        # Default max_tokens=4000, should work
        result = truncate_session(session)
        assert isinstance(result, Session)
