"""
Integration tests for evolution orchestration (Shell layer).

Tests use real SQLite databases (tmpdir), no mocking at DB boundary.
LLM calls are mocked for unit testing the pipeline logic.
Per R8 (Real Wiring): Integration tests use real implementations at database boundary.
"""

from pathlib import Path
import tempfile
from unittest.mock import patch

import pytest
from returns.result import Failure, Success

from lattice.core.compiler import apply_batch_cap
from lattice.core.types.enums import Role
from lattice.core.types.log import LogEntry, Session
from lattice.shell.schema import create_store
from lattice.shell.store import get_metadata, insert_log, set_metadata
from lattice.shell.evolution import (
    EvolutionConfig,
    PendingSessions,
    apply_proposal,
    backup_rules,
    get_pending_session_count,
    list_proposals,
    query_pending_sessions,
    read_proposal,
    revert_rules,
    rotate_backups,
    run_evolution,
    update_last_evolved_at,
    write_proposal,
)


@pytest.fixture
def store_conn():
    """Create a temporary store.db for testing."""
    with tempfile.TemporaryDirectory() as tmpdir:
        result = create_store(Path(tmpdir) / "store.db")
        assert isinstance(result, Success), f"Failed to create store: {result}"
        conn = result.unwrap()
        yield conn
        conn.close()


class TestApplyBatchCap:
    """Test apply_batch_cap pure function."""

    def test_empty_list_returns_empty(self):
        """Empty list should return empty list."""
        result = apply_batch_cap([], batch_cap=30)
        assert result == []

    def test_list_smaller_than_cap_returns_all(self):
        """List smaller than cap should return all items."""
        sessions = [Session(session_id=f"session-{i}", logs=[]) for i in range(10)]
        result = apply_batch_cap(sessions, batch_cap=30)
        assert len(result) == 10
        assert result == sessions

    def test_list_larger_than_cap_returns_first_cap_items(self):
        """List larger than cap should return first cap items (oldest)."""
        sessions = [Session(session_id=f"session-{i}", logs=[]) for i in range(50)]
        result = apply_batch_cap(sessions, batch_cap=30)
        assert len(result) == 30
        # Should be the first 30 (oldest sessions)
        assert result[0].session_id == "session-0"
        assert result[29].session_id == "session-29"

    def test_batch_cap_zero_returns_empty(self):
        """batch_cap=0 should return empty list."""
        sessions = [Session(session_id=f"session-{i}", logs=[]) for i in range(10)]
        result = apply_batch_cap(sessions, batch_cap=0)
        assert result == []

    def test_negative_cap_returns_empty(self):
        """Negative batch_cap should return empty list."""
        sessions = [Session(session_id=f"session-{i}", logs=[]) for i in range(10)]
        result = apply_batch_cap(sessions, batch_cap=-5)
        assert result == []

    def test_exact_cap_size(self):
        """List exactly equal to cap should return all items."""
        sessions = [Session(session_id=f"session-{i}", logs=[]) for i in range(30)]
        result = apply_batch_cap(sessions, batch_cap=30)
        assert len(result) == 30

    def test_preserves_order(self):
        """Should preserve input order (oldest first)."""
        sessions = [Session(session_id=f"session-{i}", logs=[]) for i in range(5)]
        result = apply_batch_cap(sessions, batch_cap=10)
        # Verify order is preserved
        for i, session in enumerate(result):
            assert session.session_id == f"session-{i}"


