"""Public API for the Yagra package."""

from __future__ import annotations

import argparse
import json
import sys
from collections.abc import Mapping
from datetime import UTC
from os import PathLike
from pathlib import Path
from typing import Any

from langchain_core.runnables.config import RunnableConfig
from langgraph.checkpoint.base import BaseCheckpointSaver
from langgraph.graph.state import CompiledStateGraph

from yagra.adapters.inbound import create_workflow_studio_server
from yagra.adapters.outbound import InMemoryNodeRegistry
from yagra.application.services.template_initializer import (
    FileAlreadyExistsError,
    TemplateNotFoundError,
    initialize_from_template,
    list_templates,
    list_templates_with_info,
)
from yagra.application.use_cases import (
    WorkflowValidationIssue,
    WorkflowValidationReport,
    build_from_workflow_path,
    format_validation_report,
    validate_workflow_for_ui,
    validate_workflow_payload_for_ui,
)
from yagra.domain.entities import GraphSpec, WorkflowRunTrace
from yagra.domain.entities.comparison import ComparisonStrategy
from yagra.ports.outbound import NodeHandler, NodeRegistryPort


class Yagra:
    """Wrapper for executing LangGraph built from workflow YAML."""

    def __init__(
        self,
        compiled_graph: CompiledStateGraph,
        trace_collector: Any | None = None,
    ) -> None:
        """Initializes with a compiled graph.

        Args:
            compiled_graph: Compiled graph generated by `build_state_graph`.
            trace_collector: Optional TraceCollector instance used during invoke().
        """
        self._compiled_graph = compiled_graph
        self._trace_collector = trace_collector
        self._last_trace: WorkflowRunTrace | None = None

    @classmethod
    def from_workflow(
        cls,
        workflow_path: str | PathLike[str],
        registry: NodeRegistryPort | Mapping[str, NodeHandler],
        bundle_root: str | PathLike[str] | None = None,
        state_schema: Any = None,
        checkpointer: BaseCheckpointSaver | None = None,
        observability: bool = False,
    ) -> Yagra:
        """Creates a `Yagra` instance from a workflow file.

        If `interrupt_before` / `interrupt_after` are defined in the workflow YAML,
        a `checkpointer` must be specified to enable HITL (Human-in-the-Loop) functionality.
        If `checkpointer` is None, the interrupt configuration is ignored.

        Args:
            workflow_path: Path to the entry `workflow.yaml`.
            registry: Registry implementation resolving handler names to callables, or handler mapping.
            bundle_root: Base directory for resolving split references. Defaults to workflow parent directory if not specified.
            state_schema: LangGraph state schema. If None, automatically resolved
                from the workflow's state_schema definition or defaults to dict.
            checkpointer: LangGraph checkpointer. Required when using `interrupt_before` / `interrupt_after`.
            observability: If True, enables execution tracing (G-14, G-15).
                Each invoke() call records node-level traces in memory and can be
                read via `get_last_trace()`. Trace files are written to
                .yagra/traces/ only when trace=True is passed to invoke().

        Returns:
            `Yagra` instance containing the compiled graph.
        """
        from yagra.application.use_cases.trace_collector import TraceCollector  # noqa: PLC0415

        normalized_registry = _normalize_registry(registry)
        trace_collector: TraceCollector | None = None
        if observability:
            from yagra.application.use_cases.workflow_loader import (
                load_graph_spec_from_workflow,  # noqa: PLC0415
            )

            spec = load_graph_spec_from_workflow(
                workflow_path=workflow_path,
                bundle_root=bundle_root,
            )
            trace_collector = TraceCollector(
                workflow_name=Path(workflow_path).stem,
                workflow_version=spec.version,
                workflow_path=str(Path(workflow_path).expanduser().resolve()),
            )
        compiled = build_from_workflow_path(
            workflow_path=workflow_path,
            registry=normalized_registry,
            bundle_root=bundle_root,
            state_schema=state_schema,
            checkpointer=checkpointer,
            trace_collector=trace_collector,
        )
        return cls(compiled, trace_collector=trace_collector)

    @property
    def compiled_graph(self) -> CompiledStateGraph:
        """Returns the held compiled graph."""
        return self._compiled_graph

    def invoke(
        self,
        state: Mapping[str, Any],
        thread_id: str | None = None,
        trace: bool = False,
        trace_dir: str | Path | None = None,
    ) -> dict[str, Any]:
        """Executes the graph with an initial state.

        When using HITL (Human-in-the-Loop), specify `thread_id`.
        If interrupted by `interrupt_before` / `interrupt_after`, the return value is the state just before interruption.
        Call `resume()` to resume.

        Args:
            state: State dictionary at execution start.
            thread_id: Thread ID used for checkpoint management.
                Specify a unique string when using HITL.
                If None, state is not saved even if a checkpointer is configured.
            trace: If True and observability=True was set in from_workflow(),
                writes a structured JSON trace to .yagra/traces/ after execution.
                In-memory trace capture is always enabled for observability=True,
                regardless of this flag.
            trace_dir: Override the trace output directory.
                Defaults to .yagra/traces/ relative to cwd if not specified.

        Returns:
            State dictionary after execution (or at interruption point).

        Raises:
            TypeError: If the graph execution result is not dict-compatible.
        """
        from datetime import datetime
        from time import perf_counter

        config: RunnableConfig = (
            {"configurable": {"thread_id": thread_id}} if thread_id is not None else {}
        )

        if self._trace_collector is not None:
            self._trace_collector.reset()
            self._last_trace = None

        started_at = datetime.now(tz=UTC)
        t0 = perf_counter()
        result = self._compiled_graph.invoke(dict(state), config=config)
        total_ms = (perf_counter() - t0) * 1000.0
        ended_at = datetime.now(tz=UTC)

        if not isinstance(result, Mapping):
            raise TypeError("compiled graph returned non-mapping result")

        if self._trace_collector is not None:
            run_trace = self._trace_collector.build_trace(
                started_at=started_at,
                ended_at=ended_at,
                total_duration_ms=total_ms,
            )
            self._last_trace = run_trace

            if trace:
                from yagra.adapters.outbound.local_trace_sink import LocalTraceSink  # noqa: PLC0415

                sink = LocalTraceSink(
                    base_dir=Path(trace_dir) if trace_dir else Path(".yagra/traces"),
                )
                trace_path = sink.write(run_trace)
                import warnings  # noqa: PLC0415

                warnings.warn(  # noqa: B028
                    f"[yagra] trace written to: {trace_path}",
                    stacklevel=1,
                )

        return dict(result)

    def get_last_trace(self) -> WorkflowRunTrace | None:
        """Returns the trace captured during the most recent invoke() call.

        Returns:
            The last WorkflowRunTrace when observability is enabled and
            invoke() has been executed; otherwise None.
        """
        return self._last_trace

    def resume(
        self,
        update: Mapping[str, Any] | None = None,
        thread_id: str | None = None,
    ) -> dict[str, Any]:
        """Resumes a graph that was interrupted.

        Resumes a workflow that was interrupted by `invoke()`.
        You can modify the state with `update` before resuming (e.g., injecting human review results).

        Args:
            update: Update dict to merge into state before resuming. If None, resumes without modifying state.
            thread_id: Thread ID specified at `invoke()` time.

        Returns:
            State dictionary after resuming execution.

        Raises:
            TypeError: If the graph execution result is not dict-compatible.
            ValueError: If thread_id is not provided when checkpointer requires it.
        """
        config: RunnableConfig = (
            {"configurable": {"thread_id": thread_id}} if thread_id is not None else {}
        )
        if update is not None:
            self._compiled_graph.update_state(config, dict(update))
        result = self._compiled_graph.invoke(None, config=config)
        if not isinstance(result, Mapping):
            raise TypeError("compiled graph returned non-mapping result")
        return dict(result)


