# @invar:allow file_size: Single module for evolution orchestration; split after P7.
"""
Shell layer for Evolution orchestration.

This module implements:
- Input-preparation stage: query sessions, apply batch cap, truncate
- LLM-call stage: assemble prompt, call LLM, parse output, extract proposals
- Proposal workflow: write/read proposals and traces

Reference: RFC-002 §4.3 Incremental Mode
"""

from __future__ import annotations

import os
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path

from returns.result import Failure, Result, Success

from lattice.core.compiler import apply_batch_cap, truncate_session
from lattice.core.types import Session
from lattice.core.types.evidence import CompilerOutput
from lattice.core.types.log import group_logs_into_sessions
from lattice.core.types.proposal import Proposal
from lattice.shell.store import (
    count_pending_sessions,
    get_iso_timestamp,
    get_metadata,
    query_logs_since,
    set_metadata,
)


@dataclass(frozen=True)
class EvolutionConfig:
    """Configuration for evolution batch processing.

    Attributes:
        batch_cap: Maximum sessions to process per evolve run (default: 30).
        session_token_cap: Maximum tokens per session (default: 4000).

    >>> config = EvolutionConfig(batch_cap=50, session_token_cap=8000)
    >>> config.batch_cap
    50
    >>> config.session_token_cap
    8000
    """

    batch_cap: int = 30
    session_token_cap: int = 4000


DEFAULT_EVOLUTION_CONFIG = EvolutionConfig()


@dataclass(frozen=True)
class PendingSessions:
    """Result of querying pending sessions for evolution.

    Attributes:
        sessions: List of Session objects ready for evolution.
        total_pending: Total number of pending sessions (may exceed batch_cap).
        last_evolved_at: Timestamp of last successful evolution (ISO 8601).
        query_timestamp: Timestamp when this query was made (ISO 8601).

    >>> pending = PendingSessions(
    ...     sessions=[],
    ...     total_pending=0,
    ...     last_evolved_at="2026-02-17T10:00:00Z",
    ...     query_timestamp="2026-02-18T10:00:00Z",
    ... )
    >>> pending.total_pending
    0
    """

    sessions: list[Session]
    total_pending: int
    last_evolved_at: str | None
    query_timestamp: str


# @shell_complexity: Multi-condition filtering (timestamp, status, batch_cap) in single query
def query_pending_sessions(
    conn: sqlite3.Connection,
    config: EvolutionConfig = DEFAULT_EVOLUTION_CONFIG,
) -> Result[PendingSessions, str]:
    """Query sessions pending evolution with batch capping.

    This is the entry point for incremental evolution. It:
    1. Reads last_evolved_at from metadata
    2. Queries logs since last_evolved_at
    3. Groups logs into sessions
    4. Applies batch_cap (oldest sessions first)
    5. Truncates each session to session_token_cap

    Args:
        conn: SQLite connection to store.db.
        config: Evolution configuration (batch_cap, session_token_cap).

    Returns:
        Success(PendingSessions) with sessions ready for evolution.
        Failure(error_message) on database error.

    >>> import tempfile
    >>> from pathlib import Path
    >>> from lattice.shell.schema import create_store
    >>> with tempfile.TemporaryDirectory() as tmpdir:
    ...     result = create_store(Path(tmpdir) / "store.db")
    ...     conn = result.unwrap()
    ...     pending_result = query_pending_sessions(conn)
    ...     isinstance(pending_result, Success)
    True
    """
    try:
        # Get last_evolved_at timestamp
        last_evolved_result = get_metadata(conn, "last_evolved_at")
        if isinstance(last_evolved_result, Failure):
            return last_evolved_result

        last_evolved_at = last_evolved_result.unwrap()

        # Query logs since last_evolved_at
        # If no last_evolved_at (first run), use a very old timestamp to get all logs
        since_timestamp = last_evolved_at if last_evolved_at else "2000-01-01T00:00:00Z"

        logs_result = query_logs_since(conn, since_timestamp)
        if isinstance(logs_result, Failure):
            return logs_result

        logs = logs_result.unwrap()

        # Group logs into sessions
        all_sessions = group_logs_into_sessions(logs)

        # Get query timestamp (Shell: I/O for current time)
        query_timestamp = get_iso_timestamp()

        # If no sessions, return empty result
        if not all_sessions:
            return Success(
                PendingSessions(
                    sessions=[],
                    total_pending=0,
                    last_evolved_at=last_evolved_at,
                    query_timestamp=query_timestamp,
                )
            )

        # Count total pending sessions
        total_pending = len(all_sessions)

        # Apply batch_cap: take oldest sessions first (Core: pure logic)
        capped_sessions = apply_batch_cap(all_sessions, config.batch_cap)

        # Truncate each session to token cap (Core: pure logic)
        truncated_sessions = [
            truncate_session(session, config.session_token_cap)
            for session in capped_sessions
        ]

        return Success(
            PendingSessions(
                sessions=truncated_sessions,
                total_pending=total_pending,
                last_evolved_at=last_evolved_at,
                query_timestamp=query_timestamp,
            )
        )
    except (sqlite3.Error, AttributeError, TypeError) as e:
        return Failure(f"Database error: {e}")


