"""Run and spawn command implementations.

Spec source: plan step ``cli-refactor.cli-refactor-verify-cli-refactor-fix``
extracts oversized run and spawn commands from cli.py without behavior changes.

Adapter boundary note: CLI remains responsible for argument parsing and output
presentation only. Launch orchestration ownership moves to
``anima.app.launch_service``.
"""

# @invar:allow function_size: CLI command implementations with subprocess management
# @invar:allow shell_result: run/spawn command paths must surface subprocess failures as exit codes
# @invar:allow file_size: Large CLI module with helper functions (spawn passthrough added 2 lines)

from __future__ import annotations

import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any, Literal

from pydantic import ValidationError
from returns.result import Failure, Success
from rich.console import Console
from rich.panel import Panel

from anima.app.launch_service import (
    DefaultLaunchService,
    LaunchOrigin,
    LaunchRequest,
)
from anima.core.ids import generate_instance_id, generate_trace_id, validate_instance_id
from anima.core.execution import (
    ExecutionIntent,
    ExecutionIntentDefaults,
    ExecutionIntentOverrides,
    ResolvedExecutionContract,
    resolve_execution_contract,
)
from anima.core.lifecycle import LifecycleEvent, is_terminal
from anima.core.sandbox import SandboxPolicy, format_network_block_error
from anima.core.schemas import (
    AgentStateData,
    AnimaGlobalConfig,
    AgentResult,
    CapabilityToken,
    CompletionPolicy,
    PersonaSpec,
    JobSpec,
    LifecycleType,
    ToolPosture,
    numeric_code_to_string,
)
from anima.core.tool_resolver import resolve_tool_presets
from anima.shell.cli_params import validate_llm_base_url
from anima.shell.cli_utils import (
    build_job_spec,
    load_persona_or_synthesize,
    output_spawn_result,
)
from anima.shell.mcp_servers import parse_mcp_servers
from anima.shell.model_config import load_global_config
from anima.shell.model_config import resolve_execution_output_policy_defaults
from anima.shell.state import (
    delete_agent_state,
    get_agent_dir,
    read_state,
    transition_state,
    create_agent_state,
    write_agent_pid,
    write_result,
)

EXIT_AGENT_NOT_FOUND = 2


# @invar:allow shell_result: CLI helper returns PersonaSpec for subsequent wiring
def _load_persona_for_run_spawn(
    *,
    persona: str | None,
    system_prompt: str | None,
    model: str | None,
    tools: tuple[str, ...],
    mcp: tuple[str, ...],
) -> PersonaSpec:
    """Load persona for run/spawn commands.

    Tools are optional in PersonaSpec — CLI ``--tools`` and ``--mcp`` are merged
    after persona construction.
    """
    return load_persona_or_synthesize(
        persona=persona,
        system_prompt=system_prompt,
        model=model,
    )


# @invar:allow shell_result: CLI helper for sandbox banner display
# @shell_complexity: Spec-source sandbox-srt.ux-banner; verbose mode requires per-field status lines
def _print_sandbox_banner(
    policy: "SandboxPolicy",
    verbose: bool,
    json_output: bool,
    console: Console,
) -> None:
    """Display sandbox status banner based on verbosity mode.

    Spec source: sandbox-srt.ux-banner

    Modes:
        - normal (verbose=False, json=False): One dim sandbox line
        - verbose (verbose=True): 3-line banner with details + disable hint
        - quiet (json_output=True): Suppress banner (but handlers still show errors)

    Args:
        policy: SandboxPolicy with enabled/allow_fs/allow_net.
        verbose: If True, show detailed 3-line banner.
        json_output: If True, suppress all banners.
        console: Rich console for output.
    """
    # Spec source: sandbox-srt.ux-banner
    # json_output always suppresses human-oriented banners
    if json_output:
        return

    # Sandbox disabled: no banner
    if not policy.enabled:
        return

    # Verbose mode: 3-line banner with network/filesystem/secrets status + disable hint
    if verbose:
        # Build status lines
        lines = []

        # Network status
        if policy.allow_net:
            if "*" in policy.allow_net:
                net_status = "[green]allowed[/green] (all domains)"
            else:
                domains_str = ", ".join(policy.allow_net[:3])
                if len(policy.allow_net) > 3:
                    domains_str += f", +{len(policy.allow_net) - 3} more"
                net_status = f"[green]allowed[/green] ({domains_str})"
        else:
            net_status = "[yellow]blocked[/yellow]"

        lines.append(f"[bold]Network:[/] {net_status}")

        # Filesystem status
        if policy.allow_fs:
            paths_str = ", ".join(policy.allow_fs[:3])
            if len(policy.allow_fs) > 3:
                paths_str += f", +{len(policy.allow_fs) - 3} more"
            fs_status = f"[green]allowed[/green] ({paths_str})"
        else:
            fs_status = "[yellow]blocked[/yellow]"

        lines.append(f"[bold]Filesystem:[/] {fs_status}")

        # Secrets status (SRT doesn't have explicit secrets allowance, blocked by default)
        lines.append("[bold]Secrets:[/] [yellow]blocked[/yellow]")

        # Disable hint
        lines.append("[dim]Tip: Use --no-sandbox to disable sandbox protection[/dim]")

        panel = Panel(
            "\n".join(lines),
            title="[bold]Sandbox Active[/bold]",
            border_style="blue",
        )
        console.print(panel)
    else:
        # Normal mode: one dim line
        # Summarize capabilities briefly
        caps = []
        if policy.allow_net:
            caps.append("net")
        if policy.allow_fs:
            caps.append("fs")

        if caps:
            console.print(f"[dim]Sandbox active (allows: {', '.join(caps)})[/dim]")
        else:
            console.print("[dim]Sandbox active (restricted)[/dim]")