def main() -> None:
    """CLI entry point for Yagra."""
    parser = _build_cli_parser()
    args = parser.parse_args()

    if args.command == "init":
        exit_code = _run_init_command(args)
        raise SystemExit(exit_code)
    if args.command == "schema":
        exit_code = _run_schema_command(args)
        raise SystemExit(exit_code)
    if args.command == "validate":
        exit_code = _run_validate_command(args)
        raise SystemExit(exit_code)
    if args.command == "studio":
        exit_code = _run_studio_command(args)
        raise SystemExit(exit_code)
    if args.command == "handlers":
        exit_code = _run_handlers_command(args)
        raise SystemExit(exit_code)
    if args.command == "explain":
        exit_code = _run_explain_command(args)
        raise SystemExit(exit_code)
    if args.command == "analyze":
        exit_code = _run_analyze_command(args)
        raise SystemExit(exit_code)
    if args.command == "mcp":
        exit_code = _run_mcp_command(args)
        raise SystemExit(exit_code)
    if args.command == "golden":
        exit_code = _run_golden_command(args)
        raise SystemExit(exit_code)
    if args.command == "prompt":
        exit_code = _run_prompt_command(args)
        raise SystemExit(exit_code)
    raise SystemExit(2)


def _normalize_registry(registry: NodeRegistryPort | Mapping[str, NodeHandler]) -> NodeRegistryPort:
    """Normalizes the registry argument to a `NodeRegistryPort` implementation.

    Args:
        registry: Registry implementation or handler mapping.

    Returns:
        `NodeRegistryPort` implementation.

    Raises:
        TypeError: If an unsupported type is passed.
    """
    if isinstance(registry, NodeRegistryPort):
        return registry
    if isinstance(registry, Mapping):
        return InMemoryNodeRegistry(registry)
    raise TypeError("registry must be NodeRegistryPort or mapping[str, NodeHandler]")