class TestQueryPendingSessions:
    """Test query_pending_sessions function."""

    def test_empty_database_returns_empty_sessions(self, store_conn):
        """Empty database should return empty PendingSessions."""
        result = query_pending_sessions(store_conn)
        assert isinstance(result, Success)

        pending = result.unwrap()
        assert isinstance(pending, PendingSessions)
        assert pending.sessions == []
        assert pending.total_pending == 0
        # last_evolved_at should be None on first run
        assert pending.last_evolved_at is None
        # query_timestamp should be a valid ISO timestamp
        assert pending.query_timestamp.endswith("Z")
        assert "T" in pending.query_timestamp

    def test_first_run_returns_all_sessions(self, store_conn):
        """With logs but no last_evolved_at, should return all sessions."""
        # Insert logs for multiple sessions
        insert_log(store_conn, "session-1", Role.USER, "Hello from session 1")
        insert_log(store_conn, "session-1", Role.ASSISTANT, "Response 1")
        insert_log(store_conn, "session-2", Role.USER, "Hello from session 2")
        insert_log(store_conn, "session-3", Role.USER, "Hello from session 3")

        result = query_pending_sessions(store_conn)
        assert isinstance(result, Success)

        pending = result.unwrap()
        # Should have 3 distinct sessions
        assert pending.total_pending == 3
        # last_evolved_at should be None (no previous evolution)
        assert pending.last_evolved_at is None

    def test_with_last_evolved_at_returns_only_new_sessions(self, store_conn):
        """With last_evolved_at set, should return only sessions after that timestamp."""
        # Insert logs with specific timestamps using raw SQL
        # (insert_log auto-generates timestamps, so we use direct SQL for control)
        store_conn.execute(
            "INSERT INTO logs (external_id, session_id, timestamp, role, content, metadata) "
            "VALUES (?, ?, ?, ?, ?, ?)",
            (
                "id-old-1",
                "session-old",
                "2026-02-17T10:00:00Z",
                "user",
                "Old message 1",
                "{}",
            ),
        )
        store_conn.execute(
            "INSERT INTO logs (external_id, session_id, timestamp, role, content, metadata) "
            "VALUES (?, ?, ?, ?, ?, ?)",
            (
                "id-old-2",
                "session-old",
                "2026-02-17T10:01:00Z",
                "user",
                "Old message 2",
                "{}",
            ),
        )
        store_conn.commit()

        # Set last_evolved_at to exclude the old session
        set_metadata(store_conn, "last_evolved_at", "2026-02-17T12:00:00Z")

        # Insert new session logs after the timestamp
        store_conn.execute(
            "INSERT INTO logs (external_id, session_id, timestamp, role, content, metadata) "
            "VALUES (?, ?, ?, ?, ?, ?)",
            (
                "id-new",
                "session-new",
                "2026-02-17T13:00:00Z",
                "user",
                "New message",
                "{}",
            ),
        )
        store_conn.commit()

        result = query_pending_sessions(store_conn)
        assert isinstance(result, Success)

        pending = result.unwrap()
        # Should have only 1 new session
        assert pending.total_pending == 1
        assert len(pending.sessions) == 1
        assert pending.sessions[0].session_id == "session-new"
        # Should not include session-old
        session_ids = {s.session_id for s in pending.sessions}
        assert "session-old" not in session_ids

    def test_batch_cap_limits_sessions(self, store_conn):
        """Batch cap should limit the number of sessions returned."""
        config = EvolutionConfig(batch_cap=2, session_token_cap=4000)

        # Insert more sessions than the cap
        for i in range(5):
            insert_log(store_conn, f"session-{i}", Role.USER, f"Message {i}")

        result = query_pending_sessions(store_conn, config)
        assert isinstance(result, Success)

        pending = result.unwrap()
        # total_pending should reflect all sessions
        assert pending.total_pending == 5
        # But sessions should be capped
        assert len(pending.sessions) == 2

    def test_custom_config(self, store_conn):
        """Should respect custom EvolutionConfig."""
        config = EvolutionConfig(batch_cap=5, session_token_cap=1000)

        for i in range(10):
            insert_log(store_conn, f"session-{i}", Role.USER, f"Message {i}")

        result = query_pending_sessions(store_conn, config)
        assert isinstance(result, Success)

        pending = result.unwrap()
        assert pending.total_pending == 10
        assert len(pending.sessions) == 5

    def test_returns_failure_on_invalid_connection(self):
        """Should return Failure on database errors."""
        # Pass None as connection to trigger error
        result = query_pending_sessions(None)  # type: ignore
        assert isinstance(result, Failure)

    def test_query_timestamp_is_current(self, store_conn):
        """query_timestamp should be a recent ISO 8601 timestamp."""
        import time

        before = time.time()
        result = query_pending_sessions(store_conn)
        after = time.time()

        assert isinstance(result, Success)
        pending = result.unwrap()
        # Timestamp should be valid ISO 8601 with Z suffix
        assert pending.query_timestamp.endswith("Z")
        assert "T" in pending.query_timestamp


class TestUpdateLastEvolvedAt:
    """Test update_last_evolved_at function."""

    def test_updates_metadata_key(self, store_conn):
        """Should update the last_evolved_at metadata key."""
        timestamp = "2026-02-17T10:00:00Z"
        result = update_last_evolved_at(store_conn, timestamp)

        assert isinstance(result, Success)

        # Verify the metadata was set
        get_result = get_metadata(store_conn, "last_evolved_at")
        assert isinstance(get_result, Success)
        assert get_result.unwrap() == timestamp

    def test_returns_success_on_valid_update(self, store_conn):
        """Should return Success on valid update."""
        result = update_last_evolved_at(store_conn, "2026-02-18T15:30:00Z")
        assert isinstance(result, Success)
        assert result.unwrap() is None

    def test_can_update_multiple_times(self, store_conn):
        """Should allow updating last_evolved_at multiple times."""
        # First update
        result1 = update_last_evolved_at(store_conn, "2026-02-17T10:00:00Z")
        assert isinstance(result1, Success)

        # Second update (should overwrite)
        result2 = update_last_evolved_at(store_conn, "2026-02-18T10:00:00Z")
        assert isinstance(result2, Success)

        # Verify second value stuck
        get_result = get_metadata(store_conn, "last_evolved_at")
        assert get_result.unwrap() == "2026-02-18T10:00:00Z"

    def test_returns_failure_on_invalid_connection(self):
        """Should return Failure on database errors."""
        result = update_last_evolved_at(None, "2026-02-17T10:00:00Z")  # type: ignore
        assert isinstance(result, Failure)