# @invar:allow shell_result: CLI helper validates input and exits on invalid token file
# @shell_complexity: CLI command with async subprocess orchestration and result handling
def _load_capability_token(token_file: str, console: Console) -> CapabilityToken | None:
    """Load and validate CapabilityToken JSON from file.

    Source: docs/INTERFACES.md Section E.5 (CapabilityToken schema) and
    Section H.5 (anima reads --token-file at startup).
    """
    token_path = Path(token_file)
    if not token_path.exists():
        console.print(f"[red]Error: Token file not found: {token_file}[/red]")
        sys.exit(1)

    try:
        token_data = json.loads(token_path.read_text())
        return CapabilityToken(**token_data)
    except (OSError, json.JSONDecodeError, ValidationError) as e:
        console.print(f"[red]Error loading token: {e}[/red]")
        sys.exit(1)


# @invar:allow shell_result: CLI validation helper exits via sys.exit on invalid input
def _validate_mcp_values(
    mcp_values: tuple[str, ...],
    console: Console,
    capability_token: CapabilityToken | None = None,
) -> list[Any] | None:
    """Validate and parse ``--mcp`` values.

    Spec source: verify-wire-params blocker #1 requires malformed ``--mcp``
    values to be reported as user-facing errors in both run and spawn paths.

    Spec source: docs/INTERFACES.md Section H.5 (Token Transport)
    CapabilityToken is passed to MCP servers via client_info.capability_token.
    """
    try:
        return parse_mcp_servers(
            list(mcp_values) if mcp_values else None,
            capability_token=capability_token,
        )
    except ValueError as exc:
        console.print(f"[red]Error: Invalid --mcp value: {exc}[/red]")
        sys.exit(1)


# @invar:allow shell_result: CLI validation helper exits on unknown preset
# @shell_complexity: Branches for empty presets, unknown names, config load error
def _resolve_tools_to_mcp_servers(
    tools: tuple[str, ...],
    config: AnimaGlobalConfig,
    console: Console,
) -> list[object]:
    """Resolve tool preset names from config to MCP server specs.

    Spec source: vectl step ``tool-presets.cli-flag`` requires tool preset
    names to be resolved via core resolver and converted to MCPServerStdio.

    Args:
        tools: Tuple of tool preset names from CLI --tools flags.
        config: Global config containing tool preset definitions.
        console: Rich console for error output.

    Returns:
        List of MCPServerStdio objects for resolved tool presets.

    Raises:
        SystemExit: If any preset name is not found in config.
    """
    if not tools:
        return []

    from anima.core.tool_resolver import ToolPresetInput
    from anima.shell.mcp_servers import build_mcp_server_stdio

    # Convert config tools to resolver's ToolPresetInput format
    # Spec source: sandbox-srt.cli-flags - preserve sandbox/sandbox_allow for resolution
    preset_dict: dict[str, ToolPresetInput] = {
        name: ToolPresetInput(
            command=preset.command,
            args=list(preset.args) if preset.args else [],
            env=dict(preset.env) if preset.env else {},
            sandbox=preset.sandbox if preset.sandbox is not None else False,
            sandbox_allow=list(preset.sandbox_allow) if preset.sandbox_allow else [],
        )
        for name, preset in config.tools.items()
    }

    variables = {"CWD": os.getcwd(), "HOME": str(Path.home())}
    try:
        resolved = resolve_tool_presets(list(tools), preset_dict, variables)
    except ValueError as exc:
        # Unknown preset name
        console.print(f"[red]Error: {exc}[/red]")
        sys.exit(1)

    # Convert ResolvedTool to MCPServerStdio
    # Note: sandbox/sandbox_allow are NOT used here for MCP server creation;
    # they're collected separately for global sandbox policy (see run_impl)
    mcp_servers: list[object] = []
    for tool in resolved:
        server = build_mcp_server_stdio(
            command=tool["command"],
            args=tool["args"],
            env=tool["env"] if tool["env"] else None,
        )
        mcp_servers.append(server)

    return mcp_servers