def _run_init_command(args: argparse.Namespace) -> int:
    """Executes the `init` subcommand.

    Args:
        args: Parsed CLI arguments.

    Returns:
        Exit code. 0 on success.
    """
    # Show template list
    if args.list:
        names = list_templates()
        if not names:
            print("No templates available.")
            return 1
        infos = {info.name: info for info in list_templates_with_info()}
        print("Available templates:")
        for name in names:
            info = infos.get(name)
            if info and info.use_case:
                print(f"  - {name}  [{info.use_case}]")
            else:
                print(f"  - {name}")
            if info and info.description:
                print(f"      {info.description}")
        return 0

    # Error if no template name is specified
    if args.template is None:
        print(
            "Error: specify either --template or --list.",
            file=sys.stderr,
        )
        return 2

    output_dir = Path(args.output).expanduser().resolve()

    try:
        initialize_from_template(
            template_name=args.template,
            output_dir=output_dir,
            force=args.force,
        )
    except TemplateNotFoundError as e:
        print(str(e), file=sys.stderr)
        return 1
    except FileAlreadyExistsError as e:
        print(str(e), file=sys.stderr)
        return 1

    print(f"Initialized from template '{args.template}': {output_dir}")

    # Validate the generated workflow
    workflow_path = output_dir / "workflow.yaml"
    if workflow_path.exists():
        print(f"\nValidating workflow: {workflow_path}")
        report = validate_workflow_for_ui(
            workflow_path=str(workflow_path),
            bundle_root=None,
        )
        if report.is_valid:
            print("✓ workflow is valid.")
        else:
            print("✗ workflow has issues:", file=sys.stderr)
            print(format_validation_report(report), file=sys.stderr)
            return 1

    return 0


