"""
CLI for Lattice - Local-first persistent memory for AI agents.

All commands are Shell layer — they perform I/O and exit with appropriate codes.

Reference: RFC-002 §6.1 CLI Commands
"""

# @invar:allow file_size: CLI module with all commands; splitting would fragment command grouping
# @invar:allow entry_point_too_thick: Typer commands require docstrings + options formatting
# @invar:allow shell_result: CLI output formatting helpers, not I/O operations
# @invar:allow shell_pure_logic: String formatting helpers, not I/O operations
from __future__ import annotations

import logging
from pathlib import Path
from typing import TYPE_CHECKING, Annotated, Any, cast

import typer
from returns.result import Failure, Success

logger = logging.getLogger(__name__)

from lattice.core.auth import validate_provider_name
from lattice.core.types.search import SearchResult
from lattice.shell.auth import (
    delete_auth,
    get_auth,
    load_auth,
    save_auth,
)
from lattice.shell.config import resolve_global_dir
from lattice.shell.orchestration import (
    acquire_global_evolution_lock,
    format_project_list,
    format_verify_results,
    load_global_config,
    print_evolution_summary,
    release_global_evolution_lock,
    write_converged_proposals,
)
from lattice.shell.projects import (
    exclude_project,
    find_project_by_path,
    get_projects_path,
    include_project,
    is_excluded,
    list_projects,
    load_projects,
    register_project,
    unregister_project,
    verify_projects,
)
from litellm.exceptions import (
    APIConnectionError,
    APIError,
    AuthenticationError,
    RateLimitError,
)

app = typer.Typer(
    name="lattice",
    help="Local-first persistent memory for AI agents",
)

projects_app = typer.Typer(help="Project registry management")
app.add_typer(projects_app, name="projects")

auth_app = typer.Typer(help="Manage API key authentication")
app.add_typer(auth_app, name="auth")

config_app = typer.Typer(help="Configuration management")
app.add_typer(config_app, name="config")


# Completion command (workaround for environments where shell detection fails)
# @invar:allow entry_point_too_thick: Typer completion command - needs shell detection logic inline
@app.command("completion")
def completion_command(
    shell: Annotated[
        str | None,
        typer.Option(
            "--shell", "-s", help="Shell type: bash, zsh, fish, or powershell"
        ),
    ] = None,
    install_flag: Annotated[
        bool,
        typer.Option("--install", "-i", help="Install completion script"),
    ] = False,
) -> None:
    """Show or install shell completion scripts.

    Lattice supports shell completion for bash, zsh, fish, and PowerShell.

    \b
    Examples:
        # Show completion script for zsh (for manual installation)
        lattice completion --shell zsh

        # Install completion for bash
        lattice completion --shell bash --install

        # If shell is not specified, auto-detect from $SHELL
        lattice completion --install
    """
    from typer.completion import completion_init, get_completion_script, install

    # Initialize completion classes
    completion_init()

    prog_name = "lattice"
    complete_var = "_LATTICE_COMPLETE"

    # Determine shell
    if shell is None:
        # Try to auto-detect
        import os

        shell_env = os.environ.get("SHELL", "")
        if "zsh" in shell_env:
            shell = "zsh"
        elif "bash" in shell_env:
            shell = "bash"
        elif "fish" in shell_env:
            shell = "fish"
        elif "powershell" in shell_env.lower() or "pwsh" in shell_env:
            shell = "powershell"
        else:
            typer.secho(
                "Could not auto-detect shell. Please specify with --shell.",
                fg=typer.colors.RED,
            )
            typer.echo(
                "Supported shells: bash, zsh, fish, powershell\n"
                "Example: lattice completion --shell zsh --install"
            )
            raise typer.Exit(1)

    shell = shell.lower()
    if shell not in ("bash", "zsh", "fish", "powershell", "pwsh"):
        typer.secho(f"Unsupported shell: {shell}", fg=typer.colors.RED)
        typer.echo("Supported shells: bash, zsh, fish, powershell")
        raise typer.Exit(1)

    if install_flag:
        # Install completion
        installed_shell, installed_path = install(
            shell=shell, prog_name=prog_name, complete_var=complete_var
        )
        typer.secho(
            f"✓ {installed_shell} completion installed to {installed_path}",
            fg=typer.colors.GREEN,
        )
        typer.echo("Completion will take effect once you restart the terminal.")
    else:
        # Show completion script
        script = get_completion_script(
            prog_name=prog_name, complete_var=complete_var, shell=shell
        )
        typer.echo(script)


@app.command("serve")
# @invar:allow entry_point_too_thick: Typer command with input validation and server start
def serve_command(
    capture_mode: str | None = typer.Option(
        None,
        "--capture-mode",
        help="Override capture mode: active (expose log_turn) or passive (read-only)",
    ),
) -> None:
    """Start MCP server (stdio transport).

    Capture modes:
    - active: Enables log_turn and log tools for recording interactions
    - passive: Read-only mode, log_turn/log tools return error

    If not specified, uses config.toml [capture].mode setting.
    """
    from lattice.shell.server import serve_main

    # Validate capture_mode value
    if capture_mode is not None and capture_mode not in ("active", "passive"):
        typer.secho(
            f"Error: --capture-mode must be 'active', 'passive', or omitted",
            fg=typer.colors.RED,
        )
        raise typer.Exit(1)

    logger.debug(f"Starting Lattice MCP server (capture_mode={capture_mode})")
    typer.secho("Starting Lattice MCP server...", fg=typer.colors.CYAN)
    serve_main(capture_mode=capture_mode)


@projects_app.callback(invoke_without_command=True)
def projects_default(ctx: typer.Context) -> None:
    """List projects (default action)."""
    if ctx.invoked_subcommand is None:
        projects_list()


# @invar:allow entry_point_too_thick: Typer list command - needs Result handling inline
@projects_app.command("list")
def projects_list() -> None:
    """List all registered projects."""
    result = list_projects()
    if isinstance(result, Success):
        infos = result.unwrap()
        if not infos:
            typer.secho(
                "No projects. Run 'lattice projects add <path>' to register.",
                fg=typer.colors.YELLOW,
            )
            return
        typer.echo(format_project_list(infos))
    else:
        typer.secho(
            f"Error: {result.failure()}",
            fg=typer.colors.RED,
        )
        raise typer.Exit(1)


@projects_app.command("add")
def projects_add(
    path: str = typer.Argument(..., help="Path to project directory"),
    name: str | None = typer.Option(None, "--name", "-n", help="Project name"),
) -> None:
    """Register a project without running init."""
    result = register_project(path, name)
    if isinstance(result, Success):
        typer.secho(f"✓ Registered: {result.unwrap().name}", fg=typer.colors.GREEN)
    else:
        typer.secho(
            f"Error: {result.failure()}",
            fg=typer.colors.RED,
        )
        raise typer.Exit(1)


@projects_app.command("remove")
def projects_remove(name: str = typer.Argument(..., help="Project name")) -> None:
    """Remove a project from registry."""
    result = unregister_project(name)
    if isinstance(result, Success):
        typer.secho(f"✓ Removed: {name}", fg=typer.colors.GREEN)
    else:
        typer.secho(
            f"Error: {result.failure()}",
            fg=typer.colors.RED,
        )
        raise typer.Exit(1)


@projects_app.command("verify")
def projects_verify_cmd() -> None:
    """Verify all project paths exist."""
    result = verify_projects()
    if isinstance(result, Success):
        for name, ok, msg in result.unwrap():
            color = typer.colors.GREEN if ok else typer.colors.RED
            typer.secho(f"{'✓' if ok else '✗'} {name}: {msg}", fg=color)
    else:
        typer.secho(f"Error: {result.failure()}", fg=typer.colors.RED)
        raise typer.Exit(1)