def update_last_evolved_at(
    conn: sqlite3.Connection,
    timestamp: str,
) -> Result[None, str]:
    """Update the last_evolved_at timestamp after successful evolution.

    Per RFC-002 §4.3: "On each successful `evolve`, this timestamp is updated."

    Args:
        conn: SQLite connection to store.db.
        timestamp: ISO 8601 timestamp to set.

    Returns:
        Success(None) on success.
        Failure(error_message) on database error.

    >>> import tempfile
    >>> from pathlib import Path
    >>> from lattice.shell.schema import create_store
    >>> with tempfile.TemporaryDirectory() as tmpdir:
    ...     result = create_store(Path(tmpdir) / "store.db")
    ...     conn = result.unwrap()
    ...     update_result = update_last_evolved_at(conn, "2026-02-17T10:00:00Z")
    ...     isinstance(update_result, Success)
    True
    """
    try:
        return set_metadata(conn, "last_evolved_at", timestamp)
    except (sqlite3.Error, AttributeError, TypeError) as e:
        return Failure(f"Database error: {e}")


def get_pending_session_count(
    conn: sqlite3.Connection,
) -> Result[int, str]:
    """Get count of pending sessions awaiting evolution.

    Convenience function for `lattice status` to report
    "X sessions pending evolution".

    Args:
        conn: SQLite connection to store.db.

    Returns:
        Success(count) with number of distinct sessions since last_evolved_at.
        Failure(error_message) on database error.

    >>> import tempfile
    >>> from pathlib import Path
    >>> from lattice.shell.schema import create_store
    >>> with tempfile.TemporaryDirectory() as tmpdir:
    ...     result = create_store(Path(tmpdir) / "store.db")
    ...     conn = result.unwrap()
    ...     count_result = get_pending_session_count(conn)
    ...     isinstance(count_result, Success)
    True
    """
    try:
        # Get last_evolved_at
        last_evolved_result = get_metadata(conn, "last_evolved_at")
        if isinstance(last_evolved_result, Failure):
            return last_evolved_result

        last_evolved_at = last_evolved_result.unwrap()

        # Count pending sessions
        return count_pending_sessions(conn, last_evolved_at)
    except (sqlite3.Error, AttributeError, TypeError) as e:
        return Failure(f"Database error: {e}")