def _build_cli_parser() -> argparse.ArgumentParser:
    """Builds the CLI parser.

    Returns:
        Instance of `argparse.ArgumentParser`.
    """
    parser = argparse.ArgumentParser(prog="yagra")
    subparsers = parser.add_subparsers(dest="command", required=True)

    init = subparsers.add_parser(
        "init",
        help="Initialize a workflow from a template",
    )
    init.add_argument(
        "--template",
        default=None,
        help="Template name to use (e.g. branch, loop, rag)",
    )
    init.add_argument(
        "--list",
        action="store_true",
        help="List available templates",
    )
    init.add_argument(
        "--force",
        action="store_true",
        help="Overwrite existing files",
    )
    init.add_argument(
        "--output",
        default=".",
        help="Output directory (default: current directory)",
    )

    schema = subparsers.add_parser(
        "schema",
        help="Output the JSON Schema for workflow YAML",
    )
    schema.add_argument(
        "--output",
        default=None,
        help="Output file path (stdout if not specified)",
    )

    validate = subparsers.add_parser(
        "validate",
        help="Validate workflow YAML",
    )
    validate.add_argument(
        "--workflow",
        required=True,
        help="Path to the workflow YAML to validate",
    )
    validate.add_argument(
        "--bundle-root",
        default=None,
        help="Base directory for resolving split references",
    )
    validate.add_argument(
        "--format",
        choices=["text", "json"],
        default="text",
        help="Output format (default: text)",
    )

    studio = subparsers.add_parser(
        "studio",
        help="Start the local WebUI/API for workflow editing",
    )
    studio.add_argument(
        "--workflow",
        default=None,
        help="Path to the workflow YAML to edit (select/create in UI if not specified)",
    )
    studio.add_argument(
        "--bundle-root",
        default=None,
        help="Base directory for resolving split references",
    )
    studio.add_argument(
        "--ui-state",
        default=None,
        help="Path to the UI sidecar JSON (defaults to <workflow>.workflow-ui.json)",
    )
    studio.add_argument(
        "--workspace-root",
        default=None,
        help=(
            "Workspace root for Studio to search/create workflows. "
            "Defaults to current dir if workflow is under it, "
            "otherwise workflow parent dir, or current dir if no workflow specified."
        ),
    )
    studio.add_argument(
        "--backup-dir",
        default=".yagra/backups",
        help="Directory for storing backups",
    )
    studio.add_argument(
        "--host",
        default="127.0.0.1",
        help="Bind host",
    )
    studio.add_argument(
        "--port",
        type=int,
        default=8787,
        help="Bind port",
    )

    handlers = subparsers.add_parser(
        "handlers",
        help="Display the params schema for built-in handlers",
    )
    handlers.add_argument(
        "--format",
        choices=["text", "json"],
        default="text",
        help="Output format (default: text)",
    )

    explain = subparsers.add_parser(
        "explain",
        help="Statically analyze workflow YAML and output execution information",
    )
    explain.add_argument(
        "--workflow",
        required=True,
        help="Path to the workflow YAML to analyze",
    )
    explain.add_argument(
        "--bundle-root",
        default=None,
        help="Base directory for resolving split references",
    )
    explain.add_argument(
        "--format",
        choices=["text", "json"],
        default="json",
        help="Output format (default: json)",
    )

    analyze = subparsers.add_parser(
        "analyze",
        help="Aggregate and summarize execution traces",
    )
    analyze.add_argument(
        "--trace-dir",
        default=".yagra/traces",
        help="Root trace directory (default: .yagra/traces)",
    )
    analyze.add_argument(
        "--workflow",
        default=None,
        help="Filter by workflow name",
    )
    analyze.add_argument(
        "--limit",
        type=int,
        default=None,
        help="Limit to N most recent traces",
    )
    analyze.add_argument(
        "--format",
        choices=["json", "text"],
        default="text",
        dest="output_format",
        help="Output format (default: text)",
    )

    mcp_parser = subparsers.add_parser(
        "mcp",
        help="Start the MCP server in stdio mode (requires yagra[mcp])",
    )
    # mcp subcommand takes no arguments
    _ = mcp_parser

    # --- golden subcommand group ---
    golden = subparsers.add_parser(
        "golden",
        help="Manage golden test cases for regression testing",
    )
    golden_sub = golden.add_subparsers(dest="golden_command", required=True)

    golden_save = golden_sub.add_parser(
        "save",
        help="Create a golden case from a trace file",
    )
    golden_save.add_argument(
        "--trace",
        required=True,
        help="Path to the trace JSON file",
    )
    golden_save.add_argument(
        "--name",
        required=True,
        help="Name for the golden case (kebab-case)",
    )
    golden_save.add_argument(
        "--description",
        default="",
        help="Human-readable description",
    )
    golden_save.add_argument(
        "--strategy",
        action="append",
        default=None,
        metavar="NODE_ID:STRATEGY",
        help=(
            "Per-node comparison strategy override (repeatable). "
            "Format: node_id:strategy. "
            "Valid strategies: exact, structural, skip, auto. "
            "Example: --strategy node_a:structural --strategy node_b:skip"
        ),
    )
    golden_save.add_argument(
        "--golden-dir",
        default=".yagra/golden",
        help="Golden case directory (default: .yagra/golden)",
    )

    golden_test = golden_sub.add_parser(
        "test",
        help="Run golden tests against a workflow",
    )
    golden_test.add_argument(
        "--workflow",
        required=True,
        help="Path to the workflow YAML to test",
    )
    golden_test.add_argument(
        "--name",
        default=None,
        help="Specific case name (runs all cases if omitted)",
    )
    golden_test.add_argument(
        "--golden-dir",
        default=".yagra/golden",
        help="Golden case directory (default: .yagra/golden)",
    )
    golden_test.add_argument(
        "--bundle-root",
        default=None,
        help="Base directory for resolving split references",
    )
    golden_test.add_argument(
        "--format",
        choices=["text", "json"],
        default="text",
        dest="output_format",
        help="Output format (default: text)",
    )

    golden_list = golden_sub.add_parser(
        "list",
        help="List saved golden cases",
    )
    golden_list.add_argument(
        "--workflow",
        default=None,
        help="Filter by workflow name",
    )
    golden_list.add_argument(
        "--golden-dir",
        default=".yagra/golden",
        help="Golden case directory (default: .yagra/golden)",
    )
    golden_list.add_argument(
        "--format",
        choices=["text", "json"],
        default="text",
        dest="output_format",
        help="Output format (default: text)",
    )

    # --- prompt subcommand group ---
    prompt = subparsers.add_parser(
        "prompt",
        help="Manage and inspect prompt YAML files",
    )
    prompt_sub = prompt.add_subparsers(dest="prompt_command", required=True)

    prompt_info = prompt_sub.add_parser(
        "info",
        help="Display metadata and version info for a prompt YAML file",
    )
    prompt_info.add_argument(
        "--file",
        required=True,
        help="Path to the prompt YAML file",
    )
    prompt_info.add_argument(
        "--format",
        choices=["text", "json"],
        default="text",
        dest="output_format",
        help="Output format (default: text)",
    )

    return parser


def _run_schema_command(args: argparse.Namespace) -> int:
    """Executes the `schema` subcommand.

    Outputs the JSON Schema for GraphSpec.
    Provides schema information for coding agents to generate accurate workflow YAML.

    Args:
        args: Parsed CLI arguments.

    Returns:
        Exit code. 0 on success.
    """
    schema = GraphSpec.model_json_schema()
    schema_json = json.dumps(schema, indent=2, ensure_ascii=False)

    if args.output:
        output_path = Path(args.output).expanduser().resolve()
        output_path.parent.mkdir(parents=True, exist_ok=True)
        output_path.write_text(schema_json + "\n", encoding="utf-8")
        print(f"schema exported: {output_path}")
    else:
        print(schema_json)
    return 0