# @invar:allow shell_result: CLI validation helper exits on unknown preset
# @shell_complexity: Returns CLI args for subprocess, not MCPServer objects
def _resolve_tools_to_mcp_cmd_strings(
    tools: tuple[str, ...],
    config: AnimaGlobalConfig,
    console: Console,
) -> list[str]:
    """Resolve tool preset names to --mcp command strings for spawn subprocess.

    Spec source: vectl step tool-presets.spawn-passthrough requires tool presets
    to be resolved at spawn time and passed as explicit --mcp args to subprocess.
    This converts ResolvedTool to CLI command strings (e.g., "npx @anthropic/mcp-server").

    Args:
        tools: Tuple of tool preset names from CLI --tools flags.
        config: Global config containing tool preset definitions.
        console: Rich console for error output.

    Returns:
        List of --mcp argument values (command strings) to pass to subprocess.
        E.g., ["npx @anthropic/mcp-filesystem --root /tmp", "python -m mytool"]

    Raises:
        SystemExit: If any preset name is not found in config.
    """
    if not tools:
        return []

    from anima.core.tool_resolver import ToolPresetInput

    # Convert config tools to resolver's ToolPresetInput format
    # Spec source: sandbox-srt.cli-flags - preserve sandbox/sandbox_allow for resolution
    preset_dict: dict[str, ToolPresetInput] = {
        name: ToolPresetInput(
            command=preset.command,
            args=list(preset.args) if preset.args else [],
            env=dict(preset.env) if preset.env else {},
            sandbox=preset.sandbox if preset.sandbox is not None else False,
            sandbox_allow=list(preset.sandbox_allow) if preset.sandbox_allow else [],
        )
        for name, preset in config.tools.items()
    }

    variables = {"CWD": os.getcwd(), "HOME": str(Path.home())}
    try:
        resolved = resolve_tool_presets(list(tools), preset_dict, variables)
    except ValueError as exc:
        # Unknown preset name
        console.print(f"[red]Error: {exc}[/red]")
        sys.exit(1)

    # Convert ResolvedTool to CLI command strings for subprocess --mcp
    # Format: "command arg1 arg2" (same format as manual --mcp argument)
    # With env passthrough: "command arg1 arg2 |env: KEY1=VALUE1,KEY2=VALUE2"
    # Note: sandbox/sandbox_allow are NOT included in command strings;
    # they're collected separately for global sandbox policy (see run_impl)
    cmd_strings: list[str] = []
    for tool in resolved:
        # Build command string: command + args
        parts = [tool["command"]] + tool["args"]
        # Use shlex.join for proper quoting if needed, but for subprocess args
        # we want space-separated that agent_runner will parse with shlex
        cmd_string = " ".join(parts)

        # Encode env variables if present (preserve for spawned subprocess)
        # Format: " |env: KEY1=VALUE1,KEY2=VALUE2"
        if tool.get("env"):
            # Encode env vars in format that agent_runner can decode
            # Use comma-separated KEY=VALUE pairs (values may need escaping for commas)
            env_parts = []
            for k, v in tool["env"].items():
                # Escape commas in values (use \, for literal comma)
                escaped_value = v.replace(",", "\\,")
                env_parts.append(f"{k}={escaped_value}")
            cmd_string += " |env: " + ",".join(env_parts)

        cmd_strings.append(cmd_string)

    return cmd_strings


# @invar:allow shell_result: CLI helper returns preset-name tuple for local wiring
# @shell_complexity: Persona posture filtering includes none skip and unknown preset warning
def _collect_persona_tool_presets(
    persona: PersonaSpec,
    capability_token: CapabilityToken | None,
    config: AnimaGlobalConfig,
    console: Console,
) -> tuple[str, ...]:
    """Return local tool preset names derived from PersonaSpec.tools.

    Spec source: persona-tools-map.persona-tools-map-fix-gate-blockers.
    Persona tool map keys are used as standalone preset names only when no
    capability token is present. CapabilityToken.tools_profile remains a scoped
    compatibility exception handled by token transport.
    """
    if capability_token is not None or not persona.tools:
        return ()

    available_presets = set(config.tools.keys())
    selected: list[str] = []
    for name, posture in persona.tools.items():
        if posture == ToolPosture.none:
            continue
        if name not in available_presets:
            console.print(
                f"[yellow]Warning: Unknown tool preset '{name}' from persona.tools; skipping[/yellow]"
            )
            continue
        selected.append(name)

    return tuple(selected)


# @invar:allow shell_result: CLI validation helper exits via sys.exit on invalid LLM URL
def _validate_llm_base_url_or_exit(llm_base_url: str | None, console: Console) -> None:
    """Validate ``--llm-base-url`` and exit with clear error on failure."""
    try:
        validate_llm_base_url(llm_base_url)
    except ValueError as exc:
        console.print(f"[red]Error: {exc}[/red]")
        sys.exit(1)


# @invar:allow shell_orchestration: Config transformation for CLI command setup
# @invar:allow shell_complexity: Dict construction and preset resolution requires branching
# @invar:allow shell_result: CLI helper returns sandbox info, does not exit
# @shell_complexity: Preset sandbox merge traverses optional config, resolver errors, and aggregated allow-lists
# @shell_orchestration: Sandbox preset aggregation is command-layer orchestration for spawn wiring
def _collect_preset_sandbox_config(
    tools: tuple[str, ...],
    config: AnimaGlobalConfig,
) -> tuple[bool, list[str]]:
    """Collect sandbox configuration from resolved tool presets.

    Spec source: docs/INTERFACES.md A.2 - CLI + config sandbox_allow are unioned
    Tool presets contribute their sandbox_allow to the global policy.

    Args:
        tools: Tuple of tool preset names from CLI --tools flags.
        config: Global config containing tool preset definitions.

    Returns:
        Tuple of (preset_sandbox_enabled, preset_sandbox_allows) where:
        - preset_sandbox_enabled: True if any preset has sandbox=True
        - preset_sandbox_allows: Union of all preset sandbox_allow lists
    """
    from anima.core.tool_resolver import ToolPresetInput

    preset_sandbox_enabled = False
    preset_sandbox_allows: list[str] = []

    if not tools or not config.tools:
        return preset_sandbox_enabled, preset_sandbox_allows

    # Convert config tools to resolver's ToolPresetInput format
    preset_dict: dict[str, ToolPresetInput] = {
        name: ToolPresetInput(
            command=preset.command,
            args=list(preset.args) if preset.args else [],
            env=dict(preset.env) if preset.env else {},
            sandbox=preset.sandbox if preset.sandbox is not None else False,
            sandbox_allow=list(preset.sandbox_allow) if preset.sandbox_allow else [],
        )
        for name, preset in config.tools.items()
    }

    variables = {"CWD": os.getcwd(), "HOME": str(Path.home())}
    try:
        resolved = resolve_tool_presets(list(tools), preset_dict, variables)
        # Any preset with sandbox=True enables sandbox for the run
        preset_sandbox_enabled = any(tool.get("sandbox", False) for tool in resolved)
        # Union all preset sandbox_allow lists
        for tool in resolved:
            if tool.get("sandbox_allow"):
                preset_sandbox_allows.extend(tool["sandbox_allow"])
    except ValueError:
        # Error already reported in _resolve_tools_to_mcp_servers or _resolve_tools_to_mcp_cmd_strings
        pass

    return preset_sandbox_enabled, preset_sandbox_allows


