"""CLI entry point for mcp-recorder."""

from __future__ import annotations

import logging
import shlex
import sys
from pathlib import Path

import click
import uvicorn

from mcp_recorder import __version__
from mcp_recorder._types import Cassette, CassetteMetadata, InteractionType
from mcp_recorder._utils import load_cassette, save_cassette
from mcp_recorder.matcher import create_matcher
from mcp_recorder.proxy import create_proxy_app
from mcp_recorder.replayer import create_replay_app
from mcp_recorder.scenarios import load_scenarios_file, run_scenarios
from mcp_recorder.scrubber import scrub_cassette
from mcp_recorder.transport import StdioTransport
from mcp_recorder.verifier import run_verify


@click.group()
@click.version_option(version=__version__, prog_name="mcp-recorder")
def main() -> None:
    """Record, replay, and verify MCP server interactions for deterministic testing."""


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _configure_logging(verbose: bool) -> None:
    """Set up root logging and silence noisy third-party HTTP loggers."""
    log_level = logging.DEBUG if verbose else logging.INFO
    logging.basicConfig(level=log_level, format="%(message)s", stream=sys.stderr)
    if not verbose:
        logging.getLogger("httpx").setLevel(logging.WARNING)
        logging.getLogger("httpcore").setLevel(logging.WARNING)


def _parse_target_env(raw: tuple[str, ...]) -> dict[str, str]:
    """Parse ``KEY=VALUE`` pairs from ``--target-env``."""
    env: dict[str, str] = {}
    for item in raw:
        if "=" not in item:
            raise click.BadParameter(
                f"Expected KEY=VALUE format, got: {item!r}", param_hint="--target-env"
            )
        key, _, value = item.partition("=")
        env[key] = value
    return env


def _build_stdio_transport(
    target_stdio: str,
    target_env: tuple[str, ...],
) -> StdioTransport:
    """Build a :class:`StdioTransport` from CLI flags."""
    parts = shlex.split(target_stdio)
    if not parts:
        raise click.BadParameter("Command string is empty", param_hint="--target-stdio")
    env = _parse_target_env(target_env)
    return StdioTransport(command=parts[0], args=parts[1:], env=env)


def _validate_target(target: str | None, target_stdio: str | None) -> None:
    """Ensure exactly one of --target / --target-stdio is provided."""
    if target and target_stdio:
        raise click.UsageError("--target and --target-stdio are mutually exclusive.")
    if not target and not target_stdio:
        raise click.UsageError("Provide either --target (HTTP) or --target-stdio (subprocess).")


# ---------------------------------------------------------------------------
# record
# ---------------------------------------------------------------------------


@main.command()
@click.option("--target", default=None, help="URL of the real MCP server (HTTP).")
@click.option("--target-stdio", default=None, help="Command to spawn as a stdio MCP server.")
@click.option(
    "--target-env",
    multiple=True,
    help="KEY=VALUE env var passed to the stdio subprocess. Repeatable.",
)
@click.option("--port", default=5555, show_default=True, help="Local proxy port.")
@click.option("--output", default="recording.json", show_default=True, help="Output cassette file.")
@click.option("--verbose", is_flag=True, help="Log full headers and bodies to stderr.")
@click.option(
    "--redact-server-url/--no-redact-server-url",
    default=True,
    show_default=True,
    help="Strip the URL path from metadata (keeps scheme + host).",
)
@click.option(
    "--redact-env",
    multiple=True,
    help="Env var name whose value is redacted from metadata + responses. Repeatable.",
)
@click.option(
    "--redact-patterns",
    multiple=True,
    help="Regex pattern to redact from metadata + responses. Repeatable.",
)
def record(
    target: str | None,
    target_stdio: str | None,
    target_env: tuple[str, ...],
    port: int,
    output: str,
    verbose: bool,
    redact_server_url: bool,
    redact_env: tuple[str, ...],
    redact_patterns: tuple[str, ...],
) -> None:
    """Record interactions from a live MCP server."""
    _validate_target(target, target_stdio)
    _configure_logging(verbose)

    if target_stdio:
        transport = _build_stdio_transport(target_stdio, target_env)
        server_url = f"stdio://{target_stdio}"
        cassette = Cassette(
            metadata=CassetteMetadata(server_url=server_url, transport_type="stdio"),
        )
        app = create_proxy_app(cassette=cassette, transport=transport, verbose=verbose)
        display_target = f"stdio: {target_stdio}"
    else:
        assert target is not None
        cassette = Cassette(
            metadata=CassetteMetadata(server_url=target, transport_type="http"),
        )
        app = create_proxy_app(cassette=cassette, target_url=target, verbose=verbose)
        display_target = target

    output_path = Path(output)
    output_path.parent.mkdir(parents=True, exist_ok=True)

    click.echo(f"Proxying http://localhost:{port} -> {display_target}", err=True)
    click.echo(f"Output:  {output_path}", err=True)
    click.echo("Press Ctrl+C to stop and save the recording.\n", err=True)

    try:
        uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning")
    except KeyboardInterrupt:
        pass
    finally:
        _save_cassette(
            cassette,
            output_path,
            redact_server_url=redact_server_url,
            redact_env=redact_env,
            redact_patterns=redact_patterns,
        )