def _run_validate_command(args: argparse.Namespace) -> int:
    """Executes the `validate` subcommand.

    Validates workflow YAML and outputs the result in text or JSON format.
    JSON format can be used for error correction loops by coding agents.
    When ``--workflow -`` is specified, reads YAML from stdin.

    Args:
        args: Parsed CLI arguments.

    Returns:
        Exit code. valid → 0, invalid → 1.
    """
    import yaml

    if args.workflow == "-":
        yaml_text = sys.stdin.read()
        try:
            payload = yaml.safe_load(yaml_text)
        except yaml.YAMLError as exc:
            issue = WorkflowValidationIssue(
                code="schema_error",
                message=f"YAML parse error: {exc}",
                location=(),
            )
            report = WorkflowValidationReport(issues=[issue])
        else:
            if not isinstance(payload, dict):
                issue = WorkflowValidationIssue(
                    code="schema_error",
                    message="workflow must be a mapping",
                    location=(),
                )
                report = WorkflowValidationReport(issues=[issue])
            else:
                report = validate_workflow_payload_for_ui(
                    payload=payload,
                    workflow_path=Path("<stdin>"),
                    bundle_root=None,
                )
    else:
        report = validate_workflow_for_ui(
            workflow_path=args.workflow,
            bundle_root=args.bundle_root,
        )

    if args.format == "json":
        report_dict = {
            "is_valid": report.is_valid,
            "issues": [issue.to_dict() for issue in report.issues],
        }
        print(json.dumps(report_dict, indent=2, ensure_ascii=False))
    else:
        print(
            format_validation_report(report), file=sys.stderr if not report.is_valid else sys.stdout
        )

    return 0 if report.is_valid else 1


def _run_studio_command(args: argparse.Namespace) -> int:
    """Executes the `studio` subcommand.

    Args:
        args: Parsed CLI arguments.

    Returns:
        Exit code. 0 on success.
    """
    if args.workflow is None and args.ui_state is not None:
        print(
            "--ui-state is only available when --workflow is specified.",
            file=sys.stderr,
        )
        return 2

    server = create_workflow_studio_server(
        workflow_path=args.workflow,
        bundle_root=args.bundle_root,
        ui_state_path=args.ui_state,
        workspace_root=args.workspace_root,
        backup_dir=args.backup_dir,
        host=args.host,
        port=args.port,
    )
    base_url = f"http://{args.host}:{args.port}"
    print(f"workflow studio started: {base_url}")
    print("press Ctrl+C to stop")
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass
    finally:
        server.server_close()
    return 0


def _run_handlers_command(args: argparse.Namespace) -> int:
    """Executes the `handlers` subcommand.

    Outputs the params schema for each built-in handler in text or JSON format.

    Args:
        args: Parsed CLI arguments.

    Returns:
        Exit code. 0 on success.
    """
    from yagra.handlers.catalog import BUILTIN_HANDLERS_INFO

    handlers_info = BUILTIN_HANDLERS_INFO

    if args.format == "json":
        print(json.dumps({"handlers": handlers_info}, indent=2, ensure_ascii=False))
    else:
        for h in handlers_info:
            print(f"## {h['name']}")
            print(f"  {h['description']}")
            print(f"  params: {json.dumps(h['params_schema'], ensure_ascii=False)}")
            print()
    return 0


def _run_explain_command(args: argparse.Namespace) -> int:
    """Executes the `explain` subcommand.

    Statically analyzes the workflow YAML and outputs execution paths,
    required handlers, and variable flow in JSON format.
    Useful for coding agents to understand a workflow before running it.

    Args:
        args: Parsed CLI arguments.

    Returns:
        Exit code. 0 on success, 1 on validation failure.
    """
    from yagra.application.use_cases.workflow_explainer import explain_workflow
    from yagra.application.use_cases.workflow_validation_reporter import (
        WorkflowValidationFailedError,
        load_validated_graph_spec,
    )

    try:
        spec = load_validated_graph_spec(
            workflow_path=args.workflow,
            bundle_root=args.bundle_root,
        )
    except WorkflowValidationFailedError as exc:
        print(format_validation_report(exc.report), file=sys.stderr)
        return 1

    result = explain_workflow(spec)

    if args.format == "json":
        print(json.dumps(result, indent=2, ensure_ascii=False))
    else:
        print(f"entry_point: {result['entry_point']}")
        print(f"exit_points: {result['exit_points']}")
        print(f"required_handlers: {result['required_handlers']}")
        print("execution_paths:")
        for path in result["execution_paths"]:
            print(f"  {' -> '.join(path)}")
        print("variable_flow:")
        for node_id, flow in result["variable_flow"].items():
            print(f"  {node_id}: inputs={flow['inputs']} outputs={flow['outputs']}")
    return 0


