"""CLI entry point.

Commands: run, spawn, chat, ls, ps, inspect, logs, collect, kill, approve,
reject, resume, top, serve, talk, rm, prune.

This file contains only Click command decorators and thin wrappers that delegate
to implementation modules in cli_commands/.
"""

# @shell_note: CLI modules have higher line counts due to Click command entry points
# @invar:allow file_size: CLI entry point with many Click command thin wrappers delegating to cli_commands/

# =============================================================================
# Compatibility re-exports for test patching
# =============================================================================
# Tests patch functions and modules at `anima.shell.cli.*`. After modular refactor,
# implementations moved to submodules. Re-export here for backward compatibility.
# See: cli-refactor.cli-refactor-verify-cli-refactor-retest-fix blocker.
import os
import subprocess

from anima.shell.cli_utils import load_persona
from anima.shell.mcp_servers import parse_mcp_servers

# Re-export for test mocks
__all__ = [
    "load_persona",
    "parse_mcp_servers",
    "os",
    "subprocess",
]

# =============================================================================
# Main imports
# =============================================================================
import sys

import click
from rich.console import Console

from anima.shell.cli_commands.chat_cmd import chat_impl
from anima.shell.cli_commands.cleanup_cmds import _rm_bulk, prune_impl, rm_impl
from anima.shell.cli_commands.inspect_collect import (
    collect_impl,
    inspect_impl,
    logs_impl,
)
from anima.shell.cli_commands.kill_cmd import kill_impl
from anima.shell.cli_commands.lifecycle_cmds import (
    approve_impl,
    reject_impl,
    resume_impl,
)
from anima.shell.cli_commands.monitor_serve import (
    monitor_impl,
    serve_impl,
)
from anima.shell.cli_commands.query import (
    list_command,
    status_command,
    tree_command,
)
from anima.shell.cli_commands.run_spawn import run_impl, spawn_impl
from anima.shell.cli_commands.talk_cmd import talk_impl
from anima.shell.cli_commands.persona_cmd import persona_list_impl, persona_show_impl
from anima.shell.cli_commands.tools_cmd import (
    init_tools_impl,
    inspect_runtime_tools_impl,
    list_tools_impl,
)
from anima.shell.cli_params import (
    with_run_spawn_agent_options,
    with_run_spawn_sandbox_options,
    with_memory_provider_option,
)

EXIT_AGENT_NOT_FOUND = 2

console = Console()


# @shell_orchestration: CLI wrapper normalizes repeated flag values before command dispatch
def _normalize_tools_arg(kwargs: dict[str, object]) -> None:
    """Normalize repeated --tools values to a flat tuple of preset names."""
    raw_values = kwargs.get("tools", ())
    if not isinstance(raw_values, tuple):
        kwargs["tools"] = ()
        return

    kwargs["tools"] = tuple(
        segment.strip()
        for entry in raw_values
        if isinstance(entry, str)
        for segment in entry.split(",")
        if segment.strip()
    )


class SemanticCommandGroup(click.Group):
    """Render top-level commands in semantic groups."""

    _COMMAND_GROUPS: tuple[tuple[str, tuple[str, ...]], ...] = (
        ("Launch", ("run", "spawn", "chat")),
        ("Observe", ("list", "status", "inspect", "logs", "tree", "tools")),
        ("Control", ("approve", "reject", "resume", "kill")),
        ("Monitor", ("monitor", "serve", "talk")),
        ("Manage", ("persona",)),
        ("Cleanup", ("rm", "prune")),
    )

    def format_commands(
        self, ctx: click.Context, formatter: click.HelpFormatter
    ) -> None:
        """Write command help grouped by semantic category."""
        commands_by_name: dict[str, click.Command] = {}
        for command_name in self.list_commands(ctx):
            command = self.get_command(ctx, command_name)
            if command is None or command.hidden:
                continue
            commands_by_name[command_name] = command

        assigned: set[str] = set()
        for section_name, group_commands in self._COMMAND_GROUPS:
            rows: list[tuple[str, str]] = []
            for command_name in group_commands:
                command = commands_by_name.get(command_name)
                if command is None:
                    continue
                rows.append((command_name, command.get_short_help_str(formatter.width)))
                assigned.add(command_name)

            if not rows:
                continue

            with formatter.section(f"{section_name} Commands"):
                formatter.write_dl(rows)

        unassigned = [
            command_name
            for command_name in self.list_commands(ctx)
            if command_name in commands_by_name and command_name not in assigned
        ]
        if not unassigned:
            return

        rows = [
            (
                command_name,
                commands_by_name[command_name].get_short_help_str(formatter.width),
            )
            for command_name in unassigned
        ]
        with formatter.section("Other Commands"):
            formatter.write_dl(rows)