# @invar:allow entry_point_too_thick: Typer command with Result handling and user feedback
@projects_app.command("exclude")
def projects_exclude(
    path: str = typer.Argument(..., help="Path to project directory to exclude"),
    reason: str = typer.Option("", "--reason", "-r", help="Reason for exclusion"),
) -> None:
    """Exclude a project from lazy initialization.

    Excluded projects will not be auto-registered or appear in global evolution.
    """
    result = exclude_project(path, reason)
    if isinstance(result, Success):
        ex = result.unwrap()
        typer.secho(f"✓ Excluded: {ex.path}", fg=typer.colors.GREEN)
        if ex.reason:
            typer.secho(f"  Reason: {ex.reason}", fg=typer.colors.CYAN)
    else:
        typer.secho(f"Error: {result.failure()}", fg=typer.colors.RED)
        raise typer.Exit(1)


# @invar:allow entry_point_too_thick: Typer command with Result handling and user feedback
@projects_app.command("include")
def projects_include(
    path: str = typer.Argument(..., help="Path to project directory to re-include"),
) -> None:
    """Re-include a previously excluded project.

    The project will be eligible for auto-registration on next use.
    """
    result = include_project(path)
    if isinstance(result, Success):
        typer.secho(f"✓ Re-included: {path}", fg=typer.colors.GREEN)
        typer.secho("  Run 'lattice ingest' to auto-register", fg=typer.colors.CYAN)
    else:
        typer.secho(f"Error: {result.failure()}", fg=typer.colors.RED)
        raise typer.Exit(1)


# @invar:allow shell_result: Terminal I/O helper returns string directly (like getpass)
# @shell_complexity: Terminal raw mode + character echo + signal restoration
def _getpass_masked(prompt: str, mask: str = "*") -> str:
    """Get password input with masking on Unix systems.

    On Unix/macOS, getpass.getpass() doesn't support echo_char parameter.
    This function provides masking using termios directly.

    Falls back to regular getpass on non-Unix systems or if termios fails.

    Args:
        prompt: The prompt to display
        mask: Character to display for each typed character (default: *)

    Returns:
        The entered password string
    """
    import sys

    # On non-Unix systems, use regular getpass
    if sys.platform == "win32":
        import getpass

        return getpass.getpass(prompt)

    # Unix/macOS: use termios for masking
    import termios
    import tty

    fd = sys.stdin.fileno()
    old_settings = termios.tcgetattr(fd)
    try:
        tty.setraw(fd)
        # Print prompt
        sys.stdout.write(prompt)
        sys.stdout.flush()

        password = []
        while True:
            ch = sys.stdin.read(1)
            if ch == "\r" or ch == "\n":
                break
            elif ch == "\x03":  # Ctrl+C
                raise KeyboardInterrupt
            elif ch == "\x7f" or ch == "\x08":  # Backspace
                if password:
                    password.pop()
                    # Move back, overwrite with space, move back again
                    sys.stdout.write("\b \b")
                    sys.stdout.flush()
            else:
                password.append(ch)
                sys.stdout.write(mask)
                sys.stdout.flush()

        # Print newline after Enter
        sys.stdout.write("\n")
        sys.stdout.flush()
        return "".join(password)
    finally:
        termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)


# =============================================================================
# Auth Commands (CONTRACT PHASE)
# =============================================================================


# @invar:allow entry_point_too_thick: Typer command with user interaction and Result handling
@auth_app.command("login")
def auth_login(
    provider: str = typer.Argument(..., help="Provider name (e.g., openai, anthropic)"),
    key: str | None = typer.Option(
        None, help="API key (if not provided, will prompt securely)"
    ),
) -> None:
    """Save API key for provider.

    The key is stored in ~/.config/lattice/auth.json and will be used automatically
    when no api_key/api_key_env is configured in config.toml.

    Priority chain:
    1. config.toml api_key/api_key_env
    2. auth.json (this command)
    3. environment variable
    4. litellm default

    >>> from typer.testing import CliRunner
    >>> runner = CliRunner()
    >>> _ = runner.invoke(auth_app, ['login', 'openai', '--key', 'sk-test'])  # doctest: +SKIP
    """

    # Validate provider name
    validation = validate_provider_name(provider)
    if validation is not True:
        typer.secho(f"Error: {validation}", fg=typer.colors.RED)
        raise typer.Exit(1)

    # Get API key securely if not provided via --key
    if key is None:
        key = _getpass_masked(f"API Key for {provider}: ")

    if not key:
        typer.secho("Error: API key cannot be empty", fg=typer.colors.RED)
        raise typer.Exit(1)

    result = save_auth(provider, key)
    if isinstance(result, Success):
        typer.secho(f"✓ Saved API key for {provider}", fg=typer.colors.GREEN)
        typer.secho(
            f"  Key will be used automatically (no config.toml changes needed)",
            fg=typer.colors.CYAN,
        )
    else:
        typer.secho(f"Error: {result.failure()}", fg=typer.colors.RED)
        raise typer.Exit(1)


@auth_app.command("logout")
# @invar:allow entry_point_too_thick: Auth logout: provider validation + auth deletion + existence check messaging
def auth_logout(
    provider: str = typer.Argument(..., help="Provider name"),
) -> None:
    """Remove API key for provider.

    >>> from typer.testing import CliRunner
    >>> runner = CliRunner()
    >>> _ = runner.invoke(auth_app, ['logout', 'openai'])  # doctest: +SKIP
    """
    # Validate provider name
    validation = validate_provider_name(provider)
    if validation is not True:
        typer.secho(f"Error: {validation}", fg=typer.colors.RED)
        raise typer.Exit(1)

    result = delete_auth(provider)
    if isinstance(result, Success):
        deleted = result.unwrap()
        if deleted:
            typer.secho(f"✓ Removed API key for {provider}", fg=typer.colors.GREEN)
        else:
            typer.secho(f"No API key found for {provider}", fg=typer.colors.YELLOW)
    else:
        typer.secho(f"Error: {result.failure()}", fg=typer.colors.RED)
        raise typer.Exit(1)


@auth_app.command("list")
# @invar:allow entry_point_too_thick: Auth list: load auth data + iterate providers + display key names only
def auth_list() -> None:
    """List saved providers (without showing keys).

    >>> from typer.testing import CliRunner
    >>> runner = CliRunner()
    >>> result = runner.invoke(auth_app, ['list'])  # doctest: +SKIP
    """
    result = load_auth()
    if isinstance(result, Success):
        auth_data = result.unwrap()
        if not auth_data:
            typer.secho("No API keys saved.", fg=typer.colors.YELLOW)
            return
        typer.secho("\n🔐 Saved API Keys:", fg=typer.colors.CYAN)
        for provider in auth_data:
            typer.secho(f"  • {provider}", fg=typer.colors.WHITE)
    else:
        typer.secho(f"Error: {result.failure()}", fg=typer.colors.RED)
        raise typer.Exit(1)