def _run_analyze_command(args: argparse.Namespace) -> int:
    """Executes the `analyze` subcommand.

    Loads execution traces, aggregates statistics, and outputs the result
    in text or JSON format.

    Args:
        args: Parsed CLI arguments.

    Returns:
        Exit code. 0 on success, 1 if no traces found.
    """
    from yagra.application.use_cases.trace_aggregator import (  # noqa: PLC0415
        aggregate_traces,
        load_traces,
    )

    traces = load_traces(
        args.trace_dir,
        workflow_name=args.workflow,
        limit=args.limit,
    )
    if not traces:
        print("No traces found.", file=sys.stderr)
        return 1

    summary = aggregate_traces(traces)

    if args.output_format == "json":
        print(json.dumps(summary.to_dict(), indent=2, ensure_ascii=False))
    else:
        _print_analyze_text(summary)

    return 0


def _print_analyze_text(summary: Any) -> None:
    """Print a human-readable text summary of aggregated trace data.

    Displays workflow-level statistics followed by a per-node table with
    key metrics such as execution count, success rate, duration, and tokens.

    Args:
        summary: AggregatedSummary instance to display.
    """
    print(f"Workflow: {summary.workflow_name}")
    print(
        f"Runs: {summary.total_runs} (success: {summary.successful_runs}, "
        f"failed: {summary.failed_runs})"
    )
    print(f"Success rate: {summary.run_success_rate:.1%}")
    print(
        f"Duration (avg/min/max): "
        f"{summary.avg_total_duration_ms:.1f}ms / "
        f"{summary.min_total_duration_ms:.1f}ms / "
        f"{summary.max_total_duration_ms:.1f}ms"
    )
    print(
        f"Total tokens: {summary.total_tokens} "
        f"(prompt: {summary.total_prompt_tokens}, "
        f"completion: {summary.total_completion_tokens})"
    )
    if summary.total_estimated_cost_usd is not None:
        print(f"Estimated cost: ${summary.total_estimated_cost_usd:.6f}")
    else:
        print("Estimated cost: N/A")
    print(f"Time range: {summary.time_range_start} - {summary.time_range_end}")
    print()

    if not summary.node_stats:
        print("No node statistics available.")
        return

    # Per-node table
    header = (
        f"{'Node':<20} {'Runs':>5} {'OK%':>6} "
        f"{'Avg(ms)':>9} {'P50(ms)':>9} {'P95(ms)':>9} "
        f"{'Tokens':>8} {'Errors':>6}"
    )
    print(header)
    print("-" * len(header))
    for ns in summary.node_stats:
        node_label = ns.node_id[:20]
        print(
            f"{node_label:<20} {ns.execution_count:>5} {ns.success_rate:>5.0%} "
            f"{ns.avg_duration_ms:>9.1f} {ns.p50_duration_ms:>9.1f} "
            f"{ns.p95_duration_ms:>9.1f} {ns.total_tokens:>8} {ns.error_count:>6}"
        )
        if ns.error_types:
            for err_type, count in ns.error_types.items():
                print(f"  error: {err_type} ({count}x)")


def _run_mcp_command(args: argparse.Namespace) -> int:
    """Executes the `mcp` subcommand.

    Starts the Yagra MCP server in stdio mode.
    Requires the 'mcp' optional dependency: uv add yagra[mcp]

    Args:
        args: Parsed CLI arguments.

    Returns:
        Exit code. 0 on success.
    """
    import asyncio

    try:
        from yagra.adapters.inbound.mcp_server import run_mcp_server
    except ImportError as exc:
        print(str(exc), file=sys.stderr)
        return 1
    asyncio.run(run_mcp_server())
    return 0


def _run_golden_command(args: argparse.Namespace) -> int:
    """Dispatches the golden subcommand to the appropriate handler.

    Args:
        args: Parsed CLI arguments including golden_command.

    Returns:
        Exit code. 0 on success.
    """
    if args.golden_command == "save":
        return _run_golden_save_command(args)
    if args.golden_command == "test":
        return _run_golden_test_command(args)
    if args.golden_command == "list":
        return _run_golden_list_command(args)
    return 2