class TestGetPendingSessionCount:
    """Test get_pending_session_count function."""

    def test_returns_zero_when_no_pending_sessions(self, store_conn):
        """Should return 0 when no pending sessions."""
        result = get_pending_session_count(store_conn)
        assert isinstance(result, Success)
        assert result.unwrap() == 0

    def test_returns_count_of_pending_sessions(self, store_conn):
        """Should return count of distinct sessions since last_evolved_at."""
        # Insert logs for multiple sessions
        insert_log(store_conn, "session-1", Role.USER, "Message 1")
        insert_log(store_conn, "session-1", Role.ASSISTANT, "Response 1")
        insert_log(store_conn, "session-2", Role.USER, "Message 2")
        insert_log(store_conn, "session-3", Role.USER, "Message 3")

        result = get_pending_session_count(store_conn)
        assert isinstance(result, Success)
        # 3 distinct sessions
        assert result.unwrap() == 3

    def test_respects_last_evolved_at(self, store_conn):
        """Should count only sessions after last_evolved_at."""
        # Insert first batch with explicit old timestamp
        store_conn.execute(
            "INSERT INTO logs (external_id, session_id, timestamp, role, content, metadata) "
            "VALUES (?, ?, ?, ?, ?, ?)",
            (
                "id-old",
                "session-old",
                "2026-02-17T10:00:00Z",
                "user",
                "Old message",
                "{}",
            ),
        )
        store_conn.commit()

        # Set last_evolved_at
        set_metadata(store_conn, "last_evolved_at", "2026-02-17T12:00:00Z")

        # Insert new session with newer timestamp
        store_conn.execute(
            "INSERT INTO logs (external_id, session_id, timestamp, role, content, metadata) "
            "VALUES (?, ?, ?, ?, ?, ?)",
            (
                "id-new",
                "session-new",
                "2026-02-17T13:00:00Z",
                "user",
                "New message",
                "{}",
            ),
        )
        store_conn.commit()

        result = get_pending_session_count(store_conn)
        assert isinstance(result, Success)
        # Only 1 new session
        assert result.unwrap() == 1

    def test_returns_zero_after_full_evolution(self, store_conn):
        """Should return 0 after all sessions have been evolved."""
        # Insert sessions
        insert_log(store_conn, "session-1", Role.USER, "Message")
        insert_log(store_conn, "session-2", Role.USER, "Message")

        # Simulate evolution by setting last_evolved_at to future
        set_metadata(store_conn, "last_evolved_at", "2099-01-01T00:00:00Z")

        result = get_pending_session_count(store_conn)
        assert isinstance(result, Success)
        assert result.unwrap() == 0

    def test_returns_failure_on_invalid_connection(self):
        """Should return Failure on database errors."""
        result = get_pending_session_count(None)  # type: ignore
        assert isinstance(result, Failure)


class TestEvolutionConfig:
    """Test EvolutionConfig dataclass."""

    def test_default_values(self):
        """Should have default values per RFC-002."""
        config = EvolutionConfig()
        assert config.batch_cap == 30
        assert config.session_token_cap == 4000

    def test_custom_values(self):
        """Should accept custom values."""
        config = EvolutionConfig(batch_cap=50, session_token_cap=8000)
        assert config.batch_cap == 50
        assert config.session_token_cap == 8000

    def test_is_frozen(self):
        """Should be immutable (frozen dataclass)."""
        config = EvolutionConfig()
        with pytest.raises(Exception):  # FrozenInstanceError
            config.batch_cap = 100  # type: ignore


class TestPendingSessions:
    """Test PendingSessions dataclass."""

    def test_attributes(self):
        """Should have all required attributes."""
        pending = PendingSessions(
            sessions=[],
            total_pending=0,
            last_evolved_at="2026-02-17T10:00:00Z",
            query_timestamp="2026-02-18T10:00:00Z",
        )
        assert pending.sessions == []
        assert pending.total_pending == 0
        assert pending.last_evolved_at == "2026-02-17T10:00:00Z"
        assert pending.query_timestamp == "2026-02-18T10:00:00Z"

    def test_last_evolved_at_can_be_none(self):
        """last_evolved_at should allow None for first run."""
        pending = PendingSessions(
            sessions=[],
            total_pending=0,
            last_evolved_at=None,
            query_timestamp="2026-02-18T10:00:00Z",
        )
        assert pending.last_evolved_at is None

    def test_is_frozen(self):
        """Should be immutable (frozen dataclass)."""
        pending = PendingSessions(
            sessions=[],
            total_pending=0,
            last_evolved_at=None,
            query_timestamp="2026-02-18T10:00:00Z",
        )
        with pytest.raises(Exception):  # FrozenInstanceError
            pending.total_pending = 100  # type: ignore


class TestIntegrationFlow:
    """Integration tests for full evolution flow."""

    def test_full_evolution_cycle(self, store_conn):
        """Test the full evolution cycle: query, update, query again."""
        # 1. Initial query - no sessions
        result1 = query_pending_sessions(store_conn)
        pending1 = result1.unwrap()
        assert pending1.total_pending == 0
        assert pending1.last_evolved_at is None

        # 2. Insert some sessions
        insert_log(store_conn, "session-1", Role.USER, "Message 1")
        insert_log(store_conn, "session-2", Role.USER, "Message 2")
        insert_log(store_conn, "session-3", Role.USER, "Message 3")

        # 3. Query pending sessions
        result2 = query_pending_sessions(store_conn)
        pending2 = result2.unwrap()
        assert pending2.total_pending == 3
        assert len(pending2.sessions) == 3

        # 4. Simulate evolution - update last_evolved_at
        update_result = update_last_evolved_at(store_conn, pending2.query_timestamp)
        assert isinstance(update_result, Success)

        # 5. Query again - should have fewer/no pending
        result3 = query_pending_sessions(store_conn)
        pending3 = result3.unwrap()
        # No new sessions since we just evolved
        assert pending3.total_pending == 0

    def test_incremental_evolution(self, store_conn):
        """Test incremental evolution over multiple cycles."""
        config = EvolutionConfig(batch_cap=2, session_token_cap=4000)

        # Cycle 1: Insert 5 sessions
        for i in range(5):
            insert_log(store_conn, f"session-{i}", Role.USER, f"Message {i}")

        # Query with batch cap of 2
        result1 = query_pending_sessions(store_conn, config)
        pending1 = result1.unwrap()
        assert pending1.total_pending == 5
        assert len(pending1.sessions) == 2  # Only 2 returned due to cap

        # Count pending should still be 5
        count1 = get_pending_session_count(store_conn).unwrap()
        assert count1 == 5