@auth_app.command("test")
# @invar:allow entry_point_too_thick: Typer command with litellm API test and error handling
# @shell_complexity: API test requires provider mapping, error handling, and user messaging
def auth_test(
    provider: str = typer.Argument(..., help="Provider name"),
) -> None:
    """Test API key by making a minimal API call.

    >>> from typer.testing import CliRunner
    >>> runner = CliRunner()
    >>> _ = runner.invoke(auth_app, ['test', 'openai'])  # doctest: +SKIP
    """
    # Validate provider name
    validation = validate_provider_name(provider)
    if validation is not True:
        typer.secho(f"Error: {validation}", fg=typer.colors.RED)
        raise typer.Exit(1)

    # Get the API key
    result = get_auth(provider)
    if isinstance(result, Failure):
        typer.secho(f"Error: {result.failure()}", fg=typer.colors.RED)
        raise typer.Exit(1)

    api_key = result.unwrap()
    if api_key is None:
        typer.secho(f"No API key found for {provider}", fg=typer.colors.RED)
        typer.secho(
            f"Run 'lattice auth login {provider}' to add one.", fg=typer.colors.YELLOW
        )
        raise typer.Exit(1)

    typer.secho(f"Testing API key for {provider}...", fg=typer.colors.CYAN)

    # Make minimal API call using litellm
    try:
        import litellm

        # Map provider name to litellm model format
        # E.g., "openai" -> "openai/gpt-3.5-turbo" for minimal test
        model_mapping = {
            "openai": "gpt-3.5-turbo",
            "anthropic": "claude-3-haiku-20240307",
            "gemini": "gemini/gemini-pro",
        }
        model = model_mapping.get(provider, f"{provider}/gpt-3.5-turbo")

        # Make a minimal call
        response = litellm.completion(
            model=model,
            messages=[{"role": "user", "content": "Say 'ok'"}],
            max_tokens=5,
            api_key=api_key,
        )

        # Check response validity
        # litellm returns ModelResponse (has choices) or CustomStreamWrapper (streaming)
        choices = getattr(response, "choices", None)
        if choices:
            typer.secho(f"✓ API key for {provider} is valid", fg=typer.colors.GREEN)
        else:
            typer.secho(
                f"⚠ API call succeeded but response was unexpected",
                fg=typer.colors.YELLOW,
            )
    except AuthenticationError as e:
        typer.secho(f"✗ Authentication failed for {provider}", fg=typer.colors.RED)
        typer.secho(f"  Error: {e}", fg=typer.colors.RED)
        raise typer.Exit(1)
    except RateLimitError as e:
        typer.secho(f"⚠ Rate limit exceeded for {provider}", fg=typer.colors.YELLOW)
        typer.secho(f"  Error: {e}", fg=typer.colors.YELLOW)
        raise typer.Exit(1)
    except APIConnectionError as e:
        typer.secho(f"✗ Connection failed for {provider}", fg=typer.colors.RED)
        typer.secho(f"  Error: {e}", fg=typer.colors.RED)
        raise typer.Exit(1)
    except APIError as e:
        typer.secho(f"✗ API error for {provider}", fg=typer.colors.RED)
        typer.secho(f"  Error: {e}", fg=typer.colors.RED)
        raise typer.Exit(1)
    except Exception as e:
        # Fallback for unexpected errors
        typer.secho(f"✗ Unexpected error testing {provider}", fg=typer.colors.RED)
        typer.secho(f"  Error: {e}", fg=typer.colors.RED)
        raise typer.Exit(1)


# =============================================================================
# Config Commands
# =============================================================================


# @invar:allow entry_point_too_thick: Typer config init command - needs file I/O logic inline
@config_app.command("init")
def config_init(
    global_cfg: bool = typer.Option(
        False, "--global", "-g", help="Initialize global config"
    ),
    force: bool = typer.Option(
        False, "--force", "-f", help="Overwrite existing config"
    ),
) -> None:
    """Initialize configuration file with default values.

    Creates config.toml with all available options and documentation.
    Use --global to create the global config (~/.config/lattice/config.toml).

    >>> from typer.testing import CliRunner
    >>> runner = CliRunner()
    >>> _ = runner.invoke(app, ['config', 'init', '--global'])  # doctest: +SKIP
    """
    from lattice.shell.config_template import get_config_template

    if global_cfg:
        global_dir_result = resolve_global_dir()
        if isinstance(global_dir_result, Failure):
            typer.secho(f"Error: {global_dir_result.failure()}", fg=typer.colors.RED)
            raise typer.Exit(1)
        config_path = global_dir_result.unwrap() / "config.toml"
    else:
        typer.secho(
            "Use --global to initialize global config (~/.config/lattice/config.toml)",
            fg=typer.colors.YELLOW,
        )
        typer.secho(
            "Project-level config is created automatically on first use (e.g., 'lattice ingest')",
            fg=typer.colors.CYAN,
        )
        raise typer.Exit(1)

    if config_path.exists() and not force:
        typer.secho(f"Config already exists: {config_path}", fg=typer.colors.YELLOW)
        typer.secho("Use --force to overwrite", fg=typer.colors.CYAN)
        raise typer.Exit(1)

    config_path.parent.mkdir(parents=True, exist_ok=True)
    config_path.write_text(get_config_template())

    typer.secho(f"✓ Created config: {config_path}", fg=typer.colors.GREEN)
    typer.secho(
        "Edit the file to configure your model and preferences",
        fg=typer.colors.CYAN,
    )
    typer.secho(
        "Run 'lattice auth login <provider>' to save your API key",
        fg=typer.colors.CYAN,
    )


# @invar:allow entry_point_too_thick: Typer config show command - needs file I/O logic inline
@config_app.command("show")
def config_show(
    global_cfg: bool = typer.Option(False, "--global", "-g", help="Show global config"),
) -> None:
    """Show current configuration.

    >>> from typer.testing import CliRunner
    >>> runner = CliRunner()
    >>> _ = runner.invoke(app, ['config', 'show'])  # doctest: +SKIP
    """
    if global_cfg:
        global_dir_result = resolve_global_dir()
        if isinstance(global_dir_result, Failure):
            typer.secho(f"Error: {global_dir_result.failure()}", fg=typer.colors.RED)
            raise typer.Exit(1)
        config_path = global_dir_result.unwrap() / "config.toml"
    else:
        typer.secho(
            "Use --global to show global config",
            fg=typer.colors.YELLOW,
        )
        raise typer.Exit(1)

    if not config_path.exists():
        typer.secho(f"No config found at {config_path}", fg=typer.colors.YELLOW)
        typer.secho(
            "Run 'lattice config init --global' to create one",
            fg=typer.colors.CYAN,
        )
        raise typer.Exit(1)

    typer.echo(config_path.read_text())


# =============================================================================
# Evolution Commands
# =============================================================================


