"""
Shell layer for Global Evolution orchestration.

This module implements the Global Compiler:
- Phase 1: Scan rules, traces from all projects, identify candidate patterns
- Phase 2: Verify candidates with evidence from project stores, generate proposals

Pure logic (parsing, formatting) is delegated to core/global_compiler.py.

Reference: RFC-002 §4.4 Global Evolution
"""

# @invar:allow file_size: Global evolution module - single file for Phases 1 & 2 orchestration
from __future__ import annotations

# @invar:allow forbidden_import: importlib.resources needed for package data access
import importlib.resources
import math
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.global_compiler import (
    GlobalCandidatePattern,
    GlobalProposal,
    assemble_global_phase1_prompt,
    assemble_global_phase2_prompt,
    cosine_similarity,
    extract_cross_ref_content,
    extract_sessions_count,
    extract_synthesis_content,
    extract_trace_preview,
    extract_triage_content,
    parse_candidate_patterns,
    parse_global_proposals,
)
from lattice.core.types.config import CompilerConfig, EmbeddingConfig
from lattice.core.types.evidence import ProjectRegistration
from lattice.shell.projects import load_projects
from lattice.shell.config import resolve_global_dir


# @shell_orchestration: Path computation uses Path.home() which reads environment (HOME/USERPROFILE), cannot be pure
# @invar:allow shell_result: Returns Path directly with fallback (no failure case)
def _get_global_path() -> Path:
    """Get the global lattice directory path (~/.config/lattice/)."""
    result = resolve_global_dir()
    if isinstance(result, Success):
        return result.unwrap()
    # Fallback (should not happen, but defensive)
    return Path.home() / ".config" / "lattice"


@dataclass(frozen=True)
class ProjectRules:
    """Rules collected from a single project.

    Attributes:
        project_name: Name of the project.
        project_path: Path to the project root.
        rules_content: Concatenated content of rules/*.md files.
        rule_files: List of rule file names.
        promoted_count: Number of rules with <!-- lattice:promoted --> marker.

    >>> rules = ProjectRules(
    ...     project_name="test",
    ...     project_path="/path/to/project",
    ...     rules_content="# Rule 1\\n\\nContent",
    ...     rule_files=["conventions.md"],
    ...     promoted_count=0,
    ... )
    >>> rules.project_name
    'test'
    """

    project_name: str
    project_path: str
    rules_content: str
    rule_files: list[str]
    promoted_count: int


@dataclass(frozen=True)
class ProjectTrace:
    """Most recent evolution trace from a project.

    Attributes:
        project_name: Name of the project.
        trace_content: Full content of the trace file.
        trace_timestamp: Timestamp of the trace file.
        sessions_processed: Number of sessions processed in this trace.

    >>> trace = ProjectTrace(
    ...     project_name="test",
    ...     trace_content="# Evolution Trace\\n...",
    ...     trace_timestamp="2026-02-17T10:00:00Z",
    ...     sessions_processed=5,
    ... )
    >>> trace.sessions_processed
    5
    """

    project_name: str
    trace_content: str
    trace_timestamp: str
    sessions_processed: int


@dataclass(frozen=True)
class EmbeddingCluster:
    """A cluster of similar content identified by embedding similarity.

    Attributes:
        cluster_id: Unique identifier for the cluster.
        projects: List of project names in this cluster.
        similarity_score: Average cosine similarity within cluster.
        content_preview: Preview of the clustered content.

    >>> cluster = EmbeddingCluster(
    ...     cluster_id="cluster-1",
    ...     projects=["proj-a", "proj-b"],
    ...     similarity_score=0.85,
    ...     content_preview="Type hints...",
    ... )
    >>> cluster.similarity_score
    0.85
    """

    cluster_id: str
    projects: list[str]
    similarity_score: float
    content_preview: str


@dataclass(frozen=True)
class GlobalPhase1Output:
    """Output from Global Compiler Phase 1.

    Attributes:
        candidates: List of CandidatePattern objects.
        triage_content: Raw triage phase content.
        cot_output: Full CoT output from LLM.

    >>> output = GlobalPhase1Output(
    ...     candidates=[],
    ...     triage_content="Triage output...",
    ...     cot_output="Full CoT...",
    ... )
    >>> len(output.candidates)
    0
    """

    candidates: list[GlobalCandidatePattern]
    triage_content: str
    cot_output: str