# @invar:allow shell_result: CLI presentation helper returns optional inline text
# @shell_complexity: Branches distinguish non-file refs, missing files, and read errors
def _read_result_ref_file_content(result_ref: str | None) -> str | None:
    """Return inline content when ``result_ref`` points to a local file.

    Spec source: ``cli-refactor.enhance-collect-auto-read`` requires ``collect``
    to auto-read and inline file content when ``result_ref`` is a file path.
    Non-file result references must preserve existing behavior.
    """
    if not result_ref:
        return None

    # Non-file references such as URIs (s3://, https://, etc.) remain as refs.
    if "://" in result_ref:
        return None

    result_path = Path(result_ref).expanduser()
    if not result_path.is_file():
        return None

    try:
        return result_path.read_text(encoding="utf-8")
    except (OSError, UnicodeDecodeError):
        return None


# @invar:allow shell_result: CLI helper exits on invalid or unreadable global config
def _load_global_config_or_exit(console: Console) -> AnimaGlobalConfig:
    """Load global config or exit with user-facing error."""
    config_result = load_global_config()
    if isinstance(config_result, Failure):
        console.print(
            f"[red]Error loading global config: {config_result.failure()}[/red]"
        )
        sys.exit(1)
    return config_result.unwrap()


# @invar:allow shell_result: CLI helper validates instance_id and exits on invalid input
def _resolve_instance_id_or_exit(instance_id: str | None, console: Console) -> str:
    """Generate or validate instance_id before filesystem writes."""
    inst_id = instance_id or generate_instance_id()
    if instance_id:
        try:
            validate_instance_id(instance_id)
        except Exception as exc:
            console.print(f"[red]Error: {exc}[/red]")
            sys.exit(1)
    return inst_id


class _CliLaunchStoreAdapter:
    """Adapter binding CLI launch calls to state persistence boundary."""

    def create_agent_state(
        self,
        *,
        instance_id: str,
        persona_name: str,
        job: JobSpec,
        parent_id: str | None = None,
        trace_id: str | None = None,
        spec_id: str | None = None,
        spec_digest: str | None = None,
        model: str | None = None,
        resolved_contract: ResolvedExecutionContract | None = None,
    ):
        return create_agent_state(
            instance_id=instance_id,
            persona_name=persona_name,
            job=job,
            parent_id=parent_id,
            trace_id=trace_id,
            spec_id=spec_id,
            spec_digest=spec_digest,
            model=model,
            resolved_contract=resolved_contract,
        )


def _launch_via_service_or_exit(
    *,
    origin: LaunchOrigin,
    task: str,
    execution_intent: ExecutionIntent,
    persona_source: Literal["file", "inline", "synthesized", "snapshot"],
    inline_behavior: Literal["auto_materialize_snapshot", "reject_spawn"],
    instance_id: str,
    trace_id: str,
    console: Console,
):
    """Call app launch service and exit with user-facing error on failure."""
    request = LaunchRequest(
        origin=origin,
        task=task,
        execution_intent=execution_intent,
        instance_id=instance_id,
        trace_id=trace_id,
    )
    launch_service = DefaultLaunchService(
        contract_resolver=lambda intent: resolve_execution_contract(
            intent,
            persona_source=persona_source,
            inline_behavior=inline_behavior,
        ),
        launch_store=_CliLaunchStoreAdapter(),
    )
    launch_result = launch_service.launch(request)
    if isinstance(launch_result, Failure):
        console.print(f"[red]Error: {launch_result.failure()}[/red]")
        sys.exit(1)
    return launch_result.unwrap()


# @invar:allow shell_result: CLI output helper returns POSIX exit code
# @shell_complexity: Preserves JSON/human formatting branches and token usage fields
def _render_run_success(
    *,
    agent_result: AgentResult,
    state: AgentStateData,
    persona: PersonaSpec,
    effective_model: str,
    json_output: bool,
    verbose: bool,
    console: Console,
) -> int:
    """Render successful run output and return exit code."""
    if json_output:
        output = {
            "instance_id": state.instance_id,
            "agent_id": state.instance_id,
            "persona": persona.id,
            "model": effective_model,
            "final_state": agent_result.final_state.value,
            "token_usage": {
                "prompt_tokens": agent_result.token_usage.prompt_tokens
                if agent_result.token_usage
                else 0,
                "completion_tokens": agent_result.token_usage.completion_tokens
                if agent_result.token_usage
                else 0,
                "total_tokens": agent_result.token_usage.total_tokens
                if agent_result.token_usage
                else 0,
            },
            "response_text": agent_result.response_text,
            "result_ref": agent_result.result_ref,
            "error": agent_result.error,
        }
        console.print_json(data=output)
    else:
        if agent_result.response_text:
            console.print(agent_result.response_text)
        if verbose:
            state_color = (
                "green" if agent_result.final_state.value == "completed" else "red"
            )
            panel = Panel(
                f"[bold]Instance ID:[/] {state.instance_id}\n"
                f"[bold]State:[/] [{state_color}]{agent_result.final_state.value}[/{state_color}]\n"
                f"[bold]Tokens:[/] {agent_result.token_usage.total_tokens if agent_result.token_usage else 0}",
                title="anima run complete",
                border_style="blue",
            )
            console.print(panel)

    return 0 if agent_result.final_state.value == "completed" else 1