# @invar:allow entry_point_too_thick: Global evolution: Phase 1 scan + Phase 2 verify + global.db writes across projects
# @shell_complexity: Two-phase pipeline with error handling at each step
def _run_global_evolution(full: bool) -> None:
    """Run global evolution pipeline across all projects.

    Implements RFC-002 §4.4 Global Evolution:
    - Phase 1: Rule Scan + Candidate Pattern Detection
    - Phase 2: Evidence Verification
    - Post-processing: Promotion marking + global.db writes

    Concurrency Note: Only acquires global evolve.lock (~/.lattice/evolve.lock).
    Does not acquire individual project locks. A project-level evolve could run
    concurrently on a project being processed by global evolution. This is acceptable
    for MVP since global evolution is primarily read-only on project data.
    See SPEC QUESTION comment at lock acquisition for post-MVP consideration.
    """
    from lattice.shell.global_evolution import run_global_phase1, run_global_phase2
    from lattice.core.types.evidence import ProjectRegistration

    global_path = _resolve_global_path()
    lock_result = acquire_global_evolution_lock(global_path)
    if lock_result is None:
        raise typer.Exit(1)
    lock_fd, lock_path = lock_result

    try:
        config_result = load_global_config(global_path)
        if config_result is None:
            raise typer.Exit(1)
        config, _ = config_result

        project_infos = _get_registered_projects()
        if project_infos is None:
            raise typer.Exit(1)

        typer.secho(
            f"\nRunning global evolution across {len(project_infos)} projects...",
            fg=typer.colors.CYAN,
        )

        phase1_output = _run_phase1(config.compiler)
        if phase1_output is None:
            raise typer.Exit(1)

        if not phase1_output.candidates:
            typer.secho(
                "\n✓ Global evolution complete (no candidates found)",
                fg=typer.colors.GREEN,
            )
            return

        phase2_output = _run_phase2(
            phase1_output.candidates, config.compiler, project_infos
        )
        if phase2_output is None:
            raise typer.Exit(1)

        converged_proposals = [
            p for p in phase2_output.proposals if len(p.source_projects) >= 3
        ]
        typer.secho(
            f"  Converged proposals (≥3 projects): {len(converged_proposals)}",
            fg=typer.colors.CYAN,
        )

        if converged_proposals:
            projects_result = list_projects()
            write_converged_proposals(
                converged_proposals,
                phase1_output,
                phase2_output,
                project_infos,
                projects_result,
                global_path,
            )

        print_evolution_summary(phase1_output, phase2_output, converged_proposals)

    finally:
        release_global_evolution_lock(lock_fd)


# =============================================================================
# Global Evolution Helpers
# =============================================================================


# @invar:allow shell_result: Path resolution with fallback (no I/O failure case)
# @invar:allow shell_pure_logic: Path resolution with fallback
def _resolve_global_path() -> Path:
    """Resolve global directory path with fallback."""
    global_path_result = resolve_global_dir()
    if isinstance(global_path_result, Success):
        return global_path_result.unwrap()
    return Path.home() / ".config" / "lattice"


# @invar:allow shell_result: Project listing returns None on registry error or empty registry
# @invar:allow entry_point_too_thick: Project listing with error display
def _get_registered_projects() -> list[Any] | None:
    """Get list of registered projects, or None on error."""
    projects_result = list_projects()
    if isinstance(projects_result, Failure):
        typer.secho(
            f"Error listing projects: {projects_result.failure()}",
            fg=typer.colors.RED,
        )
        return None

    project_infos = projects_result.unwrap()
    if not project_infos:
        typer.secho(
            "No projects registered. Use 'lattice projects add <path>' to register projects.",
            fg=typer.colors.YELLOW,
        )
        return None

    return project_infos


# @invar:allow shell_result: Phase 1 (Rule Scan + Candidate Detection) returns None on scan failure
# @invar:allow entry_point_too_thick: Phase 1 execution with output display
def _run_phase1(compiler_config: Any) -> Any | None:
    """Execute Phase 1: Rule Scan + Candidate Detection. Returns output or None."""
    from lattice.shell.global_evolution import run_global_phase1

    typer.secho(
        "\n[Phase 1] Scanning project rules and traces...", fg=typer.colors.CYAN
    )
    phase1_result = run_global_phase1(compiler_config, None)

    if isinstance(phase1_result, Failure):
        typer.secho(
            f"Phase 1 failed: {phase1_result.failure()}",
            fg=typer.colors.RED,
        )
        return None

    output = phase1_result.unwrap()
    typer.secho(
        f"  Candidates found: {len(output.candidates)}",
        fg=typer.colors.CYAN,
    )
    return output


# @invar:allow shell_result: Phase 2 (Evidence Verification) returns None on verification failure
# @invar:allow entry_point_too_thick: Phase 2 execution with output display
def _run_phase2(
    candidates: list[Any],
    compiler_config: Any,
    project_infos: list[Any],
) -> Any | None:
    """Execute Phase 2: Evidence Verification. Returns output or None."""
    from lattice.shell.global_evolution import run_global_phase2
    from lattice.core.types.evidence import ProjectRegistration

    typer.secho("\n[Phase 2] Verifying evidence...", fg=typer.colors.CYAN)

    registrations = [
        ProjectRegistration(
            name=info.name,
            path=info.path,
            registered_at=info.registered_at,
        )
        for info in project_infos
        if info.exists
    ]

    phase2_result = run_global_phase2(candidates, compiler_config, registrations, None)

    if isinstance(phase2_result, Failure):
        typer.secho(
            f"Phase 2 failed: {phase2_result.failure()}",
            fg=typer.colors.RED,
        )
        return None

    output = phase2_result.unwrap()
    typer.secho(
        f"  Proposals generated: {len(output.proposals)}",
        fg=typer.colors.CYAN,
    )
    return output


# =============================================================================
# Evolution Helpers
# =============================================================================


# @invar:allow shell_result: Evolution lock acquisition returns None on lock conflict or I/O error
# @invar:allow shell_pure_logic: Lock file I/O wrapper returning file handle
def _acquire_evolution_lock(
    lock_path: Path,
) -> tuple[Any, Path] | None:
    """Acquire per-project evolution lock.

    Returns (lock_fd, lock_path) on success, None on failure.
    Raises typer.Exit on error.
    """
    import fcntl
    import sys

    # Ensure parent directory exists (lazy creation)
    lock_path.parent.mkdir(parents=True, exist_ok=True)

    try:
        lock_fd = open(lock_path, "w")
        fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
        lock_fd.write(f"{sys.argv[0]}\n")
        lock_fd.flush()
        return lock_fd, lock_path
    except BlockingIOError:
        typer.secho(
            "Another evolution is in progress. Try again later.",
            fg=typer.colors.YELLOW,
        )
        return None
    except OSError as e:
        typer.secho(f"Error acquiring lock: {e}", fg=typer.colors.RED)
        return None


def _release_evolution_lock(lock_fd: Any) -> None:
    """Release per-project evolution lock."""
    import fcntl

    if lock_fd is not None:
        fcntl.flock(lock_fd.fileno(), fcntl.LOCK_UN)
        lock_fd.close()


# @invar:allow shell_result: Evolution config loading returns None on missing model config or template file
def _load_evolution_config(
    lattice_dir: Path,
) -> tuple[Any, str] | None:
    """Load evolution config and prompt template.

    Returns (config, prompt_template) or None on error.

    Uses load_config_with_fallback for consistent config loading:
    1. Project-level config: .lattice/config.toml
    2. Global config: ~/.config/lattice/config.toml
    """
    from lattice.shell.config import load_config_with_fallback

    config_path = lattice_dir / "config.toml"
    prompt_path = lattice_dir.parent / "data" / "compiler_prompt.md"

    config, config_path_used = load_config_with_fallback(config_path)

    if not config.compiler.model:
        typer.secho(
            "No LLM model configured. Set [compiler].model in config.toml",
            fg=typer.colors.RED,
        )
        typer.secho(
            "  Project config: .lattice/config.toml",
            fg=typer.colors.YELLOW,
        )
        typer.secho(
            "  Global config: ~/.config/lattice/config.toml",
            fg=typer.colors.YELLOW,
        )
        typer.secho(
            "  Run 'lattice config init --global' to create global config",
            fg=typer.colors.CYAN,
        )
        return None

    if prompt_path.exists():
        prompt_template = prompt_path.read_text(encoding="utf-8")
    else:
        typer.secho(
            f"Prompt template not found at {prompt_path}",
            fg=typer.colors.RED,
        )
        return None

    return config, prompt_template