# @shell_complexity: Full-mode query (all sessions), batch cap, truncation
def query_all_sessions(
    conn: sqlite3.Connection,
    config: EvolutionConfig = DEFAULT_EVOLUTION_CONFIG,
) -> Result[PendingSessions, str]:
    """Query ALL sessions for full recompile mode.

    This is used by `lattice evolve --full` to process all sessions
    in the store, ignoring last_evolved_at.

    Args:
        conn: SQLite connection to store.db.
        config: Evolution configuration (batch_cap, session_token_cap).

    Returns:
        Success(PendingSessions) with sessions ready for evolution.
        Failure(error_message) on database error.

    >>> import tempfile
    >>> from pathlib import Path
    >>> from lattice.shell.schema import create_store
    >>> with tempfile.TemporaryDirectory() as tmpdir:
    ...     result = create_store(Path(tmpdir) / "store.db")
    ...     conn = result.unwrap()
    ...     all_result = query_all_sessions(conn)
    ...     isinstance(all_result, Success)
    True
    """
    try:
        # Query ALL logs (use a very old timestamp to get everything)
        logs_result = query_logs_since(conn, "2000-01-01T00:00:00Z")
        if isinstance(logs_result, Failure):
            return logs_result

        logs = logs_result.unwrap()

        # Group logs into sessions
        all_sessions = group_logs_into_sessions(logs)

        # Get query timestamp
        query_timestamp = get_iso_timestamp()

        # If no sessions, return empty result
        if not all_sessions:
            return Success(
                PendingSessions(
                    sessions=[],
                    total_pending=0,
                    last_evolved_at=None,
                    query_timestamp=query_timestamp,
                )
            )

        # Count total sessions
        total_pending = len(all_sessions)

        # Apply batch_cap: take oldest sessions first
        capped_sessions = apply_batch_cap(all_sessions, config.batch_cap)

        # Truncate each session to token cap
        truncated_sessions = [
            truncate_session(session, config.session_token_cap)
            for session in capped_sessions
        ]

        return Success(
            PendingSessions(
                sessions=truncated_sessions,
                total_pending=total_pending,
                last_evolved_at=None,
                query_timestamp=query_timestamp,
            )
        )
    except (sqlite3.Error, AttributeError, TypeError) as e:
        return Failure(f"Database error: {e}")


# @shell_complexity: Full evolution orchestration pipeline (query → prompt → LLM → parse → apply)
def run_evolution(
    conn: sqlite3.Connection,
    prompt_template: str,
    current_rules: list,
    current_token_count: int,
    alert_tokens: int,
    llm_config,
    evolution_config: EvolutionConfig = DEFAULT_EVOLUTION_CONFIG,
    full_mode: bool = False,
) -> Result[CompilerOutput, str]:
    """Run the evolution pipeline: LLM call + output processing.

    This implements the LLM-call stage of evolution:
    1. Query pending sessions (via query_pending_sessions or query_all_sessions)
    2. Assemble prompt (via core/compiler assemble_compiler_prompt)
    3. Call LLM (via shell/llm llm_complete)
    4. Parse CoT output (via core/compiler parse_cot_output)
    5. Extract proposals (via core/compiler extract_proposals)
    6. Update metadata (last_evolved_at, evolve_count)

    Per RFC-002 §4.3: Zero-proposal runs are valid and still update
    last_evolved_at since sessions were processed.

    Args:
        conn: SQLite connection to store.db.
        prompt_template: Template with {current_rules}, {sessions},
            {current_token_count}, {alert_tokens}, {token_warning} placeholders.
        current_rules: List of current Rule objects.
        current_token_count: Current total token count of rules.
        alert_tokens: Token threshold for aggressive consolidation warning.
        llm_config: CompilerConfig for LLM client.
        evolution_config: Evolution batch/timeout settings.
        full_mode: If True, process ALL sessions (ignore last_evolved_at).

    Returns:
        Success(CompilerOutput) with proposals and CoT phases.
        Failure(error_message) on any error.

    >>> import tempfile
    >>> from pathlib import Path
    >>> from lattice.shell.schema import create_store
    >>> with tempfile.TemporaryDirectory() as tmpdir:
    ...     result = create_store(Path(tmpdir) / "store.db")
    ...     conn = result.unwrap()
    ...     # Without sessions and with None config, returns early
    ...     # (no LLM call needed when no sessions)
    ...     output = run_evolution(
    ...         conn,
    ...         prompt_template="<triage><cross_ref><synthesis><review>",
    ...         current_rules=[],
    ...         current_token_count=0,
    ...         alert_tokens=3000,
    ...         llm_config=None,  # type: ignore
    ...     )
    ...     isinstance(output, Success)
    True
    """
    from lattice.core.compiler import (
        assemble_compiler_prompt,
        extract_proposals,
        parse_cot_output,
    )
    from lattice.core.types.evidence import CotPhases
    from lattice.shell.llm import llm_complete

    # Step 1: Query pending sessions (incremental or full mode)
    if full_mode:
        pending_result = query_all_sessions(conn, evolution_config)
    else:
        pending_result = query_pending_sessions(conn, evolution_config)
    if isinstance(pending_result, Failure):
        return pending_result

    pending = pending_result.unwrap()

    # Step 2: If no sessions, return empty result (no LLM call needed)
    if not pending.sessions:
        output = CompilerOutput(
            proposals=[],
            cot_phases=CotPhases(
                triage="No sessions to process.",
                cross_ref="",
                synthesis="",
                review="",
            ),
            trace_path="",
            sessions_processed=0,
        )
        return Success(output)

    # Step 3: Assemble prompt
    try:
        prompt = assemble_compiler_prompt(
            sessions=pending.sessions,
            current_rules=current_rules,
            alert_tokens=alert_tokens,
            current_token_count=current_token_count,
            prompt_template=prompt_template,
        )
    except Exception as e:
        return Failure(f"Failed to assemble prompt: {e}")

    # Step 4: Call LLM
    if llm_config is None:
        return Failure("LLM config is required for evolution")

    llm_result = llm_complete(llm_config, prompt)
    if isinstance(llm_result, Failure):
        return llm_result

    raw_output = llm_result.unwrap()

    # Step 5: Parse CoT output
    cot_phases = parse_cot_output(raw_output)

    # Step 6: Extract proposals
    proposals = extract_proposals(cot_phases)

    # Step 7: Update metadata
    try:
        # Update last_evolved_at to the query timestamp
        update_result = update_last_evolved_at(conn, pending.query_timestamp)
        if isinstance(update_result, Failure):
            return update_result

        # Increment evolve_count
        count_result = get_metadata(conn, "evolve_count")
        if isinstance(count_result, Failure):
            return count_result

        current_count = count_result.unwrap()
        new_count = 1 if current_count is None else int(current_count) + 1
        set_result = set_metadata(conn, "evolve_count", str(new_count))
        if isinstance(set_result, Failure):
            return set_result

    except (sqlite3.Error, ValueError) as e:
        return Failure(f"Failed to update metadata: {e}")

    # Build output
    output = CompilerOutput(
        proposals=proposals,
        cot_phases=cot_phases,
        trace_path="",  # Will be set by caller when saving trace
        sessions_processed=len(pending.sessions),
    )

    return Success(output)


