"""Agent runner stub for detached spawn.

This is a standalone script that runs the agent loop.
It is spawned as a detached background process by `anima spawn`.

Uses AgentRunner for MCP server integration.
"""

import os
import sys
from dataclasses import dataclass, field
from inspect import signature
from pathlib import Path
from typing import Any

from pydantic import ValidationError

from anima.shell.contract_compat import normalize_legacy_persona_payload

# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent.parent))


@dataclass
class _ParsedRunnerArgs:
    persona_file: str | None = None
    persona_spec_id: str | None = None
    job_file: str | None = None
    model_override: str | None = None
    llm_base_url: str | None = None
    mcp_values: list[str] = field(default_factory=list)
    tool_values: list[str] = field(default_factory=list)
    token_file: str | None = None
    completion_policy_value: str | None = None
    max_turns_override: int | None = None
    max_turns_parse_error: str | None = None
    sandbox: bool = False
    no_sandbox: bool = False
    sandbox_allow_values: list[str] = field(default_factory=list)
    auto_rm: bool = False
    memory_provider: str | None = None


# @invar:allow shell_result: CLI parser helper returns structured options, not I/O result
# @shell_complexity: Flag parser branches directly mirror CLI surface area
# @shell_orchestration: Parser remains in shell to keep CLI flag semantics adjacent to runner wiring
def _parse_runner_args(argv: list[str]) -> _ParsedRunnerArgs:
    parsed = _ParsedRunnerArgs()
    i = 3
    while i < len(argv):
        if argv[i] == "--persona-file" and i + 1 < len(argv):
            parsed.persona_file = argv[i + 1]
            i += 2
        elif argv[i] == "--persona-spec-id" and i + 1 < len(argv):
            parsed.persona_spec_id = argv[i + 1]
            i += 2
        elif argv[i] == "--job-file" and i + 1 < len(argv):
            parsed.job_file = argv[i + 1]
            i += 2
        elif argv[i] == "--model" and i + 1 < len(argv):
            parsed.model_override = argv[i + 1]
            i += 2
        elif argv[i] == "--llm-base-url" and i + 1 < len(argv):
            parsed.llm_base_url = argv[i + 1]
            i += 2
        elif argv[i] == "--mcp" and i + 1 < len(argv):
            parsed.mcp_values.append(argv[i + 1])
            i += 2
        elif argv[i] == "--tools" and i + 1 < len(argv):
            parsed.tool_values.append(argv[i + 1])
            i += 2
        elif argv[i] == "--token-file" and i + 1 < len(argv):
            parsed.token_file = argv[i + 1]
            i += 2
        elif argv[i] == "--completion-policy" and i + 1 < len(argv):
            parsed.completion_policy_value = argv[i + 1]
            i += 2
        elif argv[i] == "--max-turns" and i + 1 < len(argv):
            try:
                parsed.max_turns_override = int(argv[i + 1])
            except ValueError:
                parsed.max_turns_parse_error = (
                    f"Invalid --max-turns value: {argv[i + 1]!r}"
                )
            i += 2
        elif argv[i] == "--sandbox":
            parsed.sandbox = True
            i += 1
        elif argv[i] == "--no-sandbox":
            parsed.no_sandbox = True
            i += 1
        elif argv[i] == "--sandbox-allow" and i + 1 < len(argv):
            parsed.sandbox_allow_values.append(argv[i + 1])
            i += 2
        elif argv[i] == "--rm":
            parsed.auto_rm = True
            i += 1
        elif argv[i] == "--memory-provider" and i + 1 < len(argv):
            parsed.memory_provider = argv[i + 1]
            i += 2
        else:
            i += 1
    return parsed


# @invar:allow shell_result: Crash transition helper returns no value by design
# @shell_orchestration: Best-effort state transition helper for subprocess crash path
def _transition_state_crash(instance_id: str, error_msg: str) -> None:
    try:
        from anima.core.lifecycle import LifecycleEvent
        from anima.shell.state import transition_state

        transition_state(instance_id, LifecycleEvent.crash, error=error_msg)
    except Exception:
        return