# @invar:allow shell_result: CLI output helper returns POSIX exit code
# @shell_complexity: Preserves Section G envelope and sandbox-specific messaging branches
def _render_run_failure(
    *,
    error: Any,
    sandbox_policy: SandboxPolicy,
    json_output: bool,
    console: Console,
) -> int:
    """Render run failure envelope and return exit code."""
    if json_output:
        if hasattr(error, "code") and hasattr(error, "message"):
            from anima.core.schemas import AnimaError as CoreAnimaError

            if isinstance(error, CoreAnimaError):
                error_data = {
                    "error": {
                        "code": numeric_code_to_string(error.code),
                        "numeric_code": error.code,
                        "message": error.message,
                        "details": error.details,
                    }
                }
            else:
                error_data = {
                    "error": {
                        "code": str(error.code),
                        "numeric_code": 10,
                        "message": str(error.message),
                        "details": getattr(error, "details", None),
                    }
                }
        else:
            error_data = {
                "error": {
                    "code": "INTERNAL",
                    "numeric_code": 10,
                    "message": str(error),
                }
            }
        console.print_json(data=error_data)
    else:
        error_str = str(error)
        block_info = format_network_block_error(error_str, sandbox_policy)
        if block_info:
            console.print(
                f"[red]Network access blocked by sandbox:[/] {block_info.error_message}"
            )
            console.print(f"[yellow]Suggestion:[/] {block_info.suggestion}")
            if block_info.allowed_domains:
                domains = ", ".join(block_info.allowed_domains)
                console.print(f"[dim]Currently allowing: {domains}[/dim]")
        else:
            console.print(f"[red]Error: {error}[/red]")

    if hasattr(error, "code") and getattr(error, "code") == 300:
        return EXIT_AGENT_NOT_FOUND
    return 1


# @invar:allow shell_result: CLI helper derives lifecycle/completion values for subprocess staging
# @shell_orchestration: Lifecycle/completion default resolution is shell subprocess orchestration
def _resolve_spawn_lifecycle_and_completion_policy(
    lifecycle: str,
    completion_policy: str | None,
) -> tuple[LifecycleType, str]:
    """Resolve spawn lifecycle/completion defaults.

    Preserve an explicit daemon lifecycle even when completion_policy is omitted.
    """
    try:
        lifecycle_type = LifecycleType(lifecycle)
    except ValueError:
        valid_options = [lt.value for lt in LifecycleType]
        raise ValueError(
            f"Invalid lifecycle '{lifecycle}'. Must be one of: {valid_options}"
        )

    if completion_policy is not None:
        spawn_completion_policy = completion_policy
    elif lifecycle_type == LifecycleType.daemon:
        # Daemon agents should not auto-complete — they persist for interactive use.
        spawn_completion_policy = "explicit"
    else:
        spawn_completion_policy = "auto"
    return lifecycle_type, spawn_completion_policy


# @invar:allow shell_result: CLI helper materializes persona file path for subprocess handoff
def _resolve_spawn_persona_file(
    persona: str | None, loaded_persona: PersonaSpec
) -> str:
    """Resolve persona file path passed to child process.

    Keeps child-owned temp-file cleanup semantics unchanged.
    If persona value doesn't exist on disk (e.g., spec_id or inline),
    serialize the resolved persona to a temp file instead.
    """
    if persona and Path(persona).exists():
        return persona

    temp_file = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False)
    temp_file.write(loaded_persona.model_dump_json())
    temp_file.flush()
    temp_file.close()
    return temp_file.name


# @invar:allow shell_result: Helper returns subprocess IPC values without side effects beyond temp-file materialization
def _resolve_spawn_persona_ipc_values(
    persona: str | None,
    loaded_persona: PersonaSpec,
) -> tuple[str | None, str | None]:
    """Resolve subprocess persona IPC values.

    Internal contract:
        - ``--persona-spec-id`` when CLI ``--persona`` is a spec_id.
        - ``--persona-file`` when CLI used a persona file or inline ``-s`` prompt.
    """
    if persona is not None and loaded_persona.id == f"bootstrap-{persona}":
        return persona, None
    return None, _resolve_spawn_persona_file(persona, loaded_persona)


# @invar:allow shell_result: CLI helper returns argv sequence for staged subprocess launch
# @shell_complexity: Optional CLI flags map to conditional argv entries with stable ordering
# @shell_orchestration: Spawn runner argv materialization is shell launch orchestration boundary
def _build_spawn_runner_command(
    *,
    state: AgentStateData,
    task: str,
    persona_spec_id_to_pass: str | None,
    persona_file_to_pass: str | None,
    job_file: str | None,
    model: str | None,
    llm_base_url: str | None,
    completion_policy: str,
    tools_mcp_cmd_strings: list[str],
    mcp: tuple[str, ...],
    token_file: str | None,
    sandbox: bool,
    merged_allows: list[str],
    auto_rm: bool,
    memory_provider: str | None,
) -> list[str]:
    """Build child runner argv preserving staged ordering and optional fields."""
    runner_path = os.path.join(os.path.dirname(__file__), "..", "agent_runner.py")
    cmd = [sys.executable, runner_path, state.instance_id, task]
    if persona_spec_id_to_pass:
        cmd.extend(["--persona-spec-id", persona_spec_id_to_pass])
    if persona_file_to_pass:
        cmd.extend(["--persona-file", persona_file_to_pass])

    if job_file:
        cmd.extend(["--job-file", job_file])
    if model:
        cmd.extend(["--model", model])
    if llm_base_url:
        cmd.extend(["--llm-base-url", llm_base_url])

    cmd.extend(["--completion-policy", completion_policy])

    for mcp_cmd in tools_mcp_cmd_strings:
        cmd.extend(["--mcp", mcp_cmd])
    for mcp_value in mcp:
        cmd.extend(["--mcp", mcp_value])
    if token_file:
        cmd.extend(["--token-file", token_file])

    cmd.append("--sandbox" if sandbox else "--no-sandbox")
    for allow in merged_allows:
        cmd.extend(["--sandbox-allow", allow])

    if auto_rm:
        cmd.append("--rm")
    if memory_provider:
        cmd.extend(["--memory-provider", memory_provider])

    return cmd