def write_proposal(
    project_path: Path,
    proposals: list[Proposal],
    cot_phases,
    sessions_processed: int = 0,
) -> Result[tuple[str, str], str]:
    """Write proposals and trace to drift/ directory.

    Creates two files:
    - drift/proposals/<timestamp>_<topic>.md — Proposal details
    - drift/traces/<timestamp>.md — Full CoT trace for auditing

    Per RFC-002 §4.3: "The full CoT output (all four phases) is saved to
    drift/traces/ as an audit trail. Rule proposals are extracted from
    <synthesis> and <review> and written to drift/proposals/."

    Args:
        project_path: Path to the project root (where drift/ lives).
        proposals: List of Proposal objects to write.
        cot_phases: CotPhases object with triage, cross_ref, synthesis, review.
        sessions_processed: Number of sessions processed in this run.

    Returns:
        Success((proposal_path, trace_path)) with relative paths to files.
        Failure(error_message) on I/O error.

    >>> from pathlib import Path
    >>> import tempfile
    >>> 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 Rule",
    ...         content="Test content",
    ...         evidence_session_ids=[],
    ...     )]
    ...     phases = CotPhases(triage="t", cross_ref="c", synthesis="s", review="r")
    ...     result = write_proposal(project_path, proposals, phases)
    ...     isinstance(result, Success)
    True
    """
    try:
        lattice_dir = project_path / ".lattice"
        # Ensure directories exist
        proposals_dir = lattice_dir / "drift" / "proposals"
        traces_dir = lattice_dir / "drift" / "traces"
        proposals_dir.mkdir(parents=True, exist_ok=True)
        traces_dir.mkdir(parents=True, exist_ok=True)

        # Generate timestamp for filenames
        timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")

        # Write trace file
        trace_filename = f"{timestamp}.md"
        trace_path = traces_dir / trace_filename
        trace_content = _format_trace_content(cot_phases, sessions_processed)
        trace_path.write_text(trace_content, encoding="utf-8")

        # Generate topic from first proposal or default
        topic = "no_proposals"
        if proposals:
            # Use first proposal's title, sanitized for filename
            topic = proposals[0].title.lower().replace(" ", "_")[:40]
            topic = "".join(c if c.isalnum() or c == "_" else "" for c in topic)

        # Write proposal file
        proposal_filename = f"{timestamp}_{topic}.md"
        proposal_path = proposals_dir / proposal_filename
        proposal_content = _format_proposal_content(proposals, timestamp)
        proposal_path.write_text(proposal_content, encoding="utf-8")

        return Success(
            (
                f"drift/proposals/{proposal_filename}",
                f"drift/traces/{trace_filename}",
            )
        )
    except OSError as e:
        return Failure(f"Failed to write proposal: {e}")