# @invar:allow shell_result: CLI process uses exit-code contract for early failures
def _fail_early(instance_id: str, error_msg: str) -> int:
    _transition_state_crash(instance_id, error_msg)
    return 1


# @invar:allow shell_result: Path normalization helper returns Path directly
def _resolve_json_path(path_str: str) -> Path:
    path = Path(path_str)
    if not path.is_absolute():
        return Path.cwd() / path
    return path


# @invar:allow shell_result: Typed JSON loader returns parsed object + error message tuple
def _load_typed_json_file(
    path_str: str,
    schema: Any,
    *,
    label: str,
    cleanup_temp_file: bool,
) -> tuple[Any | None, str | None]:
    import json

    json_path = _resolve_json_path(path_str)
    try:
        payload = json.loads(json_path.read_text())
        if label == "persona":
            payload = normalize_legacy_persona_payload(payload)
        return schema(**payload), None
    except (OSError, json.JSONDecodeError, ValidationError) as e:
        print(f"Error loading {label}: {e}", file=sys.stderr)
        return None, f"Failed to load {label}: {e}"
    finally:
        if (
            cleanup_temp_file
            and json_path.name.startswith("tmp")
            and json_path.exists()
        ):
            try:
                json_path.unlink()
            except OSError:
                pass


# @invar:allow shell_result: Tool resolution helper returns parsed server objects
# @shell_complexity: Preset transformation mirrors config schema and resolver contracts
def _resolve_tools_mcp_servers(
    tool_values: list[str],
) -> tuple[list[object], bool, list[str], str | None]:
    from returns.result import Failure

    from anima.core.tool_resolver import ToolPresetInput
    from anima.core.tool_resolver import resolve_tool_presets
    from anima.shell.mcp_servers import build_mcp_server_stdio
    from anima.shell.model_config import load_global_config

    config_result = load_global_config()
    if isinstance(config_result, Failure):
        print(
            f"Error loading global config: {config_result.failure()}",
            file=sys.stderr,
        )
        return [], False, [], f"Failed to load global config: {config_result.failure()}"

    config = config_result.unwrap()
    preset_dict: dict[str, ToolPresetInput] = {}
    any_sandbox_enabled = False
    all_sandbox_allows: list[str] = []
    for name, preset in config.tools.items():
        preset_sandbox = preset.sandbox if preset.sandbox is not None else False
        preset_sandbox_allow = (
            list(preset.sandbox_allow) if preset.sandbox_allow else []
        )
        if preset_sandbox and name in tool_values:
            any_sandbox_enabled = True
            all_sandbox_allows.extend(preset_sandbox_allow)

        preset_dict[name] = ToolPresetInput(
            command=preset.command,
            args=list(preset.args) if preset.args else [],
            env=dict(preset.env) if preset.env else {},
            sandbox=preset_sandbox,
            sandbox_allow=preset_sandbox_allow,
        )

    variables = {"CWD": os.getcwd(), "HOME": str(Path.home())}
    try:
        resolved = resolve_tool_presets(tool_values, preset_dict, variables)
    except ValueError as e:
        print(f"Error: {e}", file=sys.stderr)
        return [], False, [], f"Tool preset resolution failed: {e}"

    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, any_sandbox_enabled, all_sandbox_allows, None