# @invar:allow shell_result: CLI helper launches detached subprocess and exits on launch errors
def _launch_spawn_subprocess_or_exit(
    *, state: AgentStateData, cmd: list[str], console: Console
) -> subprocess.Popen[bytes]:
    """Launch detached child process with stderr redirection and exit on failure."""
    try:
        stderr_log_path = os.path.join(get_agent_dir(state.instance_id), "stderr.log")
        stderr_log = open(stderr_log_path, "w")  # noqa: SIM115
        proc = subprocess.Popen(
            cmd,
            stdout=subprocess.DEVNULL,
            stderr=stderr_log,
            stdin=subprocess.DEVNULL,
            start_new_session=True,
        )
        stderr_log.close()
        return proc
    except (OSError, subprocess.SubprocessError, RuntimeError, ValueError) as exc:
        console.print(f"[red]Error: Failed to spawn agent: {exc}[/red]")
        sys.exit(1)


# @invar:allow shell_result: CLI cleanup helper returns None by design
def _cleanup_run_state_if_terminal(
    *,
    inst_id: str,
    auto_rm: bool,
    console: Console,
) -> None:
    """Delete run state only after terminal transition when auto_rm is enabled."""
    if not auto_rm:
        return

    state_result = read_state(inst_id)
    if isinstance(state_result, Success) and is_terminal(state_result.unwrap().state):
        delete_result = delete_agent_state(inst_id)
        if isinstance(delete_result, Failure):
            console.print(
                f"[yellow]Warning: Failed to clean up agent state: {delete_result.failure()}[/yellow]"
            )