class RejectDualSandboxFlagsCommand(click.Command):
    """Reject --sandbox and --no-sandbox when both are present."""

    def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
        if "--sandbox" in args and "--no-sandbox" in args:
            raise click.ClickException("Cannot use both --sandbox and --no-sandbox")
        return super().parse_args(ctx, args)


@click.group(cls=SemanticCommandGroup)
def main() -> None:
    """anima — CLI agent runner with lifecycle management."""


@main.command(cls=RejectDualSandboxFlagsCommand)
@click.argument("task")
@with_run_spawn_agent_options(
    instance_id_help="Idempotency key (auto-generated if not provided)"
)
@click.option(
    "--json/--no-json", "json_output", default=False, help="Machine-readable output"
)
@click.option(
    "--verbose/--quiet",
    "verbose",
    default=False,
    help="Show run panels (default: quiet)",
)
@with_run_spawn_sandbox_options()
@click.option(
    "--rm/--no-rm",
    "auto_rm",
    default=True,
    help="Delete agent state after run (default: --rm). Use --no-rm to preserve.",
)
@with_memory_provider_option(show_in_help=False)
# @invar:allow entry_point_too_thick: Click command with many options
def run(**kwargs):
    """Run a one-off (ephemeral) agent task. Blocks until completion or timeout."""
    _normalize_tools_arg(kwargs)
    run_impl(console=console, **kwargs)


@main.command(cls=RejectDualSandboxFlagsCommand)
@click.argument("task")
@with_run_spawn_agent_options(instance_id_help="Idempotency key")
@click.option(
    "--lifecycle",
    type=click.Choice(["ephemeral", "daemon"]),
    default="daemon",
    help="Agent lifecycle type",
)
@click.option("--json", "json_output", is_flag=True, help="Machine-readable output")
@with_run_spawn_sandbox_options()
@click.option(
    "--rm",
    "auto_rm",
    is_flag=True,
    default=False,
    help="Delete agent state after completion (default: preserve state)",
)
@with_memory_provider_option(show_in_help=False)
# @invar:allow entry_point_too_thick: Click command with many options
def spawn(**kwargs):
    """Spawn a background agent. Returns immediately with instance_id."""
    _normalize_tools_arg(kwargs)
    spawn_impl(console=console, **kwargs)


@main.command("chat")
@click.argument("task", required=False, default=None)
@with_run_spawn_agent_options(
    instance_id_help="Idempotency key (auto-generated if not provided)"
)
@click.option(
    "--json/--no-json", "json_output", default=False, help="Machine-readable output"
)
@with_run_spawn_sandbox_options()
@click.option(
    "--keep/--no-keep",
    "keep",
    default=False,
    help="Preserve spawned agent after chat exits (default: cleanup)",
)
@with_memory_provider_option()
# @invar:allow entry_point_too_thick: Click command with shared run/spawn options
def chat(**kwargs):
    """Spawn ephemeral agent, chat interactively, then cleanup by default."""
    _normalize_tools_arg(kwargs)
    chat_impl(console=console, **kwargs)


def _list_impl(state_filter: str | None, json_output: bool) -> None:
    """Shared implementation for list/ls command names."""
    list_command(state_filter=state_filter, json_output=json_output, console=console)