# @shell_complexity: File I/O per project (exist check, glob, read, promoted marker check)
def scan_project_rules(
    projects: list[ProjectRegistration],
) -> Result[list[ProjectRules], str]:
    """Scan rules/*.md from all registered projects.

    Excludes rules marked with <!-- lattice:promoted --> marker.
    Per RFC-002 §4.4: "Promoted rules are excluded from project instinct assembly."

    Args:
        projects: List of registered projects.

    Returns:
        Success(list of ProjectRules) for each project with rules.
        Failure(error_message) on I/O error.

    >>> from lattice.core.types.evidence import ProjectRegistration
    >>> projects = [ProjectRegistration(path="/nonexistent", name="test", registered_at="")]
    >>> result = scan_project_rules(projects)
    >>> isinstance(result, Success)
    True
    """
    all_rules: list[ProjectRules] = []

    for proj in projects:
        proj_path = Path(proj.path)
        rules_dir = proj_path / ".lattice" / "rules"

        if not rules_dir.exists():
            # Skip projects without rules
            all_rules.append(
                ProjectRules(
                    project_name=proj.name,
                    project_path=proj.path,
                    rules_content="(No rules directory)",
                    rule_files=[],
                    promoted_count=0,
                )
            )
            continue

        try:
            rule_files = sorted(rules_dir.glob("*.md"))
            contents: list[str] = []
            file_names: list[str] = []
            promoted_count = 0

            for rule_file in rule_files:
                content = rule_file.read_text(encoding="utf-8")

                # Check for promoted marker
                if "<!-- lattice:promoted -->" in content:
                    promoted_count += 1
                    # Skip promoted rules from assembly
                    continue

                contents.append(f"### {rule_file.name}\n\n{content}")
                file_names.append(rule_file.name)

            all_rules.append(
                ProjectRules(
                    project_name=proj.name,
                    project_path=proj.path,
                    rules_content="\n\n".join(contents) if contents else "(No rules)",
                    rule_files=file_names,
                    promoted_count=promoted_count,
                )
            )
        except OSError as e:
            return Failure(f"Failed to read rules for {proj.name}: {e}")

    return Success(all_rules)


# @shell_complexity: File I/O per project (exist check, sorted glob, stat, read)
def scan_project_traces(
    projects: list[ProjectRegistration],
) -> Result[list[ProjectTrace], str]:
    """Scan most recent drift/traces/ from each project.

    Per RFC-002 §4.4: "Phase 1 reads drift/traces/ rather than raw project logs.
    Traces are structured CoT outputs from project-level Compiler runs."

    Args:
        projects: List of registered projects.

    Returns:
        Success(list of ProjectTrace) with most recent trace per project.
        Failure(error_message) on I/O error.

    >>> from lattice.core.types.evidence import ProjectRegistration
    >>> projects = [ProjectRegistration(path="/nonexistent", name="test", registered_at="")]
    >>> result = scan_project_traces(projects)
    >>> isinstance(result, Success)
    True
    """
    all_traces: list[ProjectTrace] = []

    for proj in projects:
        proj_path = Path(proj.path)
        traces_dir = proj_path / "drift" / "traces"

        if not traces_dir.exists():
            # Skip projects without traces
            all_traces.append(
                ProjectTrace(
                    project_name=proj.name,
                    trace_content="(No traces directory)",
                    trace_timestamp="",
                    sessions_processed=0,
                )
            )
            continue

        try:
            trace_files = sorted(
                traces_dir.glob("*.md"),
                key=lambda f: f.stat().st_mtime,
                reverse=True,
            )

            if not trace_files:
                all_traces.append(
                    ProjectTrace(
                        project_name=proj.name,
                        trace_content="(No traces)",
                        trace_timestamp="",
                        sessions_processed=0,
                    )
                )
                continue

            # Read most recent trace
            recent_trace = trace_files[0]
            content = recent_trace.read_text(encoding="utf-8")
            timestamp = datetime.fromtimestamp(
                recent_trace.stat().st_mtime, tz=timezone.utc
            ).strftime("%Y-%m-%dT%H:%M:%SZ")

            # Extract sessions_processed from trace content (using Core function)
            sessions_processed = extract_sessions_count(content)

            all_traces.append(
                ProjectTrace(
                    project_name=proj.name,
                    trace_content=content,
                    trace_timestamp=timestamp,
                    sessions_processed=sessions_processed,
                )
            )
        except OSError as e:
            return Failure(f"Failed to read traces for {proj.name}: {e}")

    return Success(all_traces)