# @invar:allow shell_result: CLI output helper returns None (no error case)
# @invar:allow shell_pure_logic: Formatting helper, no I/O beyond typer output
def _format_proposal_summary(
    output: Any,
    proposal_path: str | Path,
    trace_path: str | Path,
) -> None:
    """Display evolution output summary."""
    typer.secho(f"\n✓ Evolution complete", fg=typer.colors.GREEN)
    typer.secho(
        f"  Sessions processed: {output.sessions_processed}", fg=typer.colors.CYAN
    )
    typer.secho(f"  Proposals generated: {len(output.proposals)}", fg=typer.colors.CYAN)
    typer.secho(f"  Trace: {trace_path}", fg=typer.colors.CYAN)

    if output.proposals:
        typer.secho(f"  Proposal: {proposal_path}", fg=typer.colors.CYAN)
        for i, prop in enumerate(output.proposals, 1):
            typer.secho(
                f"\n  [{i}] {prop.action.value}: {prop.title}",
                fg=typer.colors.YELLOW,
            )


# @invar:allow shell_result: CLI helper returns bool for success/failure
# @invar:allow shell_pure_logic: Auto-apply logic with typer output
def _try_auto_apply(
    project_path: Path,
    config: Any,
    proposals: list,
) -> bool:
    """Attempt auto-apply if configured. Returns True if auto-applied."""
    from lattice.shell.evolution import apply_proposal, list_proposals

    if not config.safety.auto_apply or not proposals:
        return False

    typer.secho("\nAuto-applying proposals...", fg=typer.colors.CYAN)
    proposals_list = list_proposals(project_path)
    if isinstance(proposals_list, Success) and proposals_list.unwrap():
        latest_proposal = proposals_list.unwrap()[-1]
        apply_result = apply_proposal(project_path, latest_proposal)
        if isinstance(apply_result, Failure):
            typer.secho(
                f"Auto-apply failed: {apply_result.failure()}",
                fg=typer.colors.RED,
            )
            return False
        typer.secho("✓ Proposals auto-applied", fg=typer.colors.GREEN)
        return True
    return False


# @invar:allow shell_result: Evolution execution returns None on LLM processing failure
# @invar:allow entry_point_too_thick: Evolution execution with config/rules/rules
def _execute_evolution(
    conn: Any,
    config: Any,
    prompt_template: str,
    current_rules: list[Any],
    full: bool,
) -> Any | None:
    """Execute evolution and return output, or None on error."""
    from lattice.core.rules import total_token_count
    from lattice.shell.evolution import EvolutionConfig, run_evolution

    current_token_count = total_token_count(current_rules)
    alert_tokens = config.thresholds.alert_tokens

    typer.secho(
        f"Running {'full' if full else 'incremental'} evolution...",
        fg=typer.colors.CYAN,
    )

    evolution_config = EvolutionConfig(
        batch_cap=config.compiler.batch_cap,
        session_token_cap=config.thresholds.per_session_tokens,
    )

    output_result = run_evolution(
        conn=conn,
        prompt_template=prompt_template,
        current_rules=current_rules,
        current_token_count=current_token_count,
        alert_tokens=alert_tokens,
        llm_config=config.compiler,
        evolution_config=evolution_config,
        full_mode=full,
    )

    if isinstance(output_result, Failure):
        typer.secho(
            f"Evolution failed: {output_result.failure()}",
            fg=typer.colors.RED,
        )
        return None

    return output_result.unwrap()


# @invar:allow shell_result: Proposal writing returns None on file write failure
# @invar:allow entry_point_too_thick: Proposal writing with error display
def _write_evolution_proposal(
    project_path: Path,
    output: Any,
) -> tuple[str, str] | None:
    """Write proposals and traces. Returns (proposal_path, trace_path) or None."""
    from lattice.shell.evolution import write_proposal

    write_result = write_proposal(
        project_path=project_path,
        proposals=output.proposals,
        cot_phases=output.cot_phases,
        sessions_processed=output.sessions_processed,
    )

    if isinstance(write_result, Failure):
        typer.secho(
            f"Error writing proposal: {write_result.failure()}",
            fg=typer.colors.RED,
        )
        return None

    return write_result.unwrap()


@app.command("evolve")
# @invar:allow entry_point_too_thick: Project evolution: config load + rule scan + LLM evolution + proposal write + auto-apply
def evolve_command(
    full: bool = typer.Option(False, "--full", help="Full recompile"),
    global_mode: bool = typer.Option(False, "--global", help="Global evolution"),
) -> None:
    """Run Project Compiler to evolve rules."""
    from lattice.shell.config import read_rules, resolve_project_dir
    from lattice.shell.schema import create_store

    if global_mode:
        return _run_global_evolution(full)

    dir_result = resolve_project_dir()
    if isinstance(dir_result, Failure):
        typer.secho(f"Error: {dir_result.failure()}", fg=typer.colors.RED)
        raise typer.Exit(1)

    project_path = dir_result.unwrap().parent
    lattice_dir = dir_result.unwrap()
    store_path = lattice_dir / "store.db"
    lock_path = lattice_dir / "evolve.lock"

    lock_result = _acquire_evolution_lock(lock_path)
    if lock_result is None:
        raise typer.Exit(1)
    lock_fd, _ = lock_result

    try:
        config_result = _load_evolution_config(lattice_dir)
        if config_result is None:
            raise typer.Exit(1)
        config, prompt_template = config_result

        rules_dir = lattice_dir / "rules"
        rules_result = read_rules(rules_dir)
        current_rules = (
            [] if isinstance(rules_result, Failure) else rules_result.unwrap()
        )

        store_result = create_store(store_path)
        if isinstance(store_result, Failure):
            typer.secho(
                f"Error opening store: {store_result.failure()}",
                fg=typer.colors.RED,
            )
            raise typer.Exit(1)
        conn = store_result.unwrap()

        output = _execute_evolution(conn, config, prompt_template, current_rules, full)
        if output is None:
            raise typer.Exit(1)

        paths = _write_evolution_proposal(project_path, output)
        if paths is None:
            raise typer.Exit(1)

        proposal_path, trace_path = paths
        _format_proposal_summary(output, proposal_path, trace_path)

        if not _try_auto_apply(project_path, config, output.proposals):
            if output.proposals:
                typer.secho(
                    "\nTo apply proposals: lattice apply <proposal_file>",
                    fg=typer.colors.YELLOW,
                )

    finally:
        _release_evolution_lock(lock_fd)


# =============================================================================
# Status Helpers
# =============================================================================


# @invar:allow shell_result: Pure formatting helper returns tuple (no error case)
# @invar:allow shell_pure_logic: Pure formatting helper
def _format_token_status(
    token_count: int,
    warn_tokens: int,
    alert_tokens: int,
) -> tuple[str, str]:
    """Format token budget status.

    Returns (color, status_text).
    """
    if token_count > alert_tokens:
        return typer.colors.RED, "⚠️  OVER ALERT THRESHOLD"
    elif token_count > warn_tokens:
        return typer.colors.YELLOW, "⚡ Over warning threshold"
    else:
        return typer.colors.GREEN, "✓ OK"