@main.command("list")
@click.option("--state", "state_filter", help="Filter by state")
@click.option("--json", "json_output", is_flag=True, help="Machine-readable output")
# @invar:allow entry_point_too_thick: Click command with option processing
def ls_cmd(state_filter: str | None, json_output: bool) -> None:
    """List all tracked agents."""
    _list_impl(state_filter=state_filter, json_output=json_output)


@main.command("ls", hidden=True)
@click.option("--state", "state_filter", help="Filter by state")
@click.option("--json", "json_output", is_flag=True, help="Machine-readable output")
# @invar:allow entry_point_too_thick: Hidden compatibility alias for list command
def list_cmd(state_filter: str | None, json_output: bool) -> None:
    """Backward-compatible hidden alias for list."""
    _list_impl(state_filter=state_filter, json_output=json_output)


@main.command()
@click.option("--state", "state_filter", help="Filter by state")
@click.option("--json", "json_output", is_flag=True, help="Machine-readable output")
# @invar:allow entry_point_too_thick: Click command with tree visualization
def tree(state_filter: str | None, json_output: bool) -> None:
    """Show hierarchical agent view (parent -> children via spawn_subagent)."""
    tree_command(state_filter=state_filter, json_output=json_output, console=console)


# @shell_orchestration: Pure delegation to query.status_command
def _status_impl(agent_id: str, json_output: bool) -> None:
    """Shared implementation for status/ps command names."""
    status_command(
        agent_id=agent_id,
        json_output=json_output,
        console=console,
        exit_agent_not_found=EXIT_AGENT_NOT_FOUND,
    )


@main.command("status")
@click.argument("agent_id")
@click.option("--json", "json_output", is_flag=True, help="Machine-readable output")
# @invar:allow entry_point_too_thick: Click command with output formatting
def ps(agent_id: str, json_output: bool) -> None:
    """Show details for a single agent.

    Reads from state.json on disk. Does not require MCP connection to the agent process.
    Supports prefix matching on agent_id (e.g., 'ag_abc' matches 'ag_abc123...').

    Exit codes: 0 success, 2 agent not found.
    """
    _status_impl(agent_id=agent_id, json_output=json_output)


@main.command("ps", hidden=True)
@click.argument("agent_id")
@click.option("--json", "json_output", is_flag=True, help="Machine-readable output")
# @invar:allow entry_point_too_thick: Hidden compatibility alias for status command
def status(agent_id: str, json_output: bool) -> None:
    """Backward-compatible hidden alias for status."""
    _status_impl(agent_id=agent_id, json_output=json_output)


@main.command()
@click.argument("agent_id")
@click.option("--json", "json_output", is_flag=True, help="Machine-readable output")
# @invar:allow entry_point_too_thick: Click command with comprehensive output formatting
def inspect(agent_id: str, json_output: bool) -> None:
    """Show full agent spec/config details."""
    inspect_impl(agent_id=agent_id, json_output=json_output, console=console)


@main.command()
@click.argument("agent_id")
@click.option(
    "--tail", type=int, default=None, help="Show last N entries (default: all)"
)
@click.option("--json", "json_output", is_flag=True, help="Raw JSONL output")
@click.option("--follow", is_flag=True, help="Follow new entries")
@click.option(
    "--responses", is_flag=True, help="Show response events as conversation history"
)
# @invar:allow entry_point_too_thick: Click command with log formatting and JSON output
def logs(
    agent_id: str, tail: int | None, json_output: bool, follow: bool, responses: bool
) -> None:
    """Show agent event log."""
    logs_impl(
        agent_id=agent_id,
        tail=tail,
        json_output=json_output,
        follow=follow,
        responses=responses,
        console=console,
    )