# @shell_complexity: Embedding API calls per project + pairwise similarity comparison
def compute_embedding_clusters(
    project_rules: list[ProjectRules],
    embedding_config: EmbeddingConfig | None,
    threshold: float = 0.8,
) -> Result[list[EmbeddingCluster], str]:
    """Optionally compute embedding clusters for similarity pre-screening.

    Per RFC-002 §4.4: "if embedding configured: similarity pre-screening
    (cosine > 0.8) to cluster candidates."

    Args:
        project_rules: List of ProjectRules to cluster.
        embedding_config: EmbeddingConfig or None.
        threshold: Cosine similarity threshold for clustering.

    Returns:
        Success(list of EmbeddingCluster) if embedding configured.
        Success([]) if no embedding config (skip pre-screening).
        Failure(error_message) on embedding error.
    """
    if embedding_config is None:
        return Success([])

    from lattice.shell.embeddings import embed_text

    # Embed each project's rules
    embeddings: list[tuple[str, list[float]]] = []

    for proj in project_rules:
        if proj.rules_content.startswith("(") and proj.rules_content.endswith(")"):
            # Skip empty/placeholder content
            continue

        emb_result = embed_text(embedding_config, proj.rules_content)
        if isinstance(emb_result, Failure):
            # Log warning but continue
            continue

        embeddings.append((proj.project_name, emb_result.unwrap()))

    if len(embeddings) < 2:
        return Success([])

    # Compute pairwise similarities and cluster (using Core function)
    clusters: list[EmbeddingCluster] = []
    cluster_id = 0

    for i, (name_i, emb_i) in enumerate(embeddings):
        for j, (name_j, emb_j) in enumerate(embeddings[i + 1 :], start=i + 1):
            similarity = cosine_similarity(emb_i, emb_j)

            if similarity >= threshold:
                clusters.append(
                    EmbeddingCluster(
                        cluster_id=f"cluster-{cluster_id}",
                        projects=[name_i, name_j],
                        similarity_score=similarity,
                        content_preview=f"Similar patterns in {name_i} and {name_j}",
                    )
                )
                cluster_id += 1

    return Success(clusters)


# @shell_complexity: File I/O with exist check, glob, read loop
def read_global_rules(global_path: Path) -> Result[str, str]:
    """Read global rules from ~/.lattice/rules/.

    Args:
        global_path: Path to global .lattice directory.

    Returns:
        Success(rules_content) or Failure(error_message).
    """
    rules_dir = global_path / "rules"
    if not rules_dir.exists():
        return Success("(No global rules)")

    try:
        rule_files = sorted(rules_dir.glob("*.md"))
        contents = []

        for rule_file in rule_files:
            content = rule_file.read_text(encoding="utf-8")
            contents.append(f"### {rule_file.name}\n\n{content}")

        return Success("\n\n".join(contents) if contents else "(No global rules)")
    except OSError as e:
        return Failure(f"Failed to read global rules: {e}")


# @invar:allow shell_result: Pure helper for formatting, no I/O, returns plain tuple
# @shell_complexity: Delegating to _format_embedding_clusters helper
# @shell_orchestration: Helper formatting for prompt assembly
def _format_phase1_summaries(
    project_rules: list[ProjectRules],
    project_traces: list[ProjectTrace],
    embedding_clusters: list[EmbeddingCluster],
) -> tuple[list[str], list[str], str]:
    """Format project summaries for Phase 1 prompt assembly.

    Args:
        project_rules: List of ProjectRules from scan.
        project_traces: List of ProjectTrace from scan.
        embedding_clusters: List of EmbeddingCluster from embedding pre-screening.

    Returns:
        Tuple of (rules_summaries, traces_summaries, clusters_str).

    >>> from lattice.shell.global_evolution import _format_phase1_summaries
    >>> rules, traces, clusters = _format_phase1_summaries([], [], [])
    >>> rules
    []
    """
    rules_summaries = [
        f"### {pr.project_name}\n\n{pr.rules_content}\n\n"
        f"*Promoted rules: {pr.promoted_count}*"
        for pr in project_rules
    ]

    traces_summaries = [
        f"### {pt.project_name} (at {pt.trace_timestamp})\n\n"
        f"{extract_trace_preview(pt.trace_content)}"
        for pt in project_traces
        if not (pt.trace_content.startswith("(") and pt.trace_content.endswith(")"))
    ]

    clusters_str = _format_embedding_clusters(embedding_clusters)

    return rules_summaries, traces_summaries, clusters_str


# @invar:allow shell_result: Pure helper for formatting, no I/O
# @shell_orchestration: Helper for Phase 1 prompt assembly
def _format_embedding_clusters(clusters: list[EmbeddingCluster]) -> str:
    """Format embedding clusters for Phase 1 prompt.

    Args:
        clusters: List of EmbeddingCluster to format.

    Returns:
        Formatted cluster string.

    >>> from lattice.shell.global_evolution import _format_embedding_clusters
    >>> _format_embedding_clusters([])
    '(No embedding pre-screening performed)'
    """
    if not clusters:
        return "(No embedding pre-screening performed)"

    lines = ["Pre-screened candidate clusters based on embedding similarity:\n"]
    lines.extend(
        f"- **{c.cluster_id}**: {', '.join(c.projects)} "
        f"(similarity: {c.similarity_score:.2f})\n"
        f"  Preview: {c.content_preview}\n"
        for c in clusters
    )
    return "\n".join(lines)