def list_proposals(
    project_path: Path,
) -> Result[list[str], str]:
    """List pending proposal files in drift/proposals/.

    Returns proposal filenames sorted by timestamp (oldest first).
    Per RFC-002 §4.3: "Proposal waits in drift/proposals/ for explicit
    lattice apply" when auto_apply=false.

    Args:
        project_path: Path to the project root.

    Returns:
        Success(list of proposal filenames).
        Failure(error_message) on I/O error.

    >>> from pathlib import Path
    >>> import tempfile
    >>> with tempfile.TemporaryDirectory() as tmpdir:
    ...     project_path = Path(tmpdir)
    ...     result = list_proposals(project_path)
    ...     isinstance(result, Success)
    True
    """
    try:
        lattice_dir = project_path / ".lattice"
        proposals_dir = lattice_dir / "drift" / "proposals"
        if not proposals_dir.exists():
            return Success([])

        # List .md files, sorted by name (which includes timestamp)
        proposals = sorted(f.name for f in proposals_dir.iterdir() if f.suffix == ".md")
        return Success(proposals)
    except OSError as e:
        return Failure(f"Failed to list proposals: {e}")


def read_proposal(
    project_path: Path,
    proposal_file: str,
) -> Result[str, str]:
    """Read a proposal file's content.

    Args:
        project_path: Path to the project root.
        proposal_file: Proposal filename or relative path (e.g., "drift/proposals/foo.md").

    Returns:
        Success(proposal_content) or Failure(error_message).
    """
    try:
        lattice_dir = project_path / ".lattice"
        # Handle both bare filename and relative path
        if "/" in proposal_file or "\\" in proposal_file:
            # Already a relative path - must be under .lattice
            proposal_path = lattice_dir / proposal_file
        else:
            # Bare filename, prepend directory
            proposal_path = lattice_dir / "drift" / "proposals" / proposal_file

        if not proposal_path.exists():
            return Failure(f"Proposal not found: {proposal_file}")
        return Success(proposal_path.read_text(encoding="utf-8"))
    except OSError as e:
        return Failure(f"Failed to read proposal: {e}")


def backup_rules(project_path: Path) -> Result[str, str]:
    """Backup current rules to backups/ with timestamp.

    Per RFC-002 §4.3: "Backup current rule file to backups/, then rewrite rules/*.md."

    Args:
        project_path: Path to project root.

    Returns:
        Success(backup_path) or Failure(error_message).
        Success("") if no rules directory exists (first-time application).
    """
    try:
        from shutil import copytree

        lattice_dir = project_path / ".lattice"
        rules_dir = lattice_dir / "rules"

        # No rules directory - first time, nothing to backup
        if not rules_dir.exists():
            return Success("")

        # No rule files - nothing to backup
        rule_files = list(rules_dir.glob("*.md"))
        if not rule_files:
            return Success("")

        timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
        backups_dir = lattice_dir / "backups"
        backups_dir.mkdir(parents=True, exist_ok=True)

        backup_path = backups_dir / timestamp
        copytree(rules_dir, backup_path)
        return Success(f"backups/{timestamp}")
    except OSError as e:
        return Failure(f"Backup failed: {e}")