@main.command()
@click.argument("agent_id")
@click.option("--wait", is_flag=True, help="Block until terminal state")
@click.option(
    "--timeout", type=int, help="Timeout in seconds for --wait. Default: no timeout"
)
@click.option("--json", "json_output", is_flag=True, help="Machine-readable output")
# @invar:allow entry_point_too_thick: Click command with polling loop
def collect(agent_id: str, wait: bool, timeout: int | None, json_output: bool) -> None:
    """Harvest result from a terminal-state agent."""
    collect_impl(
        agent_id=agent_id,
        wait=wait,
        timeout=timeout,
        json_output=json_output,
        console=console,
    )


@main.command()
@click.argument("agent_id", required=False)
@click.option(
    "--all", "kill_all", is_flag=True, help="Kill all agents in killable states"
)
@click.option(
    "--state",
    "states",
    multiple=True,
    type=click.Choice(["running", "suspended", "awaiting_approval"]),
    help="Filter by killable state (can repeat). Default: all killable states.",
)
@click.option("--yes", is_flag=True, help="Skip confirmation for --all")
@click.option("--json", "json_output", is_flag=True, help="Machine-readable output")
# @invar:allow entry_point_too_thick: Click command with state validation and process signaling
def kill(
    agent_id: str | None,
    kill_all: bool,
    states: tuple[str, ...],
    yes: bool,
    json_output: bool,
) -> None:
    """Kill a running agent or bulk kill agents in killable states."""
    kill_impl(
        agent_id=agent_id,
        kill_all=kill_all,
        states=states,
        yes=yes,
        json_output=json_output,
        console=console,
    )


@main.command()
@click.argument("agent_id")
@click.option("--note", help="Approval note")
@click.option("--json", "json_output", is_flag=True, help="Machine-readable output")
# @invar:allow entry_point_too_thick: Click command with approval state validation
def approve(agent_id: str, note: str | None, json_output: bool) -> None:
    """Approve an agent's approval request."""
    approve_impl(agent_id=agent_id, note=note, json_output=json_output, console=console)


@main.command()
@click.argument("agent_id")
@click.option("--reason", help="Rejection reason")
@click.option("--json", "json_output", is_flag=True, help="Machine-readable output")
# @invar:allow entry_point_too_thick: Click command with rejection state validation
def reject(agent_id: str, reason: str | None, json_output: bool) -> None:
    """Reject an agent's approval request."""
    reject_impl(
        agent_id=agent_id, reason=reason, json_output=json_output, console=console
    )


@main.command()
@click.argument("agent_id")
@click.option("--json", "json_output", is_flag=True, help="Machine-readable output")
# @invar:allow entry_point_too_thick: Click command with resume state validation
def resume(agent_id: str, json_output: bool) -> None:
    """Resume a suspended agent."""
    resume_impl(agent_id=agent_id, json_output=json_output, console=console)


@main.command("monitor")
@click.option("--refresh", type=float, default=1.0, help="Refresh interval in seconds")
@click.option("--once", is_flag=True, help="Render one snapshot and exit")
# @invar:allow entry_point_too_thick: Click command with TUI dashboard and signal handling
def top(refresh: float, once: bool) -> None:
    """TUI dashboard showing all agents with live updates."""
    monitor_impl(refresh=refresh, once=once, console=console)


@main.command("top", hidden=True)
@click.option("--refresh", type=float, default=1.0, help="Refresh interval in seconds")
@click.option("--once", is_flag=True, help="Render one snapshot and exit")
# @invar:allow entry_point_too_thick: Hidden compatibility alias for monitor command
def monitor(refresh: float, once: bool) -> None:
    """Backward-compatible hidden alias for monitor."""
    monitor_impl(refresh=refresh, once=once, console=console)


@main.command()
@click.option("--port", type=int, default=8420, help="Listen port")
@click.option("--host", default="127.0.0.1", help="Bind address")
@click.option("--json", "json_output", is_flag=True, help="Machine-readable output")
# @invar:allow entry_point_too_thick: Click command with signal handling
def serve(port: int, host: str, json_output: bool) -> None:
    """Start anima as a remote node exposing MCP server."""
    serve_impl(port=port, host=host, json_output=json_output, console=console)