def _read_package_resource(resource_name: str) -> Result[str, str]:
    """Read a file from the lattice.data package resources.

    Args:
        resource_name: Name of the resource file to read.

    Returns:
        Success(content) or Failure(error_message).
    """
    try:
        content = (
            importlib.resources.files("lattice.data")
            .joinpath(resource_name)
            .read_text(encoding="utf-8")
        )
        return Success(content)
    except (FileNotFoundError, ModuleNotFoundError):
        return Failure(f"Template {resource_name} not found in lattice.data package")


def _read_file_content(file_path: str) -> Result[str, str]:
    """Read content from a file path.

    Args:
        file_path: Path to the file to read.

    Returns:
        Success(content) or Failure(error_message).
    """
    try:
        content = Path(file_path).read_text(encoding="utf-8")
        return Success(content)
    except OSError as e:
        return Failure(f"Failed to read prompt template: {e}")


def _load_prompt_template(
    prompt_template: str | None,
    default_template_name: str,
) -> Result[str, str]:
    """Load prompt template from file path, package resource, or use as-is.

    Args:
        prompt_template: Path to template file, template content, or None for default.
        default_template_name: Name of default template in lattice.data package.

    Returns:
        Success(template_content) or Failure(error_message).

    >>> result = _load_prompt_template(None, "nonexistent.md")
    >>> isinstance(result, Failure)
    True
    """
    if prompt_template is None:
        return _read_package_resource(default_template_name)
    if Path(prompt_template).exists():
        return _read_file_content(prompt_template)
    # Treat as literal template content
    return Success(prompt_template)


@dataclass(frozen=True)
class _Phase1ScanData:
    """Collected data from Phase 1 scanning operations.

    >>> data = _Phase1ScanData(rules=[], traces=[], clusters=[], global_rules="", projects=[])
    >>> len(data.rules)
    0
    """

    rules: list[ProjectRules]
    traces: list[ProjectTrace]
    clusters: list[EmbeddingCluster]
    global_rules: str
    projects: list[ProjectRegistration]


# @shell_complexity: Aggregates multiple I/O operations for Phase 1
def _collect_phase1_data(
    embedding_config: EmbeddingConfig | None,
) -> Result[_Phase1ScanData, str]:
    """Collect all required data for Phase 1 processing.

    Loads projects, scans rules and traces, computes clusters, reads global rules.

    Args:
        embedding_config: Optional config for embedding pre-screening.

    Returns:
        Success(_Phase1ScanData) or Failure(error_message).
    """
    # Step 1: Load registered projects
    projects_result = load_projects()
    if isinstance(projects_result, Failure):
        return projects_result

    projects = projects_result.unwrap()

    if not projects:
        return Failure("No registered projects found")

    # Step 2: Scan project rules
    rules_result = scan_project_rules(projects)
    if isinstance(rules_result, Failure):
        return rules_result

    project_rules = rules_result.unwrap()

    # Step 3: Scan project traces
    traces_result = scan_project_traces(projects)
    if isinstance(traces_result, Failure):
        return traces_result

    project_traces = traces_result.unwrap()

    # Step 4: Optional embedding pre-screening
    clusters_result = compute_embedding_clusters(project_rules, embedding_config)
    if isinstance(clusters_result, Failure):
        # Continue without clusters on embedding failure
        embedding_clusters = []
    else:
        embedding_clusters = clusters_result.unwrap()

    # Step 5: Read global rules
    global_path = _get_global_path()
    global_rules_result = read_global_rules(global_path)
    if isinstance(global_rules_result, Failure):
        return global_rules_result

    global_rules = global_rules_result.unwrap()

    return Success(
        _Phase1ScanData(
            rules=project_rules,
            traces=project_traces,
            clusters=embedding_clusters,
            global_rules=global_rules,
            projects=projects,
        )
    )