# @invar:allow function_size: Entry point preserves run wiring, sandbox policy merge, and terminal cleanup semantics
# @shell_complexity: Run path intentionally branches on crash, result kind, and output mode
def run_impl(
    task: str,
    persona: str | None,
    system_prompt: str | None,
    job_file: str | None,
    model: str | None,
    llm_base_url: str | None,
    mcp: tuple[str, ...],
    tools: tuple[str, ...],
    timeout: str | None,
    max_turns: int | None,
    max_tokens: int | None,
    context_mode: str | None,
    context_bundle: str | None,
    instance_id: str | None,
    completion_policy: str | None,
    token_file: str | None,
    json_output: bool,
    verbose: bool,
    sandbox: bool,
    sandbox_allow: tuple[str, ...],
    auto_rm: bool,
    memory_provider: str | None,
    console: Console,
) -> None:
    """Run a one-off (ephemeral) agent task. Blocks until completion or timeout.

    Lifecycle is always 'ephemeral' -- use 'anima spawn' for background agents.

    Exit codes: 0 success, 1 error, 2 not found.
    """
    import asyncio

    from anima.core.sandbox import resolve_sandbox_policy
    from anima.shell.agent import AgentRunner

    config = _load_global_config_or_exit(console)
    loaded_persona = _load_persona_for_run_spawn(
        persona=persona,
        system_prompt=system_prompt,
        model=model,
        tools=tools,
        mcp=mcp,
    )
    inst_id = _resolve_instance_id_or_exit(instance_id, console)

    try:
        job = build_job_spec(
            job_file=job_file,
            persona_ref=persona or loaded_persona.id,
            instance_id=inst_id,
            timeout=timeout,
            max_turns=max_turns,
            max_tokens=max_tokens,
            context_mode=context_mode,
            context_bundle_ref=context_bundle,
            completion_policy=completion_policy,
            lifecycle=LifecycleType.ephemeral,
        )
    except ValueError as e:
        console.print(f"[red]Error: {e}[/red]")
        sys.exit(1)
    # Note: effective_model will be read from resolved contract after resolution
    token = _load_capability_token(token_file, console) if token_file else None
    comp_policy = (
        CompletionPolicy(completion_policy)
        if completion_policy
        else job.completion_policy
    )
    _validate_llm_base_url_or_exit(llm_base_url, console)

    persona_tool_presets = _collect_persona_tool_presets(
        loaded_persona, token, config, console
    )
    all_tool_names = tuple(dict.fromkeys((*persona_tool_presets, *tools)))
    tools_mcp = _resolve_tools_to_mcp_servers(all_tool_names, config, console)
    preset_sandbox_enabled, preset_sandbox_allows = _collect_preset_sandbox_config(
        all_tool_names, config
    )
    explicit_mcp = _validate_mcp_values(mcp, console, capability_token=token)
    merged_mcp_servers: list[object] | None = None
    if tools_mcp or explicit_mcp:
        merged_mcp_servers = tools_mcp + (explicit_mcp or [])

    sandbox_policy = resolve_sandbox_policy(
        sandbox=sandbox,
        no_sandbox=not sandbox,
        sandbox_allow=list(sandbox_allow),
        preset_sandbox=preset_sandbox_enabled,
        preset_allows=list(preset_sandbox_allows),
    )
    _print_sandbox_banner(sandbox_policy, verbose, json_output, console)

    # Resolve contract once - all consumers read from this single source of truth
    # Spec source: execution-contract-specific-design-rules/tool-access-semantics
    # Warnings, UI, runtime decisions use effective tools, not declared tools alone.
    intent = ExecutionIntent(
        persona_spec=loaded_persona,
        job_spec=job,
        overrides=ExecutionIntentOverrides(
            requested_model=model,
            requested_cli_tools=all_tool_names,
            requested_completion_policy=(
                CompletionPolicy(completion_policy)
                if completion_policy is not None
                else None
            ),
            requested_lifecycle=LifecycleType.ephemeral,
        ),
        defaults=ExecutionIntentDefaults(
            default_model=config.defaults.model,
            **resolve_execution_output_policy_defaults(
                cli=None,
                env={
                    "profile": os.environ.get("ANIMA_TOOL_OUTPUT_PROFILE"),
                    "per_call_cap_bytes": os.environ.get(
                        "ANIMA_TOOL_OUTPUT_PER_CALL_CAP_BYTES"
                    ),
                    "per_turn_cap_bytes": os.environ.get(
                        "ANIMA_TOOL_OUTPUT_PER_TURN_CAP_BYTES"
                    ),
                },
                config=(
                    config.tool_output.model_dump()
                    if hasattr(config.tool_output, "model_dump")
                    else None
                ),
                model_hint=model or loaded_persona.model,
                provider_hint=None,
                context_window_tokens=getattr(
                    config.defaults, "context_window_tokens", None
                ),
            ),
        ),
    )
    launch_result = _launch_via_service_or_exit(
        origin="run",
        task=task,
        execution_intent=intent,
        persona_source="file" if persona else "inline",
        inline_behavior="auto_materialize_snapshot",
        instance_id=inst_id,
        trace_id=generate_trace_id(),
        console=console,
    )
    resolved_contract = launch_result.resolved_contract

    # Display effective values from resolved contract (not declared persona tools)
    # Spec source: model-visible-runtime-context, tool-access-semantics
    if not json_output and verbose:
        # Read effective tools from contract (not CLI args or persona.tools)
        effective_tools = resolved_contract.tool_access.effective_tools
        tools_str = ", ".join(effective_tools) if effective_tools else "none"
        # Read effective model from contract (already resolved via precedence)
        panel = Panel(
            f"[bold]Persona:[/] {loaded_persona.id}\n"
            f"[bold]Model:[/] {resolved_contract.runtime.effective_model}\n"
            f"[bold]Instance:[/] {inst_id}\n"
            f"[bold]Tools:[/] {tools_str}\n"
            f"[bold]Task:[/] {task}",
            title="anima run",
            border_style="blue",
        )
        console.print(panel)

    state = launch_result.initial_state

    runner = AgentRunner(
        instance_id=state.instance_id,
        task=task,
        persona=loaded_persona,
        job=job,
        completion_policy=comp_policy,
        max_turns=max_turns or job.max_turns,
        model_override=model,
        base_url=llm_base_url,
        memory_provider_name=memory_provider,
        mcp_servers=merged_mcp_servers,
        capability_token=token,
        sandbox_policy=sandbox_policy,
    )

    exit_code = 0
    try:
        try:
            result = asyncio.run(runner.run())
        except Exception as exc:
            try:
                trans_result = transition_state(
                    inst_id,
                    LifecycleEvent.crash,
                    error=f"Spawn runtime error ({type(exc).__name__}): {exc}",
                )
                if isinstance(trans_result, Failure):
                    console.print(
                        f"[yellow]Warning: Failed to transition state to failed: {trans_result.failure()}[/yellow]"
                    )
            except Exception:
                console.print(
                    f"[yellow]Warning: Exception during state transition: {exc}[/yellow]"
                )
            raise

        if isinstance(result, Success):
            agent_result = result.unwrap()
            write_result_result = write_result(inst_id, agent_result)
            if isinstance(write_result_result, Failure):
                console.print(
                    "[yellow]Warning: Failed to write result artifact: "
                    f"{write_result_result.failure()}[/yellow]"
                )
            exit_code = _render_run_success(
                agent_result=agent_result,
                state=state,
                persona=loaded_persona,
                effective_model=resolved_contract.runtime.effective_model,
                json_output=json_output,
                verbose=verbose,
                console=console,
            )
        else:
            exit_code = _render_run_failure(
                error=result.failure(),
                sandbox_policy=sandbox_policy,
                json_output=json_output,
                console=console,
            )
    finally:
        _cleanup_run_state_if_terminal(
            inst_id=inst_id, auto_rm=auto_rm, console=console
        )

    sys.exit(exit_code)