@main.command()
@click.argument("agent_id")
@click.option("--json", "json_output", is_flag=True, help="Machine-readable output")
# @invar:allow entry_point_too_thick: Click command with interactive REPL and signal handling
def talk(agent_id: str, json_output: bool) -> None:
    """Open an interactive session with a running agent."""
    talk_impl(agent_id=agent_id, json_output=json_output, console=console)


@main.command()
@click.argument("agent_id", required=False)
@click.option("--all", "rm_all", is_flag=True, help="Delete all agents")
@click.option("--yes", "yes", is_flag=True, help="Skip confirmation for --all")
@click.option("--force", is_flag=True, help="Force delete: kill first if not terminal")
@click.option("--json", "json_output", is_flag=True, help="Machine-readable output")
# @invar:allow entry_point_too_thick: Click command with state validation and conditional kill
def rm(
    agent_id: str | None, rm_all: bool, yes: bool, force: bool, json_output: bool
) -> None:
    """Delete an agent's state directory."""
    if rm_all and agent_id is not None:
        if json_output:
            console.print_json(
                data={
                    "error": {
                        "code": "INVALID_INPUT",
                        "numeric_code": 1,
                        "message": "Cannot use AGENT_ID with --all",
                    }
                }
            )
        else:
            console.print("[red]Error: Cannot use AGENT_ID with --all[/red]")
            console.print("[dim]Usage: anima rm <agent_id> [--force] [--json][/dim]")
            console.print("[dim]       anima rm --all [--force] [--yes] [--json][/dim]")
        sys.exit(1)

    if not rm_all and agent_id is None:
        if json_output:
            console.print_json(
                data={
                    "error": {
                        "code": "INVALID_INPUT",
                        "numeric_code": 1,
                        "message": "Either AGENT_ID or --all is required",
                    }
                }
            )
        else:
            console.print("[red]Error: Either AGENT_ID or --all is required[/red]")
            console.print("[dim]Usage: anima rm <agent_id> [--force] [--json][/dim]")
            console.print("[dim]       anima rm --all [--force] [--yes] [--json][/dim]")
        sys.exit(1)

    if rm_all:
        _rm_bulk(force=force, yes=yes, json_output=json_output, console=console)
        return

    if agent_id is None:
        raise RuntimeError("Unreachable: AGENT_ID required when --all is not set")

    rm_impl(agent_id=agent_id, force=force, json_output=json_output, console=console)


@main.command()
@click.option(
    "--state",
    "states",
    multiple=True,
    type=click.Choice(["completed", "failed", "killed"]),
    help="Filter by terminal state (can repeat). Default: all terminal states.",
)
@click.option(
    "--older-than",
    type=int,
    default=None,
    help="Filter by age in days (e.g., 7 for older than 7 days)",
)
@click.option(
    "--dry-run",
    is_flag=True,
    default=False,
    help="List what would be deleted without deleting (default behavior)",
)
@click.option(
    "--yes",
    is_flag=True,
    default=False,
    help="Skip confirmation and delete immediately",
)
@click.option("--json", "json_output", is_flag=True, help="Machine-readable output")
# @invar:allow entry_point_too_thick: Click command with multiple filters and confirmation
def prune(
    states: tuple[str, ...],
    older_than: int | None,
    dry_run: bool,
    yes: bool,
    json_output: bool,
) -> None:
    """Prune agents in terminal states (completed, failed, killed)."""
    prune_impl(
        states=states,
        older_than=older_than,
        dry_run=dry_run,
        yes=yes,
        json_output=json_output,
        console=console,
    )


# =============================================================================
# Tools command group (scaffold)
# =============================================================================


@main.group()
# @invar:allow entry_point_too_thick: Command group with scaffolded subcommands
def tools() -> None:
    """Tool inspection commands.

    Inspect tools, presets, and runtime tool configurations.
    """
    pass