@main.command("record-scenarios")
@click.argument("scenarios_file", type=click.Path(exists=True))
@click.option(
    "--output-dir",
    default=None,
    help="Output directory for cassettes (default: cassettes/ next to scenarios file).",
)
@click.option(
    "--scenario",
    multiple=True,
    help="Record only the named scenario(s). Repeatable. Default: all.",
)
@click.option("--verbose", is_flag=True, help="Log full request/response details to stderr.")
def record_scenarios_cmd(
    scenarios_file: str,
    output_dir: str | None,
    scenario: tuple[str, ...],
    verbose: bool,
) -> None:
    """Record cassettes from a YAML scenarios file."""
    _configure_logging(verbose)

    scenarios_path = Path(scenarios_file)
    sf = load_scenarios_file(scenarios_path)

    out = scenarios_path.parent / "cassettes" if output_dir is None else Path(output_dir)

    from mcp_recorder.scenarios import StdioTargetConfig

    total = len(scenario) if scenario else len(sf.scenarios)
    if isinstance(sf.target, StdioTargetConfig):
        display_target = f"stdio: {sf.target.command} {' '.join(sf.target.args)}".strip()
    else:
        display_target = sf.target
    click.echo(f"Target: {display_target}", err=True)
    click.echo(f"Scenarios: {total}", err=True)
    click.echo(f"Output: {out}/", err=True)
    click.echo("", err=True)

    results = run_scenarios(sf, out, scenario_names=scenario, verbose=verbose)

    click.echo("", err=True)
    total_interactions = sum(results.values())
    click.echo(
        f"Done: {len(results)} scenario(s), {total_interactions} total interactions.",
        err=True,
    )


def _save_cassette(
    cassette: Cassette,
    path: Path,
    *,
    redact_server_url: bool = False,
    redact_env: tuple[str, ...] = (),
    redact_patterns: tuple[str, ...] = (),
) -> None:
    """Flush the cassette to disk as JSON, applying any requested redactions."""
    count = len(cassette.interactions)
    if count == 0:
        click.echo("\nNo interactions captured. Nothing to save.", err=True)
        return

    cassette = scrub_cassette(
        cassette,
        redact_server_url=redact_server_url,
        redact_env=redact_env,
        redact_patterns=redact_patterns,
    )

    save_cassette(cassette, path)
    click.echo(f"\nSaved {count} interactions to {path}", err=True)


@main.command()
@click.option("--cassette", required=True, help="Path to cassette file.")
@click.option("--port", default=5555, show_default=True, help="Local server port.")
@click.option(
    "--match",
    default="method_params",
    show_default=True,
    type=click.Choice(["method_params", "sequential", "strict"]),
    help="Request matching strategy.",
)
@click.option("--verbose", is_flag=True, help="Log every matched request to stderr.")
def replay(cassette: str, port: int, match: str, verbose: bool) -> None:
    """Start a mock MCP server from a recorded cassette."""
    _configure_logging(verbose)

    cassette_path = Path(cassette)
    if not cassette_path.exists():
        click.echo(f"Error: cassette file not found: {cassette_path}", err=True)
        raise SystemExit(1)

    loaded = load_cassette(cassette_path)
    matcher_obj = create_matcher(match, loaded.interactions)
    app = create_replay_app(loaded, matcher_obj)

    total = sum(1 for i in loaded.interactions if i.type.value == "jsonrpc_request")
    click.echo(f"Replaying {cassette_path.name} ({total} request interactions)", err=True)
    click.echo(f"Matching strategy: {match}", err=True)
    click.echo(f"Mock server: http://localhost:{port}/mcp", err=True)
    click.echo("Press Ctrl+C to stop.\n", err=True)

    try:
        uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning")
    except KeyboardInterrupt:
        pass
    finally:
        if matcher_obj.all_consumed:
            click.echo("\nAll recorded interactions were consumed.", err=True)
        else:
            remaining = total - matcher_obj._matched_count
            click.echo(f"\nWarning: {remaining} recorded interactions were NOT consumed.", err=True)
        if matcher_obj.unmatched_requests:
            click.echo(
                f"{len(matcher_obj.unmatched_requests)} incoming request(s) had no match.",
                err=True,
            )