# @invar:allow shell_result: CLI entry point uses exit codes for subprocess status
# @shell_complexity: Argument parsing requires conditional branching
# @invar:allow function_size: CLI entry point with argument parsing and subprocess management
def main() -> int:
    """Main entry point for detached agent runner."""
    if len(sys.argv) < 3:
        print(
            "Usage: anima-agent-runner <instance_id> <task> [--persona-file PATH] [--persona-spec-id ID] [--job-file PATH] [--model MODEL] [--llm-base-url URL] [--mcp VALUE] [--tools NAME] [--token-file PATH] [--completion-policy explicit|auto] [--max-turns N] [--sandbox] [--no-sandbox] [--sandbox-allow VALUE] [--rm] [--memory-provider PROVIDER]",
            file=sys.stderr,
        )
        return 1

    instance_id = sys.argv[1]
    task = sys.argv[2]
    parsed = _parse_runner_args(sys.argv)

    # Import here to avoid circular imports
    from anima.core.schemas import (
        CapabilityToken,
        CompletionPolicy,
        JobSpec,
        PersonaSpec,
    )
    from anima.shell.cli_utils import (
        _build_deferred_spec_id_persona,
        _resolve_effective_model,
    )
    from anima.shell.agent_sync import run_agent_sync
    from anima.shell.cli_params import validate_llm_base_url
    from anima.shell.mcp_servers import parse_mcp_servers

    if parsed.max_turns_parse_error is not None:
        print(f"Error: {parsed.max_turns_parse_error}", file=sys.stderr)
        return _fail_early(instance_id, parsed.max_turns_parse_error)

    try:
        validate_llm_base_url(parsed.llm_base_url)
    except ValueError as e:
        print(f"Error: {e}", file=sys.stderr)
        return _fail_early(instance_id, str(e))

    persona = None
    if parsed.persona_file and parsed.persona_spec_id:
        print(
            "Error: Internal runner flags conflict: --persona-file and --persona-spec-id are mutually exclusive",
            file=sys.stderr,
        )
        return _fail_early(
            instance_id,
            "Internal runner flags conflict: --persona-file and --persona-spec-id",
        )

    if parsed.persona_file:
        persona, persona_err = _load_typed_json_file(
            parsed.persona_file,
            PersonaSpec,
            label="persona",
            cleanup_temp_file=True,
        )
        if persona_err is not None:
            return _fail_early(instance_id, persona_err)

    if parsed.persona_spec_id:
        try:
            effective_model = _resolve_effective_model(
                cli_model=parsed.model_override,
                persona_model=None,
            )
        except SystemExit as exc:
            return int(exc.code) if isinstance(exc.code, int) else 1
        persona = _build_deferred_spec_id_persona(
            parsed.persona_spec_id, effective_model
        )

    job = None
    if parsed.job_file:
        job, job_err = _load_typed_json_file(
            parsed.job_file,
            JobSpec,
            label="job",
            cleanup_temp_file=True,
        )
        if job_err is not None:
            return _fail_early(instance_id, job_err)

    capability_token = None
    if parsed.token_file:
        capability_token, token_err = _load_typed_json_file(
            parsed.token_file,
            CapabilityToken,
            label="token",
            cleanup_temp_file=False,
        )
        if token_err is not None:
            return _fail_early(instance_id, token_err)

    # Spec source: verify-wire-params blocker #1.
    # Detached runner must report malformed --mcp values cleanly.
    # Spec source: docs/INTERFACES.md Section H.5 (Token Transport)
    # CapabilityToken is passed to MCP servers via client_info.capability_token.
    try:
        explicit_mcp_servers: list[Any] | None = parse_mcp_servers(
            parsed.mcp_values or None,
            capability_token=capability_token,
        )
    except ValueError as e:
        print(f"Error: Invalid --mcp value: {e}", file=sys.stderr)
        return _fail_early(instance_id, f"Invalid --mcp value: {e}")

    # Spec source: vectl step tool-presets.cli-flag.
    # Resolve tool presets from global config.
    tools_mcp_servers: list[object] = []
    any_sandbox_enabled = False
    all_sandbox_allows: list[str] = []
    if parsed.tool_values:
        (
            tools_mcp_servers,
            any_sandbox_enabled,
            all_sandbox_allows,
            tool_err,
        ) = _resolve_tools_mcp_servers(parsed.tool_values)
        if tool_err is not None:
            return _fail_early(instance_id, tool_err)

    # Merge: --mcp takes precedence on conflicts (append order: tools first, then explicit)
    # Per vectl step tool-presets.cli-flag: "--mcp precedence on conflicts"
    mcp_servers: list[object] | None = None
    if tools_mcp_servers and explicit_mcp_servers:
        mcp_servers = tools_mcp_servers + explicit_mcp_servers
    elif tools_mcp_servers:
        mcp_servers = tools_mcp_servers
    elif explicit_mcp_servers:
        mcp_servers = explicit_mcp_servers

    # Spec source: sandbox-srt.cli-flags
    # Truth table: --sandbox/--no-sandbox mutual exclusion
    if parsed.sandbox and parsed.no_sandbox:
        print("Error: Cannot use both --sandbox and --no-sandbox", file=sys.stderr)
        return _fail_early(instance_id, "Cannot use both --sandbox and --no-sandbox")

    # Resolve sandbox policy from CLI flags + tool presets
    from anima.core.sandbox import resolve_sandbox_policy

    sandbox_policy = resolve_sandbox_policy(
        sandbox=parsed.sandbox,
        no_sandbox=parsed.no_sandbox,
        sandbox_allow=parsed.sandbox_allow_values,
        preset_sandbox=any_sandbox_enabled,
        preset_allows=sorted(set(all_sandbox_allows)),
    )

    completion_policy = (
        job.completion_policy if job is not None else CompletionPolicy.explicit
    )
    if parsed.completion_policy_value is not None:
        try:
            completion_policy = CompletionPolicy(parsed.completion_policy_value)
        except ValueError:
            print(
                "Error: Invalid --completion-policy value: "
                f"{parsed.completion_policy_value!r}. Expected explicit|auto",
                file=sys.stderr,
            )
            return _fail_early(
                instance_id,
                f"Invalid --completion-policy value: {parsed.completion_policy_value!r}",
            )

    max_turns = job.max_turns if job is not None else None
    if parsed.max_turns_override is not None:
        max_turns = parsed.max_turns_override

    # Run agent with crash cleanup
    # Spec source: stale-state-detection.run-cleanup
    # Unhandled exceptions/abnormal exit must transition state to failed
    # without masking the original exception.
    from returns.result import Failure

    exit_code = 1  # Default to error
    try:
        run_agent_kwargs: dict[str, Any] = {
            "job": job,
            "completion_policy": completion_policy,
            "max_turns": max_turns,
            "model_override": parsed.model_override,
            "base_url": parsed.llm_base_url,
            "mcp_servers": mcp_servers,
            "capability_token": capability_token,
            "sandbox_policy": sandbox_policy,
            "auto_rm": parsed.auto_rm,
        }
        run_agent_params = signature(run_agent_sync).parameters
        if "memory_provider_name" in run_agent_params:
            run_agent_kwargs["memory_provider_name"] = parsed.memory_provider

        exit_code = run_agent_sync(
            instance_id,
            task,
            persona,
            **run_agent_kwargs,
        )
    except Exception as e:
        # Crash path: transition state to failed
        # Spec source: stale-state-detection.run-cleanup
        # Safe handling: if transition fails, log and continue
        try:
            from anima.core.lifecycle import LifecycleEvent
            from anima.shell.state import transition_state

            trans_result = transition_state(
                instance_id,
                LifecycleEvent.crash,
                error=f"Daemon crash ({type(e).__name__}): {e}",
            )
            if isinstance(trans_result, Failure):
                # Log transition failure but don't mask the original exception
                print(
                    f"Warning: Failed to transition state to failed: {trans_result.failure()}",
                    file=sys.stderr,
                )
        except Exception as transition_err:
            # If transition itself fails, log and continue
            print(
                f"Warning: Exception during state transition: {transition_err}",
                file=sys.stderr,
            )
        # Preserve exit code 1 for crash
        exit_code = 1

    return exit_code


if __name__ == "__main__":
    sys.exit(main())