# @shell_complexity: Full Phase 1 pipeline - coordinates 10+ Shell operations
def run_global_phase1(
    llm_config: CompilerConfig,
    embedding_config: EmbeddingConfig | None = None,
    prompt_template: str | None = None,
) -> Result[GlobalPhase1Output, str]:
    """Run Global Compiler Phase 1: Rule-Level Scan.

    This implements the full Phase 1 pipeline:
    1. Load all registered projects
    2. Scan rules/*.md from each project
    3. Scan most recent drift/traces/ from each project
    4. Optionally compute embedding clusters (if config available)
    5. Read current global rules
    6. Assemble Phase 1 prompt
    7. Call LLM
    8. Parse candidate patterns

    Per RFC-002 §4.4: "The Global Compiler uses the same Structured CoT
    framework as the Project Compiler, but operates in two phases."

    Args:
        llm_config: CompilerConfig for LLM client.
        embedding_config: Optional EmbeddingConfig for pre-screening.
        prompt_template: Path to Phase 1 prompt template file.

    Returns:
        Success(GlobalPhase1Output) with candidates.
        Failure(error_message) on any error.

    >>> # Without actual LLM, this would fail at LLM call
    >>> result = run_global_phase1(None, None, None)
    >>> isinstance(result, Failure)  # No LLM config
    True
    """
    from lattice.shell.llm import llm_complete

    # Step 1: Collect all scan data (projects, rules, traces, clusters, global rules)
    scan_result = _collect_phase1_data(embedding_config)
    if isinstance(scan_result, Failure):
        return scan_result

    scan_data = scan_result.unwrap()

    # Step 2: Load prompt template
    template_result = _load_prompt_template(
        prompt_template, "global_compiler_prompt_phase1.md"
    )
    if isinstance(template_result, Failure):
        return template_result
    prompt_template_content = template_result.unwrap()

    # Step 3: Format summaries for prompt assembly
    rules_summaries, traces_summaries, clusters_str = _format_phase1_summaries(
        scan_data.rules, scan_data.traces, scan_data.clusters
    )

    # Step 4: Assemble prompt (using Core function)
    prompt = assemble_global_phase1_prompt(
        global_rules=scan_data.global_rules,
        project_rules_summaries=rules_summaries,
        project_traces_summaries=traces_summaries,
        embedding_clusters_summary=clusters_str,
        prompt_template=prompt_template_content,
    )

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

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

    cot_output = llm_result.unwrap()

    # Step 6: Parse candidate patterns (using Core functions)
    candidates = parse_candidate_patterns(cot_output)
    triage_content = extract_triage_content(cot_output)

    return Success(
        GlobalPhase1Output(
            candidates=candidates,
            triage_content=triage_content,
            cot_output=cot_output,
        )
    )


@dataclass(frozen=True)
class GlobalPhase2Output:
    """Output from Global Compiler Phase 2.

    Attributes:
        proposals: List of GlobalProposal objects.
        cross_ref_content: Raw cross_ref phase content.
        synthesis_content: Raw synthesis phase content.
        cot_output: Full CoT output from LLM.

    >>> output = GlobalPhase2Output(
    ...     proposals=[],
    ...     cross_ref_content="Cross-ref...",
    ...     synthesis_content="Synthesis...",
    ...     cot_output="Full CoT...",
    ... )
    >>> len(output.proposals)
    0
    """

    proposals: list[GlobalProposal]
    cross_ref_content: str
    synthesis_content: str
    cot_output: str


# @shell_complexity: Store I/O (connect, close), optional embedding, search, result formatting
def query_project_evidence(
    project_path: Path,
    query: str,
    embedding_config: EmbeddingConfig | None = None,
    limit: int = 5,
) -> Result[str, str]:
    """Query project store.db for evidence matching a candidate pattern.

    Uses hybrid_search: FTS5-only by default, or FTS5 + vector when embedding configured.

    Per RFC-002 §4.4: "For each candidate pattern from Phase 1, perform targeted
    queries against the relevant projects' store.db to retrieve supporting evidence."

    Args:
        project_path: Path to project root.
        query: Search query for evidence.
        embedding_config: Optional EmbeddingConfig for hybrid search.
        limit: Maximum results to return.

    Returns:
        Success(evidence_summary) or Failure(error_message).
    """
    from lattice.shell.hybrid_search import hybrid_search
    from lattice.shell.schema import create_store

    store_path = project_path / ".lattice" / "store.db"
    if not store_path.exists():
        return Success("(No project store)")

    try:
        conn = sqlite3.connect(f"file:{store_path}?mode=ro", uri=True)
    except sqlite3.Error as e:
        return Failure(f"Failed to open store: {e}")

    try:
        # Generate embedding if configured
        query_embedding = None
        if embedding_config is not None:
            from lattice.shell.embeddings import embed_text

            emb_result = embed_text(embedding_config, query)
            if isinstance(emb_result, Success):
                query_embedding = emb_result.unwrap()

        # Execute search
        search_result = hybrid_search(conn, query, query_embedding, limit)
        conn.close()

        if isinstance(search_result, Failure):
            return search_result

        results = search_result.unwrap()
        if not results:
            return Success("(No matching evidence)")

        # Format evidence summary
        evidence_lines = []
        for r in results[:limit]:
            session_id = getattr(r, "session_id", "unknown")
            content_preview = getattr(r, "content", "")[:200]
            evidence_lines.append(f"- Session {session_id}: {content_preview}...")

        return Success("\n".join(evidence_lines))
    except Exception as e:
        conn.close()
        return Failure(f"Evidence query failed: {e}")