# @shell_complexity: Multi-step apply pipeline (backup → parse → write rules → delete proposal)
def apply_proposal(project_path: Path, proposal_file: str) -> Result[None, str]:
    """Apply a proposal: backup → write rules → delete proposal.

    Args:
        project_path: Path to project root.
        proposal_file: Proposal filename or relative path (e.g., "drift/proposals/foo.md").

    Returns:
        Success(None) or Failure(error_message).
    """
    # Read proposal content
    read_result = read_proposal(project_path, proposal_file)
    if isinstance(read_result, Failure):
        return read_result

    # Backup current rules
    backup_result = backup_rules(project_path)
    if isinstance(backup_result, Failure):
        return backup_result

    # Extract rule content from proposal and write to rules/
    try:
        proposal_content = read_result.unwrap()
        lattice_dir = project_path / ".lattice"
        rules_dir = lattice_dir / "rules"
        rules_dir.mkdir(parents=True, exist_ok=True)

        # Parse and apply
        _apply_proposal_content(rules_dir, proposal_content)

        # Delete applied proposal - handle both bare filename and relative path
        if "/" in proposal_file or "\\" in proposal_file:
            proposal_path = project_path / proposal_file
        else:
            proposal_path = lattice_dir / "drift" / "proposals" / proposal_file
        proposal_path.unlink(missing_ok=True)

        return Success(None)
    except OSError as e:
        return Failure(f"Failed to apply proposal: {e}")


# @shell_complexity: Backup discovery + file restore + metadata update
def revert_rules(
    project_path: Path,
    conn: sqlite3.Connection | None = None,
) -> Result[str, str]:
    """Restore most recent backup. Increment revert_count metadata.

    Per RFC-002 §4.3: "lattice revert: Restores the most recent backup."

    Args:
        project_path: Path to project root.
        conn: Optional SQLite connection to store.db for metadata update.

    Returns:
        Success(backup_path_restored) or Failure(error_message).
    """
    try:
        from shutil import copytree, rmtree

        lattice_dir = project_path / ".lattice"
        backups_dir = lattice_dir / "backups"
        if not backups_dir.exists():
            return Failure("No backups available")

        # Get most recent backup
        backups = sorted(backups_dir.iterdir(), reverse=True)
        if not backups:
            return Failure("No backups available")

        latest_backup = backups[0]
        rules_dir = lattice_dir / "rules"

        # Remove current rules and restore backup
        if rules_dir.exists():
            rmtree(rules_dir)
        copytree(latest_backup, rules_dir)

        # Increment revert_count if connection provided
        if conn is not None:
            from lattice.shell.store import get_metadata, set_metadata

            current_result = get_metadata(conn, "revert_count")
            if isinstance(current_result, Success):
                current_val = current_result.unwrap()
                count = int(current_val) if current_val is not None else 0
                set_result = set_metadata(conn, "revert_count", str(count + 1))
                if isinstance(set_result, Failure):
                    # Log but don't fail the revert
                    pass

        return Success(f"backups/{latest_backup.name}")
    except OSError as e:
        return Failure(f"Revert failed: {e}")


def rotate_backups(project_path: Path, keep: int = 5) -> Result[int, str]:
    """Rotate backups, keeping only latest N.

    Args:
        project_path: Path to project root.
        keep: Number of backups to keep (default 5).

    Returns:
        Success(deleted_count) or Failure(error_message).
    """
    try:
        from shutil import rmtree

        lattice_dir = project_path / ".lattice"
        backups_dir = lattice_dir / "backups"
        if not backups_dir.exists():
            return Success(0)

        backups = sorted(backups_dir.iterdir(), reverse=True)
        to_delete = backups[keep:]
        deleted = 0

        for backup in to_delete:
            rmtree(backup)
            deleted += 1

        return Success(deleted)
    except OSError as e:
        return Failure(f"Rotation failed: {e}")