@tools.command("list")
@click.option("--json", "json_output", is_flag=True, help="Machine-readable output")
# @invar:allow entry_point_too_thick: Click command with table/JSON output formatting
def tools_list(json_output: bool) -> None:
    """List available tool presets.

    Shows tool presets defined in configuration files.
    """
    list_tools_impl(json_output=json_output, console=console)


@tools.command("init")
@click.argument("preset_name", required=False)
@click.option("--json", "json_output", is_flag=True, help="Machine-readable output")
# @invar:allow entry_point_too_thick: Click command with config file I/O
def tools_init(preset_name: str | None, json_output: bool) -> None:
    """Initialize a tool preset configuration.

    Creates or updates ~/.anima/config.toml with tool preset scaffolding.
    Common presets: filesystem, shell, web, github.

    Behavior:
        1. If config missing -> create with defaults + commented templates
        2. If config exists without [tools.*] -> append commented templates
        3. If config exists with [tools.*] -> skip (idempotent)
        4. Print guidance to uncomment presets in config

    Examples:
        anima tools init              # Scaffold all presets
        anima tools init filesystem   # Scaffold only filesystem preset
    """
    init_tools_impl(preset_name=preset_name, json_output=json_output, console=console)


@tools.command("inspect")
@click.option("--agent", "agent_id", help="Agent ID to inspect tools for")
@click.option("--json", "json_output", is_flag=True, help="Machine-readable output")
# @invar:allow entry_point_too_thick: Click command with async MCP connection
def tools_inspect(agent_id: str | None, json_output: bool) -> None:
    """Inspect tools for an agent or current configuration.

    Shows tool configurations and available tools.
    """
    if agent_id is None:
        if json_output:
            console.print_json(
                data={
                    "error": {
                        "code": "MISSING_AGENT_ID",
                        "numeric_code": 1,
                        "message": "--agent is required for runtime inspection",
                    }
                }
            )
        else:
            console.print(
                "[red]Error: --agent is required for runtime inspection[/red]"
            )
            console.print("[dim]Usage: anima tools inspect --agent <id>[/dim]")
        return

    inspect_runtime_tools_impl(
        agent_id=agent_id,
        json_output=json_output,
        console=console,
    )


@tools.command("update-models")
def tools_update_models() -> None:
    """Update model context window registry from litellm.

    Downloads the latest model metadata from litellm's GitHub repository
    and saves it to ~/.anima/model_context_windows.json. This data is used
    to determine context window sizes for compaction threshold decisions.
    """
    from anima.core.model_registry import update_registry_from_litellm

    try:
        count = update_registry_from_litellm()
        console.print(f"[green]Updated model registry: {count} models[/green]")
    except Exception as e:
        console.print(f"[red]Failed to update model registry: {e}[/red]")


# =============================================================================
# Persona command group
# =============================================================================


@main.group()
# @invar:allow entry_point_too_thick: Command group with scaffolded subcommands
def persona() -> None:
    """Manage personas via larva registry."""
    pass


@persona.command("list")
@click.option("--mcp", multiple=True, help="MCP server command")
@click.option("--json-output", "--json", is_flag=True, help="JSON output")
# @invar:allow entry_point_too_thick: Click command with MCP connection
def persona_list(mcp: tuple[str, ...], json_output: bool) -> None:
    """List available personas from larva registry."""
    persona_list_impl(mcp=mcp, json_output=json_output, console=console)


@persona.command("show")
@click.argument("name")
@click.option("--mcp", multiple=True, help="MCP server command")
@click.option("--json-output", "--json", is_flag=True, help="JSON output")
# @invar:allow entry_point_too_thick: Click command with MCP connection
def persona_show(name: str, mcp: tuple[str, ...], json_output: bool) -> None:
    """Show details of a specific persona from larva registry."""
    persona_show_impl(name=name, mcp=mcp, json_output=json_output, console=console)


if __name__ == "__main__":
    main()