# @invar:allow shell_result: Helper collects evidence results, always returns list
# @shell_complexity: Evidence retrieval per candidate with project lookup
def _collect_evidence_summaries(
    candidates: list[GlobalCandidatePattern],
    projects: list[ProjectRegistration],
    embedding_config: EmbeddingConfig | None,
) -> list[str]:
    """Query evidence for each candidate from relevant project stores.

    Args:
        candidates: List of candidate patterns from Phase 1.
        projects: List of registered projects.
        embedding_config: Optional EmbeddingConfig for hybrid search.

    Returns:
        List of evidence summary strings for each candidate.
    """
    evidence_summaries: list[str] = []
    for candidate in candidates:
        summary_lines = [f"### Evidence for: {candidate.pattern_name}\n"]

        for proj_name in candidate.source_projects:
            # Find project path
            project_path = None
            for proj in projects:
                if proj.name == proj_name:
                    project_path = Path(proj.path)
                    break

            if project_path is None:
                continue

            # Query evidence
            evidence_result = query_project_evidence(
                project_path, candidate.description, embedding_config
            )
            if isinstance(evidence_result, Success):
                summary_lines.append(f"**{proj_name}**:\n{evidence_result.unwrap()}\n")

        evidence_summaries.append("\n".join(summary_lines))

    return evidence_summaries


# @shell_complexity: Evidence retrieval per candidate + LLM call + parsing
def run_global_phase2(
    candidates: list[GlobalCandidatePattern],
    llm_config: CompilerConfig,
    projects: list[ProjectRegistration],
    embedding_config: EmbeddingConfig | None = None,
    prompt_template: str | None = None,
) -> Result[GlobalPhase2Output, str]:
    """Run Global Compiler Phase 2: Evidence Verification.

    This implements the full Phase 2 pipeline:
    1. For each candidate, query relevant project stores for evidence
    2. Read current global rules
    3. Assemble Phase 2 prompt
    4. Call LLM
    5. Parse global proposals

    Per RFC-002 §4.4: "Phase 2 — Evidence Verification (targeted deep-dive)
    For each candidate pattern from Phase 1, perform targeted queries against
    the relevant projects' store.db to retrieve supporting evidence."

    Args:
        candidates: List of CandidatePattern from Phase 1.
        llm_config: CompilerConfig for LLM client.
        projects: List of registered projects.
        embedding_config: Optional EmbeddingConfig for hybrid search.
        prompt_template: Path to Phase 2 prompt template file.

    Returns:
        Success(GlobalPhase2Output) with proposals (may be empty).
        Failure(error_message) on actual errors.

    >>> result = run_global_phase2([], None, [], None, None)
    >>> isinstance(result, Success)  # Empty candidates is valid
    True
    >>> result.unwrap().proposals
    []
    """
    from lattice.shell.llm import llm_complete

    # Step 1: If no candidates, return empty success (valid upstream state)
    if not candidates:
        return Success(
            GlobalPhase2Output(
                proposals=[],
                cross_ref_content="",
                synthesis_content="",
                cot_output="No candidates to verify",
            )
        )

    # Step 2: Read global rules
    global_path = _get_global_path()
    global_rules_result = read_global_rules(global_path)
    if isinstance(global_rules_result, Failure):
        return global_rules_result

    global_rules = global_rules_result.unwrap()

    # Step 3: Query evidence for each candidate (using helper)
    evidence_summaries = _collect_evidence_summaries(
        candidates, projects, embedding_config
    )

    # Step 4: Load prompt template
    template_result = _load_prompt_template(
        prompt_template, "global_compiler_prompt_phase2.md"
    )
    if isinstance(template_result, Failure):
        return template_result
    prompt_template_content = template_result.unwrap()

    # Step 5: Assemble prompt (using Core function)
    prompt = assemble_global_phase2_prompt(
        candidate_patterns=candidates,
        global_rules=global_rules,
        evidence_summaries=evidence_summaries,
        prompt_template=prompt_template_content,
    )

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

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

    cot_output = llm_result.unwrap()

    # Step 7: Parse proposals (using Core functions)
    proposals = parse_global_proposals(cot_output)
    cross_ref_content = extract_cross_ref_content(cot_output)
    synthesis_content = extract_synthesis_content(cot_output)

    return Success(
        GlobalPhase2Output(
            proposals=proposals,
            cross_ref_content=cross_ref_content,
            synthesis_content=synthesis_content,
            cot_output=cot_output,
        )
    )