# @invar:allow entry_point_too_thick: Global status: DB query + project count + display
# @shell_complexity: Global store query + project count + conditional display logic
def _display_global_status() -> None:
    """Display global store status."""
    from lattice.shell.config import resolve_global_dir
    from lattice.shell.schema import create_global_store

    global_path_result = resolve_global_dir()
    if isinstance(global_path_result, Success):
        global_path = global_path_result.unwrap()
    else:
        global_path = Path.home() / ".config" / "lattice"
    global_db_path = global_path / "store" / "global.db"

    if not global_db_path.exists():
        typer.secho(
            "No global store found. Run 'lattice evolve --global' first.",
            fg=typer.colors.YELLOW,
        )
        raise typer.Exit(1)

    store_result = create_global_store(global_db_path)
    if isinstance(store_result, Failure):
        typer.secho(
            f"Error opening global store: {store_result.failure()}",
            fg=typer.colors.RED,
        )
        raise typer.Exit(1)
    conn = store_result.unwrap()

    try:
        cursor = conn.cursor()

        cursor.execute("SELECT COUNT(*) FROM evidence")
        evidence_count = cursor.fetchone()[0]

        projects_result = list_projects()
        if isinstance(projects_result, Success):
            project_count = len([p for p in projects_result.unwrap() if p.exists])
        else:
            project_count = 0

        cursor.execute("SELECT COUNT(*) FROM rule_evidence")
        association_count = cursor.fetchone()[0]

        typer.secho("\n🌍 Global Status", fg=typer.colors.CYAN, bold=True)
        typer.secho(f"  Registered projects: {project_count}", fg=typer.colors.WHITE)
        typer.secho(f"  Evidence records: {evidence_count}", fg=typer.colors.WHITE)
        typer.secho(f"  Rule associations: {association_count}", fg=typer.colors.WHITE)

    finally:
        conn.close()


# @invar:allow entry_point_too_thick: Project status display with config/rules/proposals
# @shell_complexity: Config load + rules read + proposal count + DB query + conditional display
def _display_project_status(lattice_dir: Path, project_path: Path) -> None:
    """Display project status."""
    from lattice.core.rules import total_token_count
    from lattice.shell.config import load_config_with_fallback, read_rules
    from lattice.shell.evolution import get_pending_session_count, list_proposals
    from lattice.shell.schema import create_store

    config_path = lattice_dir / "config.toml"
    config, _ = load_config_with_fallback(config_path)

    rules_dir = lattice_dir / "rules"
    rules_result = read_rules(rules_dir)
    current_rules = [] if isinstance(rules_result, Failure) else rules_result.unwrap()
    token_count = total_token_count(current_rules)

    store_path = lattice_dir / "store.db"
    store_result = create_store(store_path)
    if isinstance(store_result, Success):
        conn = store_result.unwrap()
        pending_result = get_pending_session_count(conn)
        pending_count = (
            pending_result.unwrap() if isinstance(pending_result, Success) else 0
        )
        conn.close()
    else:
        pending_count = 0

    proposals_result = list_proposals(project_path)
    proposals = (
        proposals_result.unwrap() if isinstance(proposals_result, Success) else []
    )

    typer.secho("\n📊 Project Status", fg=typer.colors.CYAN, bold=True)
    typer.secho(f"  Path: {project_path}", fg=typer.colors.WHITE)

    warn_tokens = config.thresholds.warn_tokens
    alert_tokens = config.thresholds.alert_tokens
    token_color, token_status = _format_token_status(
        token_count, warn_tokens, alert_tokens
    )
    typer.secho(f"\n  Rule tokens: {token_count}", fg=token_color)
    typer.secho(f"    Warning threshold: {warn_tokens}", fg=typer.colors.WHITE)
    typer.secho(f"    Alert threshold: {alert_tokens}", fg=typer.colors.WHITE)
    typer.secho(f"    Status: {token_status}", fg=token_color)

    typer.secho(
        f"\n  Sessions pending evolution: {pending_count}", fg=typer.colors.CYAN
    )

    typer.secho(f"\n  Pending proposals: {len(proposals)}", fg=typer.colors.CYAN)
    if proposals:
        for i, prop in enumerate(proposals, 1):
            typer.secho(f"    [{i}] {prop}", fg=typer.colors.WHITE)

    typer.secho(f"\n  Current rules: {len(current_rules)} files", fg=typer.colors.CYAN)
    for rule in current_rules[:5]:
        typer.secho(f"    - {rule.file_path}: {rule.title}", fg=typer.colors.WHITE)
    if len(current_rules) > 5:
        typer.secho(f"    ... and {len(current_rules) - 5} more", fg=typer.colors.WHITE)


@app.command("status")
# @invar:allow entry_point_too_thick: Status display: config/rules read + token count + session count + proposals list
def status_command(global_mode: bool = typer.Option(False, "--global")) -> None:
    """Show project status."""
    from lattice.shell.config import resolve_project_dir

    if global_mode:
        _display_global_status()
        return

    dir_result = resolve_project_dir()
    if isinstance(dir_result, Failure):
        typer.secho(f"Error: {dir_result.failure()}", fg=typer.colors.RED)
        raise typer.Exit(1)

    lattice_dir = dir_result.unwrap()
    project_path = lattice_dir.parent
    _display_project_status(lattice_dir, project_path)


@app.command("apply")
# @invar:allow entry_point_too_thick: Apply proposal: resolve project + read proposal content + confirm + apply rules
def apply_command(
    proposal: str = typer.Argument(..., help="Proposal file"),
    global_mode: bool = typer.Option(False, "--global"),
) -> None:
    """Apply a pending proposal."""
    from lattice.shell.config import resolve_project_dir
    from lattice.shell.evolution import apply_proposal, read_proposal

    if global_mode:
        typer.secho(
            "Global proposals are written directly to ~/.lattice/rules/. "
            "No apply step needed.",
            fg=typer.colors.YELLOW,
        )
        raise typer.Exit(0)

    # Resolve project directory
    dir_result = resolve_project_dir()
    if isinstance(dir_result, Failure):
        typer.secho(f"Error: {dir_result.failure()}", fg=typer.colors.RED)
        raise typer.Exit(1)

    project_path = dir_result.unwrap().parent

    # Read proposal content to display it
    content_result = read_proposal(project_path, proposal)
    if isinstance(content_result, Failure):
        typer.secho(
            f"Error reading proposal: {content_result.failure()}",
            fg=typer.colors.RED,
        )
        raise typer.Exit(1)

    content = content_result.unwrap()
    typer.secho("\n📝 Proposal content:", fg=typer.colors.CYAN)
    typer.echo(content[:500] + ("..." if len(content) > 500 else ""))

    # Confirm
    if not typer.confirm("\nApply this proposal?"):
        typer.secho("Cancelled.", fg=typer.colors.YELLOW)
        raise typer.Exit(0)

    # Apply
    typer.secho("\nApplying proposal...", fg=typer.colors.CYAN)
    apply_result = apply_proposal(project_path, proposal)
    if isinstance(apply_result, Failure):
        typer.secho(
            f"Error applying proposal: {apply_result.failure()}",
            fg=typer.colors.RED,
        )
        raise typer.Exit(1)

    typer.secho(f"✓ Applied: {proposal}", fg=typer.colors.GREEN)
    typer.secho("  Rules updated. Use 'lattice revert' to undo.", fg=typer.colors.CYAN)