# @invar:allow function_size: Entry point preserves staged argv construction, PID/state writes, and subprocess handoff order
def spawn_impl(
    task: str,
    persona: str | None,
    system_prompt: str | None,
    job_file: str | None,
    model: str | None,
    llm_base_url: str | None,
    mcp: tuple[str, ...],
    tools: tuple[str, ...],
    timeout: str | None,
    max_turns: int | None,
    max_tokens: int | None,
    context_mode: str | None,
    context_bundle: str | None,
    instance_id: str | None,
    completion_policy: str | None,
    token_file: str | None,
    lifecycle: str,
    json_output: bool,
    sandbox: bool,
    sandbox_allow: tuple[str, ...],
    auto_rm: bool,
    memory_provider: str | None,
    console: Console,
) -> None:
    """Spawn a background agent. Returns immediately with instance_id."""
    from anima.core.sandbox import merge_sandbox_allows

    config = _load_global_config_or_exit(console)
    loaded_persona = _load_persona_for_run_spawn(
        persona=persona,
        system_prompt=system_prompt,
        model=model,
        tools=tools,
        mcp=mcp,
    )
    _validate_llm_base_url_or_exit(llm_base_url, console)

    token = _load_capability_token(token_file, console) if token_file else None
    persona_tool_presets = _collect_persona_tool_presets(
        loaded_persona, token, config, console
    )
    all_tool_names = tuple(dict.fromkeys((*persona_tool_presets, *tools)))
    tools_mcp_cmd_strings = _resolve_tools_to_mcp_cmd_strings(
        all_tool_names, config, console
    )
    _validate_mcp_values(mcp, console)

    _, preset_sandbox_allows = _collect_preset_sandbox_config(all_tool_names, config)
    inst_id = _resolve_instance_id_or_exit(instance_id, console)
    try:
        lifecycle_type, spawn_completion_policy = (
            _resolve_spawn_lifecycle_and_completion_policy(lifecycle, completion_policy)
        )
    except ValueError as e:
        console.print(f"[red]Error: {e}[/red]")
        sys.exit(1)

    try:
        job = build_job_spec(
            job_file=job_file,
            persona_ref=persona or loaded_persona.id,
            instance_id=inst_id,
            timeout=timeout,
            max_turns=max_turns,
            max_tokens=max_tokens,
            context_mode=context_mode,
            context_bundle_ref=context_bundle,
            completion_policy=spawn_completion_policy,
            lifecycle=lifecycle_type,
        )
    except ValueError as e:
        console.print(f"[red]Error: {e}[/red]")
        sys.exit(1)

    # Resolve contract once - all consumers read from this single source of truth
    # Spec source: execution-contract-specific-design-rules/tool-access-semantics
    intent = ExecutionIntent(
        persona_spec=loaded_persona,
        job_spec=job,
        overrides=ExecutionIntentOverrides(
            requested_model=model,
            requested_cli_tools=all_tool_names,
            requested_completion_policy=(
                CompletionPolicy(completion_policy)
                if completion_policy is not None
                else None
            ),
            requested_lifecycle=lifecycle_type,
        ),
        defaults=ExecutionIntentDefaults(
            default_model=config.defaults.model,
            **resolve_execution_output_policy_defaults(
                cli=None,
                env={
                    "profile": os.environ.get("ANIMA_TOOL_OUTPUT_PROFILE"),
                    "per_call_cap_bytes": os.environ.get(
                        "ANIMA_TOOL_OUTPUT_PER_CALL_CAP_BYTES"
                    ),
                    "per_turn_cap_bytes": os.environ.get(
                        "ANIMA_TOOL_OUTPUT_PER_TURN_CAP_BYTES"
                    ),
                },
                config=(
                    config.tool_output.model_dump()
                    if hasattr(config.tool_output, "model_dump")
                    else None
                ),
                model_hint=model or loaded_persona.model,
                provider_hint=None,
                context_window_tokens=getattr(
                    config.defaults, "context_window_tokens", None
                ),
            ),
        ),
    )
    launch_result = _launch_via_service_or_exit(
        origin="spawn",
        task=task,
        execution_intent=intent,
        persona_source="file" if persona else "inline",
        inline_behavior="auto_materialize_snapshot",
        instance_id=inst_id,
        trace_id=generate_trace_id(),
        console=console,
    )
    resolved_contract = launch_result.resolved_contract
    # Read effective model from resolved contract (not persona.model directly)
    effective_model = resolved_contract.runtime.effective_model

    state = launch_result.initial_state

    persona_spec_id_to_pass, persona_file_to_pass = _resolve_spawn_persona_ipc_values(
        persona, loaded_persona
    )
    merged_allows = merge_sandbox_allows(
        list(sandbox_allow), sorted(set(preset_sandbox_allows))
    )
    cmd = _build_spawn_runner_command(
        state=state,
        task=task,
        persona_spec_id_to_pass=persona_spec_id_to_pass,
        persona_file_to_pass=persona_file_to_pass,
        job_file=job_file,
        model=model,
        llm_base_url=llm_base_url,
        completion_policy=job.completion_policy.value,
        tools_mcp_cmd_strings=tools_mcp_cmd_strings,
        mcp=mcp,
        token_file=token_file,
        sandbox=sandbox,
        merged_allows=merged_allows,
        auto_rm=auto_rm,
        memory_provider=memory_provider,
    )
    proc = _launch_spawn_subprocess_or_exit(state=state, cmd=cmd, console=console)

    pid_result = write_agent_pid(state.instance_id, proc.pid)
    if isinstance(pid_result, Failure):
        console.print(f"[red]Error: Failed to write PID: {pid_result.failure()}[/red]")
        sys.exit(1)

    trans_result = transition_state(state.instance_id, LifecycleEvent.start)
    if isinstance(trans_result, Failure):
        console.print(
            f"[yellow]Warning: Could not transition state: {trans_result.failure()}[/yellow]"
        )

    output_spawn_result(
        inst_id,
        lifecycle_type,
        effective_model,
        json_output,
        pid=proc.pid,
        target_console=console,
    )
    sys.exit(0)