class TestRunEvolution:
    """Test run_evolution pipeline function."""

    def test_no_sessions_returns_empty_output(self, store_conn):
        """Without sessions, should return empty CompilerOutput without LLM call."""
        result = run_evolution(
            store_conn,
            prompt_template="<triage><cross_ref><synthesis><review>",
            current_rules=[],
            current_token_count=0,
            alert_tokens=3000,
            llm_config=None,  # No LLM call needed
        )
        assert isinstance(result, Success)
        output = result.unwrap()
        assert output.proposals == []
        assert output.sessions_processed == 0
        assert "No sessions" in output.cot_phases.triage

    def test_none_llm_config_returns_failure(self, store_conn):
        """With sessions but None LLM config, should return failure."""
        # Insert a session
        insert_log(store_conn, "session-1", Role.USER, "Test message")

        result = run_evolution(
            store_conn,
            prompt_template="<triage><cross_ref><synthesis><review>",
            current_rules=[],
            current_token_count=0,
            alert_tokens=3000,
            llm_config=None,
        )
        assert isinstance(result, Failure)
        assert "LLM config is required" in result.failure()

    @patch("lattice.shell.llm.llm_complete")
    def test_successful_evolution_flow(self, mock_llm, store_conn):
        """Test full evolution flow with mocked LLM."""
        # Insert sessions
        insert_log(store_conn, "session-1", Role.USER, "User message")
        insert_log(store_conn, "session-1", Role.ASSISTANT, "Assistant response")

        # Mock LLM response with valid CoT phases
        mock_llm.return_value = Success(
            "<triage>Reviewed 1 session</triage>"
            "<cross_ref>No cross-session patterns</cross_ref>"
            "<synthesis>No new rules needed</synthesis>"
            "<review>All existing rules valid</review>"
        )

        # Create a minimal LLM config mock
        from dataclasses import dataclass

        @dataclass
        class MockConfig:
            model: str = "test-model"
            api_key_env: str = "TEST_API_KEY"
            base_url: str | None = None

        result = run_evolution(
            store_conn,
            prompt_template="<triage><cross_ref><synthesis><review>",
            current_rules=[],
            current_token_count=0,
            alert_tokens=3000,
            llm_config=MockConfig(),
        )

        assert isinstance(result, Success)
        output = result.unwrap()
        assert output.sessions_processed == 1
        assert "Reviewed 1 session" in output.cot_phases.triage

        # Verify metadata was updated
        last_evolved = get_metadata(store_conn, "last_evolved_at")
        assert isinstance(last_evolved, Success)
        assert last_evolved.unwrap() is not None

        evolve_count = get_metadata(store_conn, "evolve_count")
        assert isinstance(evolve_count, Success)
        assert evolve_count.unwrap() == "1"

    @patch("lattice.shell.llm.llm_complete")
    def test_evolution_updates_last_evolved_at(self, mock_llm, store_conn):
        """Evolution should update last_evolved_at timestamp."""
        insert_log(store_conn, "session-1", Role.USER, "Test")

        mock_llm.return_value = Success(
            "<triage>Done</triage><cross_ref></cross_ref><synthesis></synthesis><review></review>"
        )

        from dataclasses import dataclass

        @dataclass
        class MockConfig:
            model: str = "test-model"
            api_key_env: str = "TEST_API_KEY"

        result = run_evolution(
            store_conn,
            prompt_template="<triage><cross_ref><synthesis><review>",
            current_rules=[],
            current_token_count=0,
            alert_tokens=3000,
            llm_config=MockConfig(),
        )

        assert isinstance(result, Success)

        # Verify last_evolved_at was set
        last_evolved = get_metadata(store_conn, "last_evolved_at")
        assert isinstance(last_evolved, Success)
        assert last_evolved.unwrap() is not None

    @patch("lattice.shell.llm.llm_complete")
    def test_llm_failure_propagates(self, mock_llm, store_conn):
        """LLM failure should propagate as Failure."""
        insert_log(store_conn, "session-1", Role.USER, "Test")

        mock_llm.return_value = Failure("API error: rate limit")

        from dataclasses import dataclass

        @dataclass
        class MockConfig:
            model: str = "test-model"
            api_key_env: str = "TEST_API_KEY"

        result = run_evolution(
            store_conn,
            prompt_template="<triage><cross_ref><synthesis><review>",
            current_rules=[],
            current_token_count=0,
            alert_tokens=3000,
            llm_config=MockConfig(),
        )

        assert isinstance(result, Failure)
        assert "API error" in result.failure()

    def test_zero_proposal_run_is_valid(self, store_conn):
        """Zero proposals should still update metadata (RFC-002 §4.3)."""
        # No sessions means zero proposals, but we test the early return path
        result = run_evolution(
            store_conn,
            prompt_template="<triage><cross_ref><synthesis><review>",
            current_rules=[],
            current_token_count=0,
            alert_tokens=3000,
            llm_config=None,
        )

        assert isinstance(result, Success)
        output = result.unwrap()
        assert output.proposals == []
        assert output.sessions_processed == 0