def _parse_strategy_overrides(
    raw: list[str] | None,
) -> dict[str, ComparisonStrategy] | None:
    """Parses ``--strategy`` CLI arguments into comparison overrides.

    Args:
        raw: List of ``node_id:strategy`` entries from argparse, or None.

    Returns:
        Mapping of node_id to ComparisonStrategy, or None if not specified.

    Raises:
        ValueError: If an entry format is invalid, strategy is unknown,
            or duplicate node_id is provided.
    """
    if raw is None:
        return None

    valid_strategies = {strategy.value for strategy in ComparisonStrategy}
    overrides: dict[str, ComparisonStrategy] = {}

    for entry in raw:
        if ":" not in entry:
            msg = (
                f"Invalid --strategy format: '{entry}'. "
                "Expected 'node_id:strategy' (e.g. 'node_a:structural')."
            )
            raise ValueError(msg)

        node_id, _, strategy_name = entry.partition(":")
        if not node_id:
            msg = f"Invalid --strategy format: '{entry}'. node_id must not be empty."
            raise ValueError(msg)

        if strategy_name not in valid_strategies:
            valid = ", ".join(sorted(valid_strategies))
            msg = (
                f"Unknown strategy '{strategy_name}' for node '{node_id}'. "
                f"Valid strategies: {valid}."
            )
            raise ValueError(msg)

        if node_id in overrides:
            msg = f"Duplicate --strategy for node '{node_id}'."
            raise ValueError(msg)

        overrides[node_id] = ComparisonStrategy(strategy_name)

    return overrides


def _run_golden_save_command(args: argparse.Namespace) -> int:
    """Executes the `golden save` subcommand.

    Loads a trace JSON file and creates a golden case from it.

    Args:
        args: Parsed CLI arguments with trace, name, description, strategy, golden_dir.

    Returns:
        Exit code. 0 on success, 1 on failure.
    """
    from yagra.adapters.outbound.local_golden_case_store import (
        LocalGoldenCaseStore,  # noqa: PLC0415
    )
    from yagra.application.use_cases.golden_case_manager import GoldenCaseManager  # noqa: PLC0415
    from yagra.domain.entities.trace import WorkflowRunTrace  # noqa: PLC0415

    trace_path = Path(args.trace)
    if not trace_path.exists():
        print(f"Error: trace file not found: {trace_path}", file=sys.stderr)
        return 1

    try:
        raw = trace_path.read_text(encoding="utf-8")
        data = json.loads(raw)
        trace = WorkflowRunTrace.model_validate(data)
    except (json.JSONDecodeError, OSError) as exc:
        print(f"Error: failed to read trace file: {exc}", file=sys.stderr)
        return 1
    except Exception as exc:
        print(f"Error: invalid trace format: {exc}", file=sys.stderr)
        return 1

    store = LocalGoldenCaseStore(base_dir=Path(args.golden_dir))
    manager = GoldenCaseManager(store)

    try:
        comparison_overrides = _parse_strategy_overrides(args.strategy)
    except ValueError as exc:
        print(f"Error: {exc}", file=sys.stderr)
        return 1

    try:
        golden = manager.save_from_trace(
            trace=trace,
            case_name=args.name,
            description=args.description,
            comparison_overrides=comparison_overrides,
        )
    except ValueError as exc:
        print(f"Error: {exc}", file=sys.stderr)
        return 1

    print(
        f"Golden case saved: {golden.workflow_name}/{golden.case_name} "
        f"({len(golden.execution_path)} nodes)"
    )
    return 0


def _run_golden_test_command(args: argparse.Namespace) -> int:
    """Executes the `golden test` subcommand.

    Runs golden test cases against a workflow YAML and reports results.

    Args:
        args: Parsed CLI arguments with workflow, name, golden_dir, format.

    Returns:
        Exit code. 0 if all tests pass, 1 if any fail or error.
    """
    from yagra.adapters.outbound.local_golden_case_store import (
        LocalGoldenCaseStore,  # noqa: PLC0415
    )
    from yagra.application.use_cases.golden_test_runner import GoldenTestRunner  # noqa: PLC0415
    from yagra.ports.outbound.golden_case_repository import GoldenCaseNotFoundError  # noqa: PLC0415

    workflow_path = Path(args.workflow)
    if not workflow_path.exists():
        print(f"Error: workflow file not found: {workflow_path}", file=sys.stderr)
        return 1

    store = LocalGoldenCaseStore(base_dir=Path(args.golden_dir))
    runner = GoldenTestRunner(store)
    workflow_name = workflow_path.stem

    try:
        if args.name is not None:
            try:
                golden_case = store.load(workflow_name, args.name)
            except GoldenCaseNotFoundError:
                print(
                    f"Error: golden case not found: {workflow_name}/{args.name}",
                    file=sys.stderr,
                )
                return 1
            results = [
                runner.run(
                    golden_case=golden_case,
                    workflow_path=workflow_path,
                    bundle_root=args.bundle_root,
                )
            ]
        else:
            results = runner.run_all(
                workflow_name=workflow_name,
                workflow_path=workflow_path,
                bundle_root=args.bundle_root,
            )
    except Exception as exc:
        print(f"Error: golden test execution failed: {exc}", file=sys.stderr)
        return 1

    if not results:
        print(f"No golden cases found for workflow '{workflow_name}'.", file=sys.stderr)
        return 1

    if args.output_format == "json":
        output = [r.model_dump(mode="json") for r in results]
        print(json.dumps(output, indent=2, ensure_ascii=False))
    else:
        _print_golden_test_results(results)

    all_passed = all(r.passed for r in results)
    return 0 if all_passed else 1