@app.command("revert")
# @invar:allow entry_point_too_thick: Revert rules: resolve project + confirm + database connection + backup restore
def revert_command(global_mode: bool = typer.Option(False, "--global")) -> None:
    """Revert last rule change."""
    from lattice.shell.config import resolve_project_dir
    from lattice.shell.evolution import revert_rules

    if global_mode:
        typer.secho(
            "Global rules revert not implemented. Restore from ~/.lattice/backups/ manually.",
            fg=typer.colors.YELLOW,
        )
        raise typer.Exit(1)

    # Resolve project directory
    dir_result = resolve_project_dir()
    if isinstance(dir_result, Failure):
        typer.secho(f"Error: {dir_result.failure()}", fg=typer.colors.RED)
        raise typer.Exit(1)

    project_path = dir_result.unwrap().parent
    lattice_dir = dir_result.unwrap()

    # Confirm
    if not typer.confirm("Revert to the previous rule backup?"):
        typer.secho("Cancelled.", fg=typer.colors.YELLOW)
        raise typer.Exit(0)

    # Open database connection for metadata update
    from lattice.shell.schema import create_store

    store_path = lattice_dir / "store.db"
    conn_result = create_store(store_path)
    conn = conn_result.unwrap() if isinstance(conn_result, Success) else None

    # Revert
    typer.secho("\nReverting rules...", fg=typer.colors.CYAN)
    revert_result = revert_rules(project_path, conn)
    if isinstance(revert_result, Failure):
        typer.secho(
            f"Error reverting rules: {revert_result.failure()}",
            fg=typer.colors.RED,
        )
        if conn:
            conn.close()
        raise typer.Exit(1)

    if conn:
        conn.close()

    backup_path = revert_result.unwrap()
    typer.secho(f"✓ Reverted to: {backup_path}", fg=typer.colors.GREEN)


@app.command("ingest")
# @invar:allow entry_point_too_thick: Ingest: resolve project + config load + JSON parse + store open + event/legacy insertion + embedding
def ingest_command(
    session_id: str | None = typer.Option(None, "--session", "-s"),
    role: str | None = typer.Option(None, "--role", "-r"),
    stdin: bool = typer.Option(
        False, "--stdin", help="Accept { session_id, events } JSON payload"
    ),
    cleanup: bool = typer.Option(
        False, "--cleanup", help="Delete events by message_id (cleanup mode)"
    ),
) -> None:
    """Ingest message from stdin.

    Legacy mode (default): Accepts { content, role?, session_id?, metadata? }
    New mode (--stdin): Accepts { session_id, events: [{ type, content, tool_input?, tool_status?, tool_error?, message_id? }] }
    Cleanup mode (--cleanup): Accepts { session_id, delete_message } to delete events by message_id
    """
    import json
    import sys

    from lattice.core.sanitizer import sanitize
    from lattice.core.types.enums import Role
    from lattice.shell.config import load_config_with_fallback, resolve_project_dir
    from lattice.shell.embeddings import embed_text, insert_embedding
    from lattice.shell.schema import create_store
    from lattice.shell.store import (
        delete_events_by_message_id,
        generate_external_id,
        generate_session_id,
        insert_event,
        insert_log,
    )

    # Resolve project directory
    dir_result = resolve_project_dir()
    if isinstance(dir_result, Failure):
        typer.secho(f"Error: {dir_result.failure()}", fg=typer.colors.RED)
        raise typer.Exit(1)

    lattice_dir = dir_result.unwrap()
    project_path = lattice_dir.parent

    # Check if project is excluded (lazy init)
    excluded_result = is_excluded(str(project_path))
    if isinstance(excluded_result, Failure):
        typer.secho(
            f"  [lattice] Warning: could not check exclusion list: {excluded_result.failure()}",
            fg=typer.colors.YELLOW,
            err=True,
        )
    elif excluded_result.unwrap():
        # Silently exit - project is excluded
        raise typer.Exit(0)

    store_path = lattice_dir / "store.db"

    # Read config for embedding and safety settings
    config_path = lattice_dir / "config.toml"
    config, _ = load_config_with_fallback(config_path)

    # Read JSON from stdin
    try:
        input_data = json.load(sys.stdin)
    except json.JSONDecodeError as e:
        typer.secho(f"Invalid JSON input: {e}", fg=typer.colors.RED)
        raise typer.Exit(1)

    # Open store
    store_result = create_store(store_path)
    if isinstance(store_result, Failure):
        typer.secho(
            f"Error opening store: {store_result.failure()}", fg=typer.colors.RED
        )
        raise typer.Exit(1)

    conn = store_result.unwrap()

    # Auto-register project if not already registered (lazy init)
    # Use resolved path to handle symlinks (e.g. /var -> /private/var on macOS)
    existing = find_project_by_path(str(project_path.resolve()))
    if isinstance(existing, Failure):
        from lattice.shell.projects import generate_unique_project_name

        project_name = project_path.name

        # Handle collision: append parent dir name if needed
        all_projects = load_projects()
        if isinstance(all_projects, Success):
            names = [p.name for p in all_projects.unwrap()]
            project_name = generate_unique_project_name(
                project_name, project_path.parent.name, names
            )

        # Register silently
        reg_result = register_project(str(project_path), project_name)
        if isinstance(reg_result, Success):
            typer.secho(f"  Registered: {project_name}", fg=typer.colors.CYAN, err=True)

    try:
        # Cleanup mode: delete events by message_id
        if cleanup:
            _handle_cleanup(conn, input_data)
        # New mode: --stdin with events array
        elif stdin or "events" in input_data:
            _ingest_events(conn, input_data, config, session_id)
        else:
            # Legacy mode: single message
            _ingest_legacy(conn, input_data, config, session_id, role)
    finally:
        conn.close()


# =============================================================================
# Ingest Helpers
# =============================================================================


def _handle_cleanup(conn, input_data: dict) -> None:
    """Handle cleanup mode: delete events by message_id.

    Args:
        conn: SQLite connection to store.db.
        input_data: JSON payload with session_id and delete_message.

    Raises:
        typer.Exit: On missing fields or database error.
    """
    from lattice.shell.store import delete_events_by_message_id

    session_id = input_data.get("session_id")
    delete_message = input_data.get("delete_message")

    if not session_id:
        typer.secho(
            "Missing 'session_id' field in cleanup payload", fg=typer.colors.RED
        )
        raise typer.Exit(1)

    if not delete_message:
        typer.secho(
            "Missing 'delete_message' field in cleanup payload", fg=typer.colors.RED
        )
        raise typer.Exit(1)

    delete_result = delete_events_by_message_id(conn, session_id, delete_message)

    if isinstance(delete_result, Failure):
        typer.secho(
            f"Error deleting events: {delete_result.failure()}",
            fg=typer.colors.RED,
        )
        raise typer.Exit(1)

    deleted_count = delete_result.unwrap()
    typer.secho(
        f"✓ Deleted {deleted_count} events with message_id: {delete_message}",
        fg=typer.colors.GREEN,
    )


# @shell_complexity: Event validation and sanitization with multiple conditionals
def _ingest_events(
    conn,
    input_data: dict,
    config,
    session_id_override: str | None,
) -> None:
    """Ingest events in new format: { session_id, events: [...] }."""
    from lattice.core.sanitizer import sanitize
    from lattice.shell.store import generate_session_id, insert_event

    # Get session_id
    session_id = session_id_override or input_data.get("session_id")
    if session_id is None:
        session_id = generate_session_id()

    events = input_data.get("events", [])
    if not events:
        typer.secho("No events to ingest", fg=typer.colors.YELLOW)
        return

    # Sanitize secret patterns for non-tool events
    secret_patterns = config.safety.secret_patterns if config.safety else []

    inserted_count = 0
    for event in events:
        event_type = event.get("type", "user")
        content = event.get("content", "")

        if not content:
            continue

        # Sanitize non-tool events
        sanitized_content = content
        if event_type != "tool" and secret_patterns:
            sanitized_content = sanitize(content, secret_patterns, "[REDACTED]")

        insert_result = insert_event(
            conn,
            session_id,
            event_type,
            sanitized_content,
            tool_input=event.get("input") or event.get("tool_input"),
            tool_status=event.get("status") or event.get("tool_status"),
            tool_error=event.get("error") or event.get("tool_error"),
            message_id=event.get("message_id"),
        )

        if isinstance(insert_result, Success):
            inserted_count += 1

    typer.secho(f"✓ Ingested {inserted_count} events", fg=typer.colors.GREEN)
    typer.secho(f"  Session: {session_id[:8]}...", fg=typer.colors.CYAN)