# @shell_complexity: Path resolution with fallbacks + file I/O error handling
def mark_rule_promoted(project_path: Path, rule_file: str) -> Result[bool, str]:
    """Mark a rule file as promoted by adding lattice:promoted comment.

    Per RFC-002 §4.4: "Mark promoted rules in source projects with
    <!-- lattice:promoted --> to exclude from future scans."

    Args:
        project_path: Path to project root.
        rule_file: Relative path to rule file (e.g., "rules/style.md").

    Returns:
        Success(True) if marked, Failure(error) on error.

    >>> result = mark_rule_promoted(Path("/nonexistent"), "rules/test.md")
    >>> isinstance(result, Failure)
    True
    """
    # Resolve full path
    if rule_file.startswith("rules/"):
        full_path = project_path / ".lattice" / rule_file
    else:
        full_path = project_path / ".lattice" / "rules" / rule_file

    if not full_path.exists():
        # Try without .lattice prefix (direct rules/)
        alt_path = project_path / rule_file
        if alt_path.exists():
            full_path = alt_path
        else:
            return Failure(f"Rule file not found: {rule_file}")

    try:
        content = full_path.read_text(encoding="utf-8")
    except OSError as e:
        return Failure(f"Failed to read rule file: {e}")

    # Check if already marked
    if "<!-- lattice:promoted -->" in content:
        return Success(True)  # Already marked

    # Add marker at the top of file
    marked_content = f"<!-- lattice:promoted -->\n{content}"

    try:
        full_path.write_text(marked_content, encoding="utf-8")
    except OSError as e:
        return Failure(f"Failed to write rule file: {e}")

    return Success(True)


@dataclass(frozen=True)
class EvidenceRecord:
    """Evidence record for global.db.

    Attributes:
        source_project: Project name where evidence was found.
        source_session_id: Session ID from the source project.
        source_rule_file: Rule file if applicable.
        pattern: The global pattern name.
        summary: Evidence summary text.
        original_snippet: Original content snippet.
        confidence: Confidence score (0.0-1.0).

    >>> record = EvidenceRecord(
    ...     source_project="proj-a",
    ...     source_session_id="s1",
    ...     source_rule_file="rules/style.md",
    ...     pattern="Type Hints",
    ...     summary="Consistent use of type hints",
    ...     original_snippet="Always use type hints",
    ...     confidence=0.95,
    ... )
    >>> record.pattern
    'Type Hints'
    """

    source_project: str
    source_session_id: str
    source_rule_file: str | None
    pattern: str
    summary: str
    original_snippet: str | None
    confidence: float = 0.0


# @shell_complexity: Database connection + batch insert + error handling
def write_evidence_to_global_db(
    evidence_records: list[EvidenceRecord],
    global_db_path: Path | None = None,
) -> Result[list[int], str]:
    """Write evidence summaries to global.db evidence table.

    Per RFC-002 §5.3: "Insert evidence records with source tracking,
    pattern name, summary, and confidence score."

    Args:
        evidence_records: List of EvidenceRecord objects.
        global_db_path: Path to global.db (default: ~/.config/lattice/store/global.db).

    Returns:
        Success(list of rowids) for each inserted record.
        Failure(error) on database error.

    >>> result = write_evidence_to_global_db([], None)
    >>> isinstance(result, Success)
    True
    >>> result.unwrap()
    []
    """
    from datetime import datetime, timezone

    from lattice.shell.schema import create_global_store

    if global_db_path is None:
        global_db_path = _get_global_path() / "store" / "global.db"

    if not evidence_records:
        return Success([])

    # Open/create database
    store_result = create_global_store(global_db_path)
    if isinstance(store_result, Failure):
        return store_result

    conn = store_result.unwrap()

    try:
        extracted_at = datetime.now(timezone.utc).isoformat()

        cursor = conn.cursor()
        rowids: list[int] = []
        for record in evidence_records:
            cursor.execute(
                """
                INSERT INTO evidence (
                    source_project, source_session_id, source_rule_file,
                    pattern, summary, original_snippet, extracted_at, confidence
                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
                """,
                (
                    record.source_project,
                    record.source_session_id,
                    record.source_rule_file,
                    record.pattern,
                    record.summary,
                    record.original_snippet,
                    extracted_at,
                    record.confidence,
                ),
            )
            # cursor.lastrowid is guaranteed non-None after successful INSERT
            rowid = cursor.lastrowid
            if rowid is not None:
                rowids.append(rowid)

        conn.commit()
        return Success(rowids)

    except sqlite3.Error as e:
        return Failure(f"Database error: {e}")
    finally:
        conn.close()


# @shell_complexity: Database connection + batch association + error handling
def write_rule_evidence_associations(
    rule_file: str,
    evidence_ids: list[int],
    global_db_path: Path | None = None,
) -> Result[int, str]:
    """Write rule_evidence associations to global.db.

    Per RFC-002 §5.3: "Link global rules to their supporting evidence."

    Args:
        rule_file: Rule file name (e.g., "conventions.md").
        evidence_ids: List of evidence IDs to associate.
        global_db_path: Path to global.db (default: ~/.config/lattice/store/global.db).

    Returns:
        Success(count) with number of associations written.
        Failure(error) on database error.

    >>> result = write_rule_evidence_associations("conv.md", [], None)
    >>> isinstance(result, Success)
    True
    """
    from lattice.shell.schema import create_global_store

    if global_db_path is None:
        global_db_path = _get_global_path() / "store" / "global.db"

    if not evidence_ids:
        return Success(0)

    # Open/create database
    store_result = create_global_store(global_db_path)
    if isinstance(store_result, Failure):
        return store_result

    conn = store_result.unwrap()

    try:
        cursor = conn.cursor()
        for evidence_id in evidence_ids:
            cursor.execute(
                """
                INSERT OR IGNORE INTO rule_evidence (rule_file, evidence_id)
                VALUES (?, ?)
                """,
                (rule_file, str(evidence_id)),
            )

        conn.commit()
        return Success(len(evidence_ids))

    except sqlite3.Error as e:
        return Failure(f"Database error: {e}")
    finally:
        conn.close()