@main.command()
@click.option("--cassette", required=True, help="Path to golden cassette file.")
@click.option("--target", default=None, help="URL of the server to verify (HTTP).")
@click.option("--target-stdio", default=None, help="Command to spawn as a stdio MCP server.")
@click.option(
    "--target-env",
    multiple=True,
    help="KEY=VALUE env var passed to the stdio subprocess. Repeatable.",
)
@click.option(
    "--ignore-fields",
    multiple=True,
    help="Key name to ignore at any depth. Repeatable.",
)
@click.option(
    "--ignore-paths",
    multiple=True,
    help="Exact dot-path to ignore (e.g. $.result.metadata.scrapeId). Repeatable.",
)
@click.option("--update", is_flag=True, help="Update the cassette with new responses.")
@click.option("--verbose", is_flag=True, help="Show full diff for each failing interaction.")
def verify(
    cassette: str,
    target: str | None,
    target_stdio: str | None,
    target_env: tuple[str, ...],
    ignore_fields: tuple[str, ...],
    ignore_paths: tuple[str, ...],
    update: bool,
    verbose: bool,
) -> None:
    """Replay recorded requests against a server and compare responses."""
    _validate_target(target, target_stdio)
    _configure_logging(verbose)

    cassette_path = Path(cassette)
    if not cassette_path.exists():
        click.echo(f"Error: cassette file not found: {cassette_path}", err=True)
        raise SystemExit(1)

    loaded = load_cassette(cassette_path)
    fields = frozenset(ignore_fields)
    paths = frozenset(ignore_paths)

    if target_stdio:
        transport = _build_stdio_transport(target_stdio, target_env)
        display_target = f"stdio: {target_stdio}"
    else:
        transport = None
        display_target = target  # type: ignore[assignment]

    click.echo(f"Verifying {cassette_path.name} against {display_target}", err=True)
    click.echo(f"Interactions: {len(loaded.interactions)}", err=True)
    if fields:
        click.echo(f"Ignoring fields: {', '.join(fields)}", err=True)
    if paths:
        click.echo(f"Ignoring paths: {', '.join(paths)}", err=True)
    click.echo("", err=True)

    result = run_verify(
        loaded,
        target_url=target,
        transport=transport,
        ignore_fields=fields,
        ignore_paths=paths,
    )

    click.echo("", err=True)
    for r in result.results:
        status = "PASS" if r.passed else "FAIL"
        click.echo(f"  {r.index}. {r.method} [{status}]", err=True)
        if not r.passed and r.diff:
            for line in r.diff:
                click.echo(f"    {line}", err=True)

    click.echo("", err=True)
    click.echo(
        f"Result: {result.passed}/{result.total} passed, {result.failed} failed",
        err=True,
    )

    if update and result.failed > 0:
        for r in result.results:
            if not r.passed and r.actual is not None:
                loaded.interactions[r.index - 1].response = r.actual
        _save_cassette(loaded, cassette_path)
        click.echo(f"Updated {cassette_path} with new responses.", err=True)

    if result.failed > 0:
        raise SystemExit(1)


@main.command()
@click.argument("cassette")
def inspect(cassette: str) -> None:
    """Pretty-print a cassette summary."""
    cassette_path = Path(cassette)
    if not cassette_path.exists():
        click.echo(f"Error: cassette file not found: {cassette_path}", err=True)
        raise SystemExit(1)

    loaded = load_cassette(cassette_path)
    meta = loaded.metadata

    click.echo(cassette_path.name)

    recorded = meta.recorded_at[:19].replace("T", " ") if meta.recorded_at else "unknown"
    click.echo(f"  Recorded: {recorded}")

    if meta.server_info:
        name = meta.server_info.get("name", "unknown")
        version = meta.server_info.get("version", "")
        click.echo(f"  Server:   {name} v{version}" if version else f"  Server:   {name}")
    if meta.protocol_version:
        click.echo(f"  Protocol: {meta.protocol_version}")
    if meta.server_url:
        click.echo(f"  Target:   {meta.server_url}")

    interactions = loaded.interactions
    click.echo(f"\n  Interactions ({len(interactions)}):")
    for i, interaction in enumerate(interactions, 1):
        click.echo(f"    {i}. {interaction.summary}")

    requests = sum(1 for x in interactions if x.type == InteractionType.JSONRPC_REQUEST)
    notifications = sum(1 for x in interactions if x.type == InteractionType.NOTIFICATION)
    lifecycle = sum(1 for x in interactions if x.type == InteractionType.LIFECYCLE)
    parts = []
    if requests:
        parts.append(f"{requests} request{'s' if requests != 1 else ''}")
    if notifications:
        parts.append(f"{notifications} notification{'s' if notifications != 1 else ''}")
    if lifecycle:
        parts.append(f"{lifecycle} lifecycle")
    click.echo(f"\n  Summary: {', '.join(parts)}")