class TestProposalWorkflow:
    """Test proposal lifecycle functions."""

    def test_write_proposal_creates_files(self):
        """write_proposal should create proposal and trace files."""
        from lattice.core.types.evidence import CotPhases
        from lattice.core.types.proposal import Proposal
        from lattice.core.types.enums import PatternType, ProposalAction

        with tempfile.TemporaryDirectory() as tmpdir:
            project_path = Path(tmpdir)
            phases = CotPhases(
                triage="Analyzed 2 sessions",
                cross_ref="Found common pattern",
                synthesis="Generated proposal",
                review="Verified",
            )
            proposals = [
                Proposal(
                    proposal_id="test-1",
                    pattern=PatternType.CONVENTION,
                    action=ProposalAction.ADD,
                    title="Use Explicit Imports",
                    content="Always use explicit imports.",
                    evidence_session_ids=["session-1", "session-2"],
                )
            ]

            result = write_proposal(
                project_path, proposals, phases, sessions_processed=2
            )
            assert isinstance(result, Success)

            proposal_path, trace_path = result.unwrap()
            assert "drift/proposals/" in proposal_path
            assert "drift/traces/" in trace_path

            # Verify files were created (using .lattice subdirectory)
            assert (project_path / ".lattice" / proposal_path).exists()
            assert (project_path / ".lattice" / trace_path).exists()

            # Verify content
            proposal_content = (project_path / ".lattice" / proposal_path).read_text()
            assert "Use Explicit Imports" in proposal_content
            assert "add" in proposal_content.lower()

            trace_content = (project_path / ".lattice" / trace_path).read_text()
            assert "Analyzed 2 sessions" in trace_content

    def test_write_proposal_empty_proposals(self):
        """write_proposal should handle empty proposals list."""
        from lattice.core.types.evidence import CotPhases

        with tempfile.TemporaryDirectory() as tmpdir:
            project_path = Path(tmpdir)
            phases = CotPhases(triage="t", cross_ref="c", synthesis="s", review="r")

            result = write_proposal(project_path, [], phases, sessions_processed=0)
            assert isinstance(result, Success)

            proposal_path, trace_path = result.unwrap()
            proposal_content = (project_path / ".lattice" / proposal_path).read_text()
            assert "No Proposals" in proposal_content

    def test_list_proposals_empty(self):
        """list_proposals should return empty list when no proposals."""
        with tempfile.TemporaryDirectory() as tmpdir:
            project_path = Path(tmpdir)
            result = list_proposals(project_path)
            assert isinstance(result, Success)
            assert result.unwrap() == []

    def test_list_proposals_returns_sorted(self):
        """list_proposals should return sorted proposal filenames."""
        import time
        from lattice.core.types.evidence import CotPhases

        with tempfile.TemporaryDirectory() as tmpdir:
            project_path = Path(tmpdir)

            # Create multiple proposals with different timestamps
            phases = CotPhases(triage="t", cross_ref="c", synthesis="s", review="r")
            write_proposal(project_path, [], phases)
            time.sleep(1.1)  # Ensure different timestamp (includes seconds)
            write_proposal(project_path, [], phases)

            result = list_proposals(project_path)
            assert isinstance(result, Success)
            proposals = result.unwrap()
            assert len(proposals) >= 2
            # Should be sorted (oldest first since filenames have timestamp)
            assert proposals == sorted(proposals)

    def test_read_proposal_success(self):
        """read_proposal should return proposal content."""
        from lattice.core.types.evidence import CotPhases
        from lattice.core.types.proposal import Proposal
        from lattice.core.types.enums import PatternType, ProposalAction

        with tempfile.TemporaryDirectory() as tmpdir:
            project_path = Path(tmpdir)
            proposals = [
                Proposal(
                    proposal_id="test",
                    pattern=PatternType.CONVENTION,
                    action=ProposalAction.ADD,
                    title="Test Proposal",
                    content="Test content here",
                    evidence_session_ids=[],
                )
            ]
            phases = CotPhases(triage="t", cross_ref="c", synthesis="s", review="r")

            write_result = write_proposal(project_path, proposals, phases)
            proposal_path, _ = write_result.unwrap()
            # Extract just the filename
            proposal_file = Path(proposal_path).name

            read_result = read_proposal(project_path, proposal_file)
            assert isinstance(read_result, Success)
            content = read_result.unwrap()
            assert "Test Proposal" in content
            assert "Test content here" in content

    def test_read_proposal_not_found(self):
        """read_proposal should return Failure for non-existent file."""
        with tempfile.TemporaryDirectory() as tmpdir:
            project_path = Path(tmpdir)
            result = read_proposal(project_path, "nonexistent.md")
            assert isinstance(result, Failure)
            assert "not found" in result.failure()