# @shell_complexity: Database connection + embedding serialization + error handling
def write_evidence_embedding(
    evidence_id: int,
    embedding: list[float],
    global_db_path: Path | None = None,
) -> Result[bool, str]:
    """Write embedding to evidence_vec table.

    Per RFC-002 §5.3: "Store generated embeddings in evidence_vec for
    vector similarity search."

    Args:
        evidence_id: The evidence record ID.
        embedding: The embedding vector (768 dimensions).
        global_db_path: Path to global.db (default: ~/.config/lattice/store/global.db).

    Returns:
        Success(True) if written.
        Failure(error) on database error.
    """
    import struct

    from lattice.shell.schema import create_global_store

    if global_db_path is None:
        global_db_path = _get_global_path() / "store" / "global.db"

    # Validate embedding dimension
    if len(embedding) != 768:
        return Failure(f"Expected 768-dim embedding, got {len(embedding)}")

    # Open/create database
    store_result = create_global_store(global_db_path)
    if isinstance(store_result, Failure):
        return store_result

    conn = store_result.unwrap()

    try:
        # Convert embedding to blob (float32 array)
        embedding_blob = struct.pack(f"{len(embedding)}f", *embedding)

        cursor = conn.cursor()
        cursor.execute(
            "INSERT INTO evidence_vec (rowid, embedding) VALUES (?, ?)",
            (evidence_id, embedding_blob),
        )
        conn.commit()
        return Success(True)

    except sqlite3.Error as e:
        return Failure(f"Database error: {e}")
    finally:
        conn.close()


def write_global_evolution_output(
    phase1_cot: str,
    phase2_cot: str,
    proposals: list[GlobalProposal],
    projects_scanned: int,
    global_path: Path | None = None,
) -> Result[tuple[str, str], str]:
    """Write global evolution trace and proposals to drift/ directory.

    Creates two files:
    - ~/.lattice/drift/traces/<timestamp>.md — Full CoT trace (Phase 1 + Phase 2)
    - ~/.lattice/drift/proposals/<timestamp>.md — Converged proposals

    Per RFC-002 §4.4: "The full CoT output (Phase 1 + Phase 2) is saved to
    ~/.lattice/drift/traces/ as an audit trail. Converged proposals are
    written to ~/.lattice/drift/proposals/ for review and application."

    Args:
        phase1_cot: Full CoT output from Phase 1 (rule scan).
        phase2_cot: Full CoT output from Phase 2 (evidence verification).
        proposals: List of GlobalProposal objects (converged only).
        projects_scanned: Number of projects scanned.
        global_path: Path to global .lattice directory (default: ~/.lattice).

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

    >>> result = write_global_evolution_output("p1", "p2", [], 5, Path("/nonexistent_test_path"))
    >>> isinstance(result, Failure)  # Path doesn't exist
    True
    """
    from lattice.core.global_compiler import (
        format_global_proposal_content,
        format_global_trace_content,
    )

    if global_path is None:
        global_path = _get_global_path()

    try:
        # Ensure directories exist
        traces_dir = global_path / "drift" / "traces"
        proposals_dir = global_path / "drift" / "proposals"
        traces_dir.mkdir(parents=True, exist_ok=True)
        proposals_dir.mkdir(parents=True, exist_ok=True)

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

        # Write trace file
        trace_filename = f"{timestamp}.md"
        trace_path = traces_dir / trace_filename
        trace_content = format_global_trace_content(
            phase1_cot, phase2_cot, projects_scanned
        )
        trace_path.write_text(trace_content, encoding="utf-8")

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

        return Success((str(proposal_path), str(trace_path)))

    except OSError as e:
        return Failure(f"Failed to write global evolution output: {e}")


__all__ = [
    "ProjectRules",
    "ProjectTrace",
    "EmbeddingCluster",
    "GlobalPhase1Output",
    "GlobalPhase2Output",
    "EvidenceRecord",
    "scan_project_rules",
    "scan_project_traces",
    "compute_embedding_clusters",
    "read_global_rules",
    "run_global_phase1",
    "query_project_evidence",
    "run_global_phase2",
    "mark_rule_promoted",
    "write_evidence_to_global_db",
    "write_rule_evidence_associations",
    "write_evidence_embedding",
    "write_global_evolution_output",
]