# @invar:allow shell_result: Internal helper, errors caught by caller.
# @invar:allow shell_complexity: 4 action types (ADD/MERGE/REMOVE/UPDATE) in switch.
# @shell_complexity: 4 action types (ADD/MERGE/REMOVE/UPDATE) — switch-like dispatch
def _apply_proposal_content(rules_dir: Path, proposal_content: str) -> list[str]:
    """Apply proposal content to rules directory.

    Parses proposal markdown and applies ADD/MERGE/REMOVE/REWORD actions.
    Returns list of files that were modified/created.

    Args:
        rules_dir: Path to rules/ directory.
        proposal_content: Markdown content from proposal file.

    Returns:
        List of rule filenames that were affected.

    >>> from pathlib import Path
    >>> import tempfile
    >>> with tempfile.TemporaryDirectory() as tmpdir:
    ...     rules_dir = Path(tmpdir)
    ...     content = '''
    ... ## Proposal 1: Test
    ... **Action**: add
    ... **Pattern**: convention
    ... ### Content
    ... Rule text here.
    ... '''
    ...     files = _apply_proposal_content(rules_dir, content)
    ...     len(files) >= 0
    True
    """
    from lattice.core.proposal_parser import parse_proposal_markdown

    directives = parse_proposal_markdown(proposal_content)
    affected_files: list[str] = []

    for directive in directives:
        rule_path = rules_dir / directive.target_file

        if directive.action.value == "add":
            # Create new rule file
            rule_path.parent.mkdir(parents=True, exist_ok=True)
            rule_path.write_text(f"# {directive.title}\n\n{directive.content}\n")
            affected_files.append(directive.target_file)

        elif directive.action.value == "merge":
            # Append to existing rule file
            if rule_path.exists():
                existing = rule_path.read_text()
                rule_path.write_text(
                    f"{existing}\n\n## {directive.title}\n\n{directive.content}\n"
                )
            else:
                rule_path.parent.mkdir(parents=True, exist_ok=True)
                rule_path.write_text(f"# {directive.title}\n\n{directive.content}\n")
            affected_files.append(directive.target_file)

        elif directive.action.value == "remove":
            # Delete rule file
            if rule_path.exists():
                rule_path.unlink()
                affected_files.append(directive.target_file)

        elif directive.action.value == "update":
            # Rewrite rule file with new content (REWORD maps to UPDATE)
            rule_path.parent.mkdir(parents=True, exist_ok=True)
            rule_path.write_text(f"# {directive.title}\n\n{directive.content}\n")
            affected_files.append(directive.target_file)

    return affected_files


# @invar:allow shell_result: Pure formatting helper for internal use.
# @invar:allow shell_pure_logic: Formatting logic, not I/O.
def _format_trace_content(cot_phases, sessions_processed: int) -> str:
    """Format CoT phases into markdown trace content."""
    from lattice.core.types.evidence import CotPhases

    if not isinstance(cot_phases, CotPhases):
        cot_phases = CotPhases(
            triage=str(cot_phases), cross_ref="", synthesis="", review=""
        )

    ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
    return f"# Evolution Trace\n\n**Timestamp**: {ts}\n**Sessions**: {sessions_processed}\n\n## Triage\n\n{cot_phases.triage}\n\n## Cross-Reference\n\n{cot_phases.cross_ref}\n\n## Synthesis\n\n{cot_phases.synthesis}\n\n## Review\n\n{cot_phases.review}\n"


# @invar:allow shell_result: Pure formatting helper for internal use.
# @invar:allow shell_pure_logic: Formatting logic, not I/O.
def _format_proposal_content(proposals: list[Proposal], timestamp: str) -> str:
    """Format proposals into markdown content."""
    lines = [
        f"# Evolution Proposals\n\n**Generated**: {timestamp}\n**Proposals**: {len(proposals)}\n"
    ]
    for i, p in enumerate(proposals, 1):
        lines.append(
            f"\n## Proposal {i}: {p.title}\n\n**Action**: {p.action.value}\n**Pattern**: {p.pattern.value}\n\n### Content\n\n{p.content}\n\n### Evidence\n\n{', '.join(p.evidence_session_ids) if p.evidence_session_ids else 'None'}\n"
        )
    if not proposals:
        lines.append("\n## No Proposals\n\nNo rule proposals generated.\n")
    return "\n".join(lines)


# Re-export apply_batch_cap from Core for backward compatibility
__all__ = [
    "EvolutionConfig",
    "PendingSessions",
    "query_pending_sessions",
    "apply_batch_cap",
    "update_last_evolved_at",
    "get_pending_session_count",
    "run_evolution",
    "write_proposal",
    "list_proposals",
    "read_proposal",
    "backup_rules",
    "apply_proposal",
    "revert_rules",
    "rotate_backups",
    "DEFAULT_EVOLUTION_CONFIG",
]