class TestBackupRevert:
    """Test backup and revert operations."""

    def test_backup_rules_creates_backup(self):
        """backup_rules should create timestamped backup."""
        with tempfile.TemporaryDirectory() as tmpdir:
            project_path = Path(tmpdir)
            lattice_dir = project_path / ".lattice"
            rules_dir = lattice_dir / "rules"
            rules_dir.mkdir(parents=True)
            (rules_dir / "test.md").write_text("# Test Rule\n")

            result = backup_rules(project_path)
            assert isinstance(result, Success)

            backup_path = result.unwrap()
            assert "backups/" in backup_path
            assert (lattice_dir / backup_path).exists()
            assert (lattice_dir / backup_path / "test.md").exists()

    def test_backup_rules_no_rules_dir(self):
        """backup_rules should return Success("") when no rules directory."""
        with tempfile.TemporaryDirectory() as tmpdir:
            project_path = Path(tmpdir)
            result = backup_rules(project_path)
            # Now returns Success("") for first-time application
            assert isinstance(result, Success)
            assert result.unwrap() == ""

    def test_revert_rules_restores_backup(self):
        """revert_rules should restore most recent backup."""
        with tempfile.TemporaryDirectory() as tmpdir:
            project_path = Path(tmpdir)
            lattice_dir = project_path / ".lattice"

            # Create initial rules and backup
            rules_dir = lattice_dir / "rules"
            rules_dir.mkdir(parents=True)
            (rules_dir / "original.md").write_text("Original content\n")
            backup_rules(project_path)

            # Modify rules
            (rules_dir / "original.md").write_text("Modified content\n")

            # Revert
            result = revert_rules(project_path)
            assert isinstance(result, Success)

            # Verify restored
            content = (rules_dir / "original.md").read_text()
            assert content == "Original content\n"

    def test_revert_rules_no_backups(self):
        """revert_rules should fail when no backups exist."""
        with tempfile.TemporaryDirectory() as tmpdir:
            project_path = Path(tmpdir)
            result = revert_rules(project_path)
            assert isinstance(result, Failure)
            assert "No backups" in result.failure()

    def test_revert_rules_increments_revert_count(self):
        """revert_rules should increment revert_count metadata when conn provided."""
        from lattice.shell.schema import create_store
        from lattice.shell.store import get_metadata

        with tempfile.TemporaryDirectory() as tmpdir:
            project_path = Path(tmpdir)
            lattice_dir = project_path / ".lattice"
            lattice_dir.mkdir(parents=True)

            # Create store.db
            conn = create_store(lattice_dir / "store.db").unwrap()

            # Create initial rules and backup
            rules_dir = lattice_dir / "rules"
            rules_dir.mkdir()
            (rules_dir / "test.md").write_text("Original\n")
            backup_rules(project_path)

            # Modify and revert
            (rules_dir / "test.md").write_text("Modified\n")
            result = revert_rules(project_path, conn)
            assert isinstance(result, Success)

            # Check revert_count incremented
            count_result = get_metadata(conn, "revert_count")
            assert isinstance(count_result, Success)
            assert count_result.unwrap() == "1"

            conn.close()

    def test_rotate_backups_keeps_latest(self):
        """rotate_backups should keep only N most recent backups."""
        with tempfile.TemporaryDirectory() as tmpdir:
            project_path = Path(tmpdir)
            lattice_dir = project_path / ".lattice"
            backups_dir = lattice_dir / "backups"
            backups_dir.mkdir(parents=True)

            # Create 5 backup directories manually with distinct names
            for i in range(5):
                (backups_dir / f"2026021{8}_06000{i}").mkdir()

            # Rotate to keep only 3
            result = rotate_backups(project_path, keep=3)
            assert isinstance(result, Success)
            assert result.unwrap() == 2  # Deleted 2

            # Verify only 3 remain
            assert len(list(backups_dir.iterdir())) == 3

    def test_rotate_backups_empty(self):
        """rotate_backups should return 0 when no backups."""
        with tempfile.TemporaryDirectory() as tmpdir:
            project_path = Path(tmpdir)
            result = rotate_backups(project_path, keep=3)
            assert isinstance(result, Success)
            assert result.unwrap() == 0

    def test_apply_proposal_workflow(self):
        """apply_proposal should backup, apply, and delete proposal."""
        from lattice.core.types.evidence import CotPhases
        from lattice.core.types.proposal import Proposal
        from lattice.core.types.enums import PatternType, ProposalAction

        with tempfile.TemporaryDirectory() as tmpdir:
            project_path = Path(tmpdir)
            lattice_dir = project_path / ".lattice"

            # Create rules dir
            rules_dir = lattice_dir / "rules"
            rules_dir.mkdir(parents=True)
            (rules_dir / "existing.md").write_text("# Existing\n")

            # Create proposal
            proposals = [
                Proposal(
                    proposal_id="test",
                    pattern=PatternType.CONVENTION,
                    action=ProposalAction.ADD,
                    title="New Rule",
                    content="New rule content",
                    evidence_session_ids=[],
                )
            ]
            phases = CotPhases(triage="t", cross_ref="c", synthesis="s", review="r")
            write_result = write_proposal(project_path, proposals, phases)
            proposal_file = Path(write_result.unwrap()[0]).name

            # Apply
            result = apply_proposal(project_path, proposal_file)
            assert isinstance(result, Success)

            # Verify backup was created
            backups_dir = lattice_dir / "backups"
            assert backups_dir.exists()
            assert len(list(backups_dir.iterdir())) == 1

            # Verify proposal was deleted
            proposal_path = lattice_dir / "drift" / "proposals" / proposal_file
            assert not proposal_path.exists()

            # Verify new rule file was written
            new_rule_path = rules_dir / "new_rule.md"
            assert new_rule_path.exists()
            content = new_rule_path.read_text()
            assert "New Rule" in content
            assert "New rule content" in content