def _print_golden_test_results(results: list[Any]) -> None:
    """Prints golden test results in human-readable text format.

    Displays a summary line per case and per-node details for failures.

    Args:
        results: List of GoldenTestResult instances.
    """
    total = len(results)
    passed_count = sum(1 for r in results if r.passed)
    failed_count = total - passed_count

    for result in results:
        status = "PASS" if result.passed else "FAIL"
        print(f"  [{status}] {result.case_name}: {result.summary}")

        if not result.passed:
            if not result.execution_path_match:
                print(f"    expected path: {result.expected_path}")
                print(f"    actual path:   {result.actual_path}")
            for nr in result.node_results:
                if nr.status not in ("pass", "skip"):
                    print(f"    node '{nr.node_id}': {nr.status} - {nr.message}")

    print()
    print(f"Results: {passed_count} passed, {failed_count} failed, {total} total")


def _run_golden_list_command(args: argparse.Namespace) -> int:
    """Executes the `golden list` subcommand.

    Lists saved golden cases, optionally filtered by workflow name.

    Args:
        args: Parsed CLI arguments with workflow, golden_dir, format.

    Returns:
        Exit code. 0 on success.
    """
    from yagra.adapters.outbound.local_golden_case_store import (
        LocalGoldenCaseStore,  # noqa: PLC0415
    )
    from yagra.application.use_cases.golden_case_manager import GoldenCaseManager  # noqa: PLC0415

    store = LocalGoldenCaseStore(base_dir=Path(args.golden_dir))
    manager = GoldenCaseManager(store)
    cases = manager.list_cases(workflow_name=args.workflow)

    if args.output_format == "json":
        output = [c.model_dump(mode="json") for c in cases]
        print(json.dumps(output, indent=2, ensure_ascii=False))
    else:
        if not cases:
            print("No golden cases found.")
        else:
            for case in cases:
                desc = f" - {case.description}" if case.description else ""
                print(f"  {case.workflow_name}/{case.case_name}{desc}")
                print(f"    created: {case.created_at.isoformat()}")
                print(f"    nodes: {len(case.execution_path)}")
    return 0


def _run_prompt_command(args: argparse.Namespace) -> int:
    """Dispatches the prompt subcommand to the appropriate handler.

    Args:
        args: Parsed CLI arguments including prompt_command.

    Returns:
        Exit code. 0 on success.
    """
    if args.prompt_command == "info":
        return _run_prompt_info_command(args)
    return 2


def _run_prompt_info_command(args: argparse.Namespace) -> int:
    """Displays metadata and version info for a prompt YAML file.

    Reads a prompt YAML file and prints its ``_meta`` section,
    available prompt keys, and version information.

    Args:
        args: Parsed CLI arguments with ``file`` and ``output_format``.

    Returns:
        Exit code. 0 on success, 1 on error.
    """
    import yaml

    prompt_path = Path(args.file).expanduser().resolve()
    if not prompt_path.exists():
        print(f"Error: file not found: {prompt_path}", file=sys.stderr)
        return 1

    try:
        with prompt_path.open("r", encoding="utf-8") as f:
            data = yaml.safe_load(f)
    except yaml.YAMLError as exc:
        print(f"Error: failed to parse YAML: {exc}", file=sys.stderr)
        return 1

    if not isinstance(data, dict):
        print("Error: prompt file must be a YAML mapping", file=sys.stderr)
        return 1

    meta = data.get("_meta", {})
    if not isinstance(meta, dict):
        meta = {}
    version = meta.get("version", "(not set)")
    changelog: list[Any] = meta.get("changelog", [])
    if not isinstance(changelog, list):
        changelog = []
    prompt_keys = [k for k in data if k != "_meta"]

    if args.output_format == "json":
        result = {
            "file": str(prompt_path),
            "version": meta.get("version"),
            "changelog": changelog,
            "prompt_keys": prompt_keys,
            "meta": meta,
        }
        print(json.dumps(result, indent=2, ensure_ascii=False))
    else:
        print(f"File: {prompt_path}")
        print(f"Version: {version}")
        if changelog:
            print("Changelog:")
            for entry in changelog:
                print(f"  - {entry}")
        print(f"Prompt keys: {', '.join(prompt_keys) or '(none)'}")

    return 0


__all__ = ["Yagra", "main"]