# @shell_complexity: Legacy format parsing with fallback handling
def _ingest_legacy(
    conn,
    input_data: dict,
    config,
    session_id_override: str | None,
    role_override: str | None,
) -> None:
    """Ingest single message in legacy format."""
    from lattice.core.sanitizer import sanitize
    from lattice.core.types.enums import Role
    from lattice.shell.embeddings import embed_text, insert_embedding
    from lattice.shell.store import generate_session_id, insert_log

    content = input_data.get("content")
    if not content:
        typer.secho("Missing 'content' field in input", fg=typer.colors.RED)
        raise typer.Exit(1)

    # Get or generate session_id
    session_id = session_id_override or input_data.get("session_id")
    if session_id is None:
        session_id = generate_session_id()

    # Get role
    role_str = role_override or input_data.get("role", "user")
    try:
        msg_role = Role(role_str.lower())
    except ValueError:
        msg_role = Role.USER

    # Sanitize content
    secret_patterns = config.safety.secret_patterns if config.safety else []
    if secret_patterns:
        content = sanitize(content, secret_patterns, "[REDACTED]")

    # Extract metadata
    metadata = input_data.get("metadata")

    # Insert log
    insert_result = insert_log(conn, session_id, msg_role, content, metadata)
    if isinstance(insert_result, Failure):
        typer.secho(
            f"Error inserting log: {insert_result.failure()}", fg=typer.colors.RED
        )
        raise typer.Exit(1)

    log_id = insert_result.unwrap()

    # Generate and insert embedding if configured
    if config.embedding:
        embed_result = embed_text(config.embedding, content)
        if isinstance(embed_result, Success):
            embedding = embed_result.unwrap()
            emb_insert_result = insert_embedding(conn, log_id, embedding)
            if isinstance(emb_insert_result, Failure):
                typer.secho(
                    f"Warning: Failed to insert embedding: {emb_insert_result.failure()}",
                    fg=typer.colors.YELLOW,
                )

    typer.secho(f"✓ Ingested log {log_id}", fg=typer.colors.GREEN)
    typer.secho(f"  Session: {session_id}", fg=typer.colors.CYAN)
    typer.secho(f"  Role: {msg_role.value}", fg=typer.colors.CYAN)


# =============================================================================
# Search Helpers
# =============================================================================


# @invar:allow shell_pure_logic: Formatting helper for search results
def _display_search_results(
    results: list[Any],
    global_mode: bool,
) -> None:
    """Display search results in formatted output."""
    if not results:
        typer.secho("No results found.", fg=typer.colors.YELLOW)
        return

    typer.secho(f"\n🔍 Found {len(results)} results:", fg=typer.colors.CYAN)

    for i, result in enumerate(results, 1):
        if global_mode:
            _display_global_search_result(i, result)
        else:
            _display_project_search_result(i, result)


# @invar:allow shell_pure_logic: Formatting helper for global search result
def _display_global_search_result(index: int, result: Any) -> None:
    """Display a single global search result."""
    global_result = cast(dict[str, Any], result)
    score = global_result.get("score", 0) if isinstance(global_result, dict) else 0
    source = (
        global_result.get("source_project", "unknown")
        if isinstance(global_result, dict)
        else "unknown"
    )
    summary = (
        global_result.get("summary", "") if isinstance(global_result, dict) else ""
    )
    typer.secho(f"\n[{index}] Score: {score:.4f}", fg=typer.colors.WHITE)
    typer.secho(f"    Source: {source}", fg=typer.colors.CYAN)
    typer.secho(f"    {summary[:200]}...", fg=typer.colors.WHITE)


# @invar:allow shell_pure_logic: Formatting helper for project search result
def _display_project_search_result(index: int, result: Any) -> None:
    """Display a single project search result."""
    project_result = cast(SearchResult, result)
    score = getattr(project_result, "rrf_score", 0)
    session_id = getattr(project_result, "session_id", "unknown")
    role = getattr(project_result, "role", None)
    content = getattr(project_result, "content", "") or ""
    typer.secho(f"\n[{index}] Score: {score:.4f}", fg=typer.colors.WHITE)
    typer.secho(f"    Session: {session_id[:8]}...", fg=typer.colors.CYAN)
    if role:
        typer.secho(f"    Role: {role.value}", fg=typer.colors.CYAN)
    typer.secho(f"    {content[:200]}...", fg=typer.colors.WHITE)


@app.command("search")
# @invar:allow entry_point_too_thick: Search: resolve store path + config load + store open + embedding + hybrid search
def search_command(
    query: str = typer.Argument(..., help="Search query"),
    limit: int = typer.Option(10, "--limit", "-l"),
    global_mode: bool = typer.Option(False, "--global"),
) -> None:
    """Search project memory."""
    from lattice.shell.config import (
        load_config_with_fallback,
        resolve_project_dir,
        resolve_global_dir,
    )
    from lattice.shell.embeddings import embed_text
    from lattice.shell.hybrid_search import hybrid_search, hybrid_search_global
    from lattice.shell.schema import create_store, create_global_store

    # Resolve store path based on mode
    if global_mode:
        dir_result = resolve_global_dir()
        if isinstance(dir_result, Failure):
            typer.secho(f"Error: {dir_result.failure()}", fg=typer.colors.RED)
            raise typer.Exit(1)
        store_path = dir_result.unwrap() / "global.db"
    else:
        dir_result = resolve_project_dir()
        if isinstance(dir_result, Failure):
            typer.secho(f"Error: {dir_result.failure()}", fg=typer.colors.RED)
            raise typer.Exit(1)
        store_path = dir_result.unwrap() / "store.db"

    # Read config for embedding settings
    config_path = dir_result.unwrap() / "config.toml" if not global_mode else None
    config, _ = load_config_with_fallback(config_path)

    # Open store
    if global_mode:
        store_result = create_global_store(store_path)
    else:
        store_result = create_store(store_path)
    if isinstance(store_result, Failure):
        typer.secho(
            f"Error opening store: {store_result.failure()}",
            fg=typer.colors.RED,
        )
        raise typer.Exit(1)

    conn = store_result.unwrap()

    try:
        # Generate embedding if configured
        query_embedding = None
        if config.embedding:
            embed_result = embed_text(config.embedding, query)
            if isinstance(embed_result, Success):
                query_embedding = embed_result.unwrap()
            elif isinstance(embed_result, Failure):
                typer.secho(
                    f"Warning: Failed to generate embedding: {embed_result.failure()}",
                    fg=typer.colors.YELLOW,
                )

        # Execute search
        if global_mode:
            search_result = hybrid_search_global(conn, query, query_embedding, limit)
        else:
            search_result = hybrid_search(conn, query, query_embedding, limit)

        if isinstance(search_result, Failure):
            typer.secho(
                f"Search failed: {search_result.failure()}", fg=typer.colors.RED
            )
            raise typer.Exit(1)

        results = search_result.unwrap()
        _display_search_results(results, global_mode)

    finally:
        conn.close()


if __name__ == "__main__":
    app()