class TestApplyProposalContent:
    """Test _apply_proposal_content for all action branches."""

    def test_merge_creates_new_file_if_not_exists(self):
        """MERGE on non-existent file should create new file."""
        from lattice.core.proposal_parser import ProposalDirective
        from lattice.core.types.enums import ProposalAction
        from lattice.shell.evolution import _apply_proposal_content

        with tempfile.TemporaryDirectory() as tmpdir:
            rules_dir = Path(tmpdir)
            content = """## Proposal 1: Merged Rule

**Action**: merge
**Pattern**: convention

### Content

Merged content here.
"""
            affected = _apply_proposal_content(rules_dir, content)
            assert "merged_rule.md" in affected

            # Verify file was created
            rule_path = rules_dir / "merged_rule.md"
            assert rule_path.exists()
            file_content = rule_path.read_text()
            assert "Merged Rule" in file_content
            assert "Merged content here" in file_content

    def test_merge_appends_to_existing_file(self):
        """MERGE on existing file should append content."""
        from lattice.core.types.enums import ProposalAction
        from lattice.shell.evolution import _apply_proposal_content

        with tempfile.TemporaryDirectory() as tmpdir:
            rules_dir = Path(tmpdir)
            # Create existing rule file
            existing = rules_dir / "existing_rule.md"
            existing.write_text("# Existing Rule\n\nOriginal content.\n")

            content = """## Proposal 1: Existing Rule

**Action**: merge
**Pattern**: convention

### Content

Additional merged content.
"""
            affected = _apply_proposal_content(rules_dir, content)
            assert "existing_rule.md" in affected

            # Verify content was appended
            file_content = existing.read_text()
            assert "Original content" in file_content
            assert "Existing Rule" in file_content
            assert "Additional merged content" in file_content

    def test_remove_deletes_existing_file(self):
        """REMOVE should delete existing rule file."""
        from lattice.shell.evolution import _apply_proposal_content

        with tempfile.TemporaryDirectory() as tmpdir:
            rules_dir = Path(tmpdir)
            # Create file to remove
            to_remove = rules_dir / "obsolete_rule.md"
            to_remove.write_text("# Obsolete Rule\n\nTo be removed.\n")
            assert to_remove.exists()

            content = """## Proposal 1: Obsolete Rule

**Action**: remove
**Pattern**: convention

### Content

No longer needed.
"""
            affected = _apply_proposal_content(rules_dir, content)
            assert "obsolete_rule.md" in affected

            # Verify file was deleted
            assert not to_remove.exists()

    def test_remove_missing_file_no_error(self):
        """REMOVE on non-existent file should be no-op."""
        from lattice.shell.evolution import _apply_proposal_content

        with tempfile.TemporaryDirectory() as tmpdir:
            rules_dir = Path(tmpdir)
            # No file exists

            content = """## Proposal 1: Nonexistent

**Action**: remove
**Pattern**: convention

### Content

Nothing to remove.
"""
            affected = _apply_proposal_content(rules_dir, content)
            # Should not be in affected list since file didn't exist
            assert "nonexistent.md" not in affected

    def test_update_creates_new_file(self):
        """UPDATE should create file if it doesn't exist."""
        from lattice.shell.evolution import _apply_proposal_content

        with tempfile.TemporaryDirectory() as tmpdir:
            rules_dir = Path(tmpdir)

            content = """## Proposal 1: Updated Rule

**Action**: update
**Pattern**: preference

### Content

Completely new content.
"""
            affected = _apply_proposal_content(rules_dir, content)
            assert "updated_rule.md" in affected

            # Verify file was created
            rule_path = rules_dir / "updated_rule.md"
            assert rule_path.exists()
            file_content = rule_path.read_text()
            assert "Updated Rule" in file_content
            assert "Completely new content" in file_content

    def test_update_rewrites_existing_file(self):
        """UPDATE should completely rewrite existing file."""
        from lattice.shell.evolution import _apply_proposal_content

        with tempfile.TemporaryDirectory() as tmpdir:
            rules_dir = Path(tmpdir)
            # Create existing file with old content
            existing = rules_dir / "rewrite_rule.md"
            existing.write_text("# Old Title\n\nOld content to be replaced.\n")
            assert "Old content" in existing.read_text()

            content = """## Proposal 1: Rewrite Rule

**Action**: update
**Pattern**: convention

### Content

New content replaces old.
"""
            affected = _apply_proposal_content(rules_dir, content)
            assert "rewrite_rule.md" in affected

            # Verify file was completely rewritten
            file_content = existing.read_text()
            assert "New content replaces old" in file_content
            assert "Old content to be replaced" not in file_content


class TestEvolutionEndToEnd:
    """End-to-end integration tests for evolution pipeline."""

    def test_full_evolution_cycle_with_proposals(self):
        """Test complete evolution cycle: insert logs → evolve → write proposals → apply."""
        from lattice.core.types.evidence import CotPhases
        from lattice.core.types.proposal import Proposal
        from lattice.core.types.enums import PatternType, ProposalAction
        from lattice.shell.schema import create_store
        from unittest.mock import patch

        with tempfile.TemporaryDirectory() as tmpdir:
            project_path = Path(tmpdir)
            lattice_dir = project_path / ".lattice"

            # Create store
            result = create_store(lattice_dir / "store.db")
            conn = result.unwrap()

            # Insert logs
            insert_log(conn, "session-1", Role.USER, "Always use explicit imports")
            insert_log(conn, "session-1", Role.ASSISTANT, "I'll use explicit imports")
            insert_log(conn, "session-2", Role.USER, "Define types explicitly")
            insert_log(conn, "session-2", Role.ASSISTANT, "Using explicit types")

            # Setup project structure
            rules_dir = lattice_dir / "rules"
            rules_dir.mkdir(parents=True)
            (rules_dir / "conventions.md").write_text(
                "# Conventions\n\nExisting rules.\n"
            )

            # Mock LLM
            from dataclasses import dataclass

            @dataclass
            class MockConfig:
                model: str = "test"
                api_key_env: str = "TEST"

            mock_response = Success(
                "<triage>Found patterns</triage>"
                "<cross_ref>Common: explicit imports</cross_ref>"
                "<synthesis>Propose new rule</synthesis>"
                "<review>Keep existing</review>"
            )

            with patch("lattice.shell.llm.llm_complete", return_value=mock_response):
                # Run evolution
                evolution_result = run_evolution(
                    conn,
                    prompt_template="<triage><cross_ref><synthesis><review>",
                    current_rules=[],
                    current_token_count=0,
                    alert_tokens=3000,
                    llm_config=MockConfig(),
                )

            assert isinstance(evolution_result, Success)
            output = evolution_result.unwrap()
            assert output.sessions_processed == 2  # 2 sessions

            # Verify metadata updated
            evolve_count = get_metadata(conn, "evolve_count")
            assert isinstance(evolve_count, Success)
            assert evolve_count.unwrap() == "1"

            # Write proposals
            proposals = [
                Proposal(
                    proposal_id="explicit_imports",
                    pattern=PatternType.CONVENTION,
                    action=ProposalAction.ADD,
                    title="Use Explicit Imports",
                    content="Always use explicit imports instead of wildcards.",
                    evidence_session_ids=["session-1", "session-2"],
                )
            ]
            phases = CotPhases(triage="Done", cross_ref="", synthesis="", review="")
            write_result = write_proposal(
                project_path, proposals, phases, sessions_processed=2
            )
            assert isinstance(write_result, Success)

            # List proposals
            list_result = list_proposals(project_path)
            assert isinstance(list_result, Success)
            assert len(list_result.unwrap()) == 1

            # Apply proposal
            proposal_file = Path(write_result.unwrap()[0]).name
            apply_result = apply_proposal(project_path, proposal_file)
            assert isinstance(apply_result, Success)

            # Verify backup created
            backups_dir = lattice_dir / "backups"
            assert backups_dir.exists()
            assert len(list(backups_dir.iterdir())) == 1

            # Rotate backups
            rotate_result = rotate_backups(project_path, keep=5)
            assert isinstance(rotate_result, Success)

            conn.close()

    def test_evolution_revert_cycle(self):
        """Test backup and revert cycle."""
        with tempfile.TemporaryDirectory() as tmpdir:
            project_path = Path(tmpdir)
            lattice_dir = project_path / ".lattice"

            # Create initial rules
            rules_dir = lattice_dir / "rules"
            rules_dir.mkdir(parents=True)
            (rules_dir / "original.md").write_text("# Original\nOriginal content.\n")

            # Backup
            backup_result = backup_rules(project_path)
            assert isinstance(backup_result, Success)

            # Modify rules
            (rules_dir / "original.md").write_text("# Modified\nModified content.\n")

            # Revert
            revert_result = revert_rules(project_path)
            assert isinstance(revert_result, Success)

            # Verify restored
            content = (rules_dir / "original.md").read_text()
            assert "Original content" in content

    def test_incremental_evolution_respects_timestamp(self, store_conn):
        """Test that incremental evolution query respects last_evolved_at."""
        # Test query_pending_sessions behavior instead of full evolution
        # This isolates the incremental behavior

        # Insert first session with explicit timestamp
        store_conn.execute(
            "INSERT INTO logs (external_id, session_id, timestamp, role, content, metadata) "
            "VALUES (?, ?, ?, ?, ?, ?)",
            (
                "id-1",
                "session-1",
                "2026-02-17T10:00:00Z",
                "user",
                "First message",
                "{}",
            ),
        )
        store_conn.commit()

        # Query first session - no last_evolved_at
        result1 = query_pending_sessions(store_conn)
        assert isinstance(result1, Success)
        assert result1.unwrap().total_pending == 1

        # Set last_evolved_at to a specific timestamp
        set_metadata(store_conn, "last_evolved_at", "2026-02-17T11:00:00Z")

        # Insert more sessions with later timestamps
        store_conn.execute(
            "INSERT INTO logs (external_id, session_id, timestamp, role, content, metadata) "
            "VALUES (?, ?, ?, ?, ?, ?)",
            (
                "id-2",
                "session-2",
                "2026-02-17T12:00:00Z",
                "user",
                "Second message",
                "{}",
            ),
        )
        store_conn.execute(
            "INSERT INTO logs (external_id, session_id, timestamp, role, content, metadata) "
            "VALUES (?, ?, ?, ?, ?, ?)",
            (
                "id-3",
                "session-3",
                "2026-02-17T13:00:00Z",
                "user",
                "Third message",
                "{}",
            ),
        )
        store_conn.commit()

        # Query should only return new sessions
        result2 = query_pending_sessions(store_conn)
        assert isinstance(result2, Success)
        pending = result2.unwrap()
        assert pending.total_pending == 2  # Only 2 new sessions
        assert len(pending.sessions) == 2
        session_ids = {s.session_id for s in pending.sessions}
        assert "session-2" in session_ids
        assert "session-3" in session_ids
        assert "session-1" not in session_ids  # Old session not included
