"""
Command-line interface for Metamorphic Guard.
"""

import json
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence

import click

from .config import ConfigLoadError, load_config
from .gate import decide_adopt
from .harness import run_eval
from .specs import list_tasks
from .util import write_report
from .reporting import render_html_report
from .monitoring import resolve_monitors
from .plugins import plugin_registry
from .notifications import collect_alerts, send_webhook_alerts
from .observability import (
    add_log_context,
    close_logging,
    configure_logging,
    configure_metrics,
    log_event,
)


_PLUGIN_TEMPLATES = {
    "monitor": """from __future__ import annotations

from metamorphic_guard.monitoring import Monitor, MonitorRecord


class {name}(Monitor):
    \"\"\"Custom monitor generated by `metamorphic-guard scaffold-plugin`.\"\"\"

    PLUGIN_METADATA = {{
        \"name\": \"{name}\",
        \"version\": \"0.1.0\",
        \"description\": \"Describe the behaviour monitored by {name}.\",
    }}

    def record(self, record: MonitorRecord) -> None:
        pass

    def finalize(self) -> dict:
        return {{
            \"id\": self.identifier(),
            \"type\": \"{name_lower}\",
            \"summary\": {{}},
            \"alerts\": [],
        }}
""",
    "dispatcher": """from __future__ import annotations

from typing import Any, Dict, Sequence, Tuple

from metamorphic_guard.dispatch import Dispatcher, RunCase
from metamorphic_guard.monitoring import Monitor


class {name}(Dispatcher):
    \"\"\"Custom dispatcher generated by `metamorphic-guard scaffold-plugin`.\"\"\"

    PLUGIN_METADATA = {{
        \"name\": \"{name}\",
        \"version\": \"0.1.0\",
        \"description\": \"Describe the dispatch strategy implemented by {name}.\",
    }}

    def __init__(self, workers: int = 1, config: Dict[str, Any] | None = None):
        super().__init__(workers, kind=\"{name_lower}\")
        self.config = config or {{}}

    def execute(
        self,
        *,
        test_inputs: Sequence[Tuple[Any, ...]],
        run_case: RunCase,
        role: str,
        monitors: Sequence[Monitor] | None = None,
        call_spec: Dict[str, Any] | None = None,
    ):
        return [run_case(i, args) for i, args in enumerate(test_inputs)]
""",
}


class DefaultCommandGroup(click.Group):
    """Group that falls back to a default command when none is supplied."""

    def __init__(self, *args: Any, default_command: str | None = None, **kwargs: Any) -> None:
        super().__init__(*args, **kwargs)
        self.default_command = default_command

    def parse_args(self, ctx: click.Context, args: List[str]) -> List[str]:
        if self.default_command:
            if not args:
                args.insert(0, self.default_command)
            elif args[0].startswith("-"):
                args.insert(0, self.default_command)
        return super().parse_args(ctx, args)


def _load_config_defaults(ctx: click.Context, param: click.Parameter, value: Optional[Path]) -> None:
    if value is None:
        return
    if not value.exists():
        raise click.ClickException(f"Config file not found: {value}")

    try:
        config = load_config(value)
    except ConfigLoadError as exc:
        raise click.ClickException(str(exc)) from exc

    default_map: Dict[str, Any] = ctx.default_map or {}
    default_map.update(
        {
            "task": config.task,
            "baseline": config.baseline,
            "candidate": config.candidate,
            "n": config.n,
            "seed": config.seed,
            "timeout_s": config.timeout_s,
            "mem_mb": config.mem_mb,
            "alpha": config.alpha,
            "improve_delta": config.improve_delta,
            "violation_cap": config.violation_cap,
            "parallel": config.parallel,
            "bootstrap_samples": config.bootstrap_samples,
            "ci_method": config.ci_method,
            "rr_ci_method": config.rr_ci_method,
            "monitor_names": config.monitors,
            "dispatcher": config.dispatcher,
            "executor": config.executor,
            "policy_version": config.policy_version,
            "alert_webhooks": config.alerts.webhooks,
            "log_json": config.log_json,
            "log_file": Path(config.log_file) if config.log_file else None,
            "metrics_enabled": config.metrics_enabled,
            "metrics_port": config.metrics_port,
            "metrics_host": config.metrics_host,
            "failed_artifact_limit": config.failed_artifact_limit,
            "failed_artifact_ttl_days": config.failed_artifact_ttl_days,
            "sandbox_plugins": config.sandbox_plugins,
        }
    )

    if config.queue is not None:
        default_map["queue_config"] = json.dumps(config.queue.dict(exclude_none=True))
    if config.executor_config is not None:
        default_map["executor_config"] = json.dumps(config.executor_config)

    ctx.default_map = default_map


def _write_violation_report(path: Path, result: Dict[str, Any]) -> None:
    payload = {
        "task": result.get("task"),
        "baseline": {
            "prop_violations": result.get("baseline", {}).get("prop_violations", []),
            "mr_violations": result.get("baseline", {}).get("mr_violations", []),
        },
        "candidate": {
            "prop_violations": result.get("candidate", {}).get("prop_violations", []),
            "mr_violations": result.get("candidate", {}).get("mr_violations", []),
        },
    }
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(payload, indent=2), encoding="utf-8")


EVALUATE_OPTIONS = [
    click.option(
        "--config",
        type=click.Path(exists=True, dir_okay=False, path_type=Path),
        callback=_load_config_defaults,
        expose_value=False,
        is_eager=True,
        help="Path to a TOML file with default option values.",
    ),
    click.option("--task", required=True, help="Task name to evaluate"),
    click.option("--baseline", required=True, help="Path to baseline implementation"),
    click.option("--candidate", required=True, help="Path to candidate implementation"),
    click.option("--n", default=400, show_default=True, help="Number of test cases to generate"),
    click.option("--seed", default=42, show_default=True, help="Random seed for generators"),
    click.option("--timeout-s", default=2.0, show_default=True, help="Timeout per test (seconds)"),
    click.option("--mem-mb", default=512, show_default=True, help="Memory limit per test (MB)"),
    click.option("--alpha", default=0.05, show_default=True, help="Significance level for bootstrap CI"),
    click.option(
        "--improve-delta",
        default=0.02,
        show_default=True,
        help="Minimum improvement threshold for adoption",
    ),
    click.option("--violation-cap", default=25, show_default=True, help="Maximum violations to record"),
    click.option(
        "--parallel",
        type=int,
        default=1,
        show_default=True,
        help="Number of concurrent workers for sandbox execution",
    ),
    click.option(
        "--bootstrap-samples",
        type=int,
        default=1000,
        show_default=True,
        help="Bootstrap resamples for confidence interval estimation",
    ),
    click.option(
        "--ci-method",
        type=click.Choice(["bootstrap", "newcombe", "wilson"], case_sensitive=False),
        default="bootstrap",
        show_default=True,
        help="Method for the pass-rate delta confidence interval",
    ),
    click.option(
        "--rr-ci-method",
        type=click.Choice(["log"], case_sensitive=False),
        default="log",
        show_default=True,
        help="Method for relative risk confidence interval",
    ),
    click.option(
        "--report-dir",
        type=click.Path(file_okay=False, writable=True, path_type=Path),
        default=None,
        help="Directory where the JSON report should be written.",
    ),
    click.option(
        "--dispatcher",
        type=click.Choice(["local", "queue"]),
        default="local",
        show_default=True,
        help="Execution dispatcher (local threads or experimental queue).",
    ),
    click.option(
        "--executor",
        type=str,
        default=None,
        help="Sandbox executor to use (e.g. 'docker' or 'package.module:callable').",
    ),
    click.option(
        "--executor-config",
        type=str,
        default=None,
        help="JSON string with executor-specific configuration.",
    ),
    click.option(
        "--export-violations",
        type=click.Path(dir_okay=False, writable=True, path_type=Path),
        default=None,
        help="Optional destination for a JSON file summarizing property and MR violations.",
    ),
    click.option(
        "--html-report",
        type=click.Path(dir_okay=False, writable=True, path_type=Path),
        default=None,
        help="Optional destination for an HTML summary report.",
    ),
    click.option(
        "--queue-config",
        type=str,
        default=None,
        help="JSON configuration for the queue dispatcher (experimental).",
    ),
    click.option(
        "--monitor",
        "monitor_names",
        multiple=True,
        help="Enable built-in monitors (e.g., 'latency').",
    ),
    click.option(
        "--alert-webhook",
        "alert_webhooks",
        multiple=True,
        help="POST monitor alerts to the provided webhook URL (can be repeated).",
    ),
    click.option(
        "--sandbox-plugins/--no-sandbox-plugins",
        default=None,
        help="Execute third-party monitors in isolated subprocesses.",
    ),
    click.option(
        "--log-file",
        type=click.Path(dir_okay=False, writable=True, path_type=Path),
        default=None,
        help="Append structured JSON logs to the specified file.",
    ),
    click.option(
        "--log-json/--no-log-json",
        default=None,
        help="Emit structured JSON logs to stdout during evaluation.",
    ),
    click.option(
        "--metrics/--no-metrics",
        "metrics_enabled",
        default=None,
        help="Toggle Prometheus metrics collection for this run.",
    ),
    click.option(
        "--metrics-port",
        type=int,
        default=None,
        help="Expose Prometheus metrics on the provided port.",
    ),
    click.option(
        "--metrics-host",
        type=str,
        default="0.0.0.0",
        show_default=True,
        help="Bind address when serving Prometheus metrics.",
    ),
    click.option(
        "--failed-artifact-limit",
        type=int,
        default=None,
        help="Maximum number of failed-case artifacts to retain (per directory).",
    ),
    click.option(
        "--failed-artifact-ttl-days",
        type=int,
        default=None,
        help="Remove failed-case artifacts older than the specified number of days.",
    ),
    click.option(
        "--policy-version",
        type=str,
        default=None,
        help="Optional policy version identifier recorded in evaluation reports.",
    ),
]


def _apply_evaluate_options(func):
    for decorator in reversed(EVALUATE_OPTIONS):
        func = decorator(func)
    return func


@click.group(cls=DefaultCommandGroup, default_command="evaluate")
def main() -> None:
    """Metamorphic Guard command group."""
    pass


@main.command("evaluate")
@_apply_evaluate_options
def evaluate_command(
    task: str,
    baseline: str,
    candidate: str,
    n: int,
    seed: int,
    timeout_s: float,
    mem_mb: int,
    alpha: float,
    improve_delta: float,
    violation_cap: int,
    parallel: int,
    bootstrap_samples: int,
    ci_method: str,
    rr_ci_method: str,
    report_dir: Path | None,
    dispatcher: str,
    executor: str | None,
    executor_config: str | None,
    export_violations: Path | None,
    html_report: Path | None,
    queue_config: str | None,
    monitor_names: Sequence[str],
    alert_webhooks: Sequence[str],
    sandbox_plugins: Optional[bool],
    log_file: Optional[Path],
    log_json: Optional[bool],
    metrics_enabled: Optional[bool],
    metrics_port: Optional[int],
    metrics_host: str,
    failed_artifact_limit: Optional[int],
    failed_artifact_ttl_days: Optional[int],
    policy_version: Optional[str],
) -> None:
    """Compare baseline and candidate implementations using metamorphic testing."""

    available_tasks = list_tasks()
    if task not in available_tasks:
        click.echo(
            f"Error: Task '{task}' not found. Available tasks: {available_tasks}",
            err=True,
        )
        sys.exit(1)

    try:
        enable_logging = log_json if log_json is not None else (True if log_file else None)
        configure_logging(enable_logging, path=log_file)
        add_log_context(command="evaluate", task=task, baseline=baseline, candidate=candidate)

        if metrics_enabled is not None or metrics_port is not None:
            try:
                configure_metrics(
                    enabled=(metrics_enabled if metrics_enabled is not None else True),
                    port=metrics_port,
                    host=metrics_host,
                )
            except RuntimeError as exc:
                raise click.ClickException(str(exc)) from exc

        click.echo(f"Running evaluation: {task}")
        click.echo(f"Baseline: {baseline}")
        click.echo(f"Candidate: {candidate}")
        click.echo(f"Test cases: {n}, Seed: {seed}")
        click.echo(f"Parallel workers: {parallel}")
        click.echo(f"CI method: {ci_method}")
        click.echo(f"RR CI method: {rr_ci_method}")

        parsed_executor_config = None
        if executor_config:
            try:
                parsed_executor_config = json.loads(executor_config)
                if not isinstance(parsed_executor_config, dict):
                    raise ValueError("Executor config must decode to a JSON object.")
            except Exception as exc:
                click.echo(f"Error: Invalid executor config ({exc})", err=True)
                sys.exit(1)

        queue_cfg = None
        if queue_config:
            try:
                queue_cfg = json.loads(queue_config)
                if not isinstance(queue_cfg, dict):
                    raise ValueError("Queue config must decode to a JSON object.")
            except Exception as exc:
                click.echo(f"Error: Invalid queue config ({exc})", err=True)
                sys.exit(1)

        monitor_objects = []
        if monitor_names:
            try:
                monitor_objects = resolve_monitors(
                    monitor_names,
                    sandbox_plugins=bool(sandbox_plugins),
                )
            except ValueError as exc:
                click.echo(f"Error: {exc}", err=True)
                sys.exit(1)

        result = run_eval(
            task_name=task,
            baseline_path=baseline,
            candidate_path=candidate,
            n=n,
            seed=seed,
            timeout_s=timeout_s,
            mem_mb=mem_mb,
            alpha=alpha,
            violation_cap=violation_cap,
            parallel=parallel,
            improve_delta=improve_delta,
            bootstrap_samples=bootstrap_samples,
            ci_method=ci_method,
            rr_ci_method=rr_ci_method,
            executor=executor,
            executor_config=parsed_executor_config,
            dispatcher=dispatcher,
            queue_config=queue_cfg,
            monitors=monitor_objects,
            failed_artifact_limit=failed_artifact_limit,
            failed_artifact_ttl_days=failed_artifact_ttl_days,
            policy_version=policy_version,
        )

        decision = decide_adopt(result, improve_delta)
        result["decision"] = decision
        result.setdefault("config", {})["sandbox_plugins"] = bool(sandbox_plugins)

        report_path = write_report(result, directory=report_dir)

        if export_violations is not None:
            _write_violation_report(export_violations, result)

        if html_report is not None:
            render_html_report(result, html_report)

        monitor_alerts = collect_alerts(result.get("monitors", {}))
        if alert_webhooks:
            try:
                send_webhook_alerts(
                    monitor_alerts,
                    alert_webhooks,
                    metadata={
                        "task": task,
                        "decision": decision,
                        "run_id": result.get("job_metadata", {}).get("run_id"),
                        "policy_version": policy_version,
                        "sandbox_plugins": sandbox_plugins,
                    },
                )
            except Exception as exc:
                click.echo(f"Warning: failed to dispatch alert webhooks: {exc}", err=True)

        click.echo("\n" + "=" * 60)
        click.echo("EVALUATION SUMMARY")
        click.echo("=" * 60)
        click.echo(f"Task: {result['task']}")
        click.echo(f"Test cases: {result['n']}")
        click.echo(f"Seed: {result['seed']}")
        click.echo()
        click.echo("BASELINE:")
        click.echo(
            f"  Pass rate: {result['baseline']['pass_rate']:.3f} "
            f"({result['baseline']['passes']}/{result['baseline']['total']})"
        )
        click.echo()
        click.echo("CANDIDATE:")
        click.echo(
            f"  Pass rate: {result['candidate']['pass_rate']:.3f} "
            f"({result['candidate']['passes']}/{result['candidate']['total']})"
        )
        click.echo(f"  Property violations: {len(result['candidate']['prop_violations'])}")
        click.echo(f"  MR violations: {len(result['candidate']['mr_violations'])}")
        click.echo()
        click.echo("IMPROVEMENT:")
        click.echo(f"  Delta: {result['delta_pass_rate']:.3f}")
        click.echo(f"  95% CI: [{result['delta_ci'][0]:.3f}, {result['delta_ci'][1]:.3f}]")
        click.echo(f"  Relative risk: {result['relative_risk']:.3f}")
        rr_ci = result["relative_risk_ci"]
        click.echo(f"  RR 95% CI: [{rr_ci[0]:.3f}, {rr_ci[1]:.3f}]")
        click.echo()
        click.echo("DECISION:")
        click.echo(f"  Adopt: {decision['adopt']}")
        click.echo(f"  Reason: {decision['reason']}")
        click.echo()
        click.echo(f"Report saved to: {report_path}")

        log_event(
            "run_eval_decision",
            adopt=decision["adopt"],
            reason=decision["reason"],
            delta=result["delta_pass_rate"],
            candidate_pass_rate=result["candidate"]["pass_rate"],
            baseline_pass_rate=result["baseline"]["pass_rate"],
            run_id=result.get("job_metadata", {}).get("run_id"),
            policy_version=policy_version,
            sandbox_plugins=sandbox_plugins,
        )

        if decision["adopt"]:
            click.echo("✅ Candidate accepted!")
            sys.exit(0)

        click.echo("❌ Candidate rejected!")
        sys.exit(1)

    except KeyboardInterrupt:  # pragma: no cover - defensive surface
        click.echo("Evaluation interrupted by user.", err=True)
        sys.exit(1)

    except Exception as exc:  # pragma: no cover - defensive surface
        click.echo(f"Error during evaluation: {exc}", err=True)
        sys.exit(1)
    finally:
        close_logging()


@main.command("init")
@click.option(
    "--path",
    type=click.Path(dir_okay=False, writable=True, path_type=Path),
    default=Path("metamorphic_guard.toml"),
    show_default=True,
    help="Configuration file to create.",
)
@click.option("--task", default="top_k", show_default=True)
@click.option("--baseline", default="baseline.py", show_default=True)
@click.option("--candidate", default="candidate.py", show_default=True)
@click.option("--distributed/--no-distributed", default=False, show_default=True)
@click.option("--monitor", "monitor_names", multiple=True, help="Monitors to enable by default.")
@click.option("--interactive/--no-interactive", default=False, show_default=False, help="Launch an interactive wizard.")
def init_command(
    path: Path,
    task: str,
    baseline: str,
    candidate: str,
    distributed: bool,
    monitor_names: Sequence[str],
    interactive: bool,
) -> None:
    """Create a starter TOML configuration file."""

    monitors = list(monitor_names)

    if interactive:
        task = click.prompt("Task name", default=task)
        baseline = click.prompt("Baseline path", default=baseline)
        candidate = click.prompt("Candidate path", default=candidate)
        distributed = click.confirm("Enable distributed execution?", default=distributed)
        monitor_default = ",".join(monitors)
        monitor_input = click.prompt(
            "Monitors (comma separated, blank for none)",
            default=monitor_default,
            show_default=bool(monitor_default),
        )
        monitors = [m.strip() for m in monitor_input.split(",") if m.strip()] if monitor_input else []

    path.parent.mkdir(parents=True, exist_ok=True)
    lines = ["[metamorphic_guard]"]
    lines.append(f'task = "{task}"')
    lines.append(f'baseline = "{baseline}"')
    lines.append(f'candidate = "{candidate}"')
    if monitors:
        monitor_str = ", ".join(f'"{name}"' for name in monitors)
        lines.append(f"monitors = [{monitor_str}]")
    if distributed:
        lines.append("")
        lines.append("[metamorphic_guard.queue]")
        lines.append('dispatcher = "queue"')
        lines.append('queue_config = { backend = "redis", url = "redis://localhost:6379/0" }')

    path.write_text("\n".join(lines) + "\n", encoding="utf-8")
    click.echo(f"Wrote configuration to {path}")


@main.command("scaffold-plugin")
@click.option("--name", required=True, help="Python class name for the plugin.")
@click.option(
    "--kind",
    type=click.Choice(["monitor", "dispatcher"], case_sensitive=False),
    default="monitor",
    show_default=True,
    help="Type of plugin scaffold to generate.",
)
@click.option(
    "--path",
    type=click.Path(dir_okay=False, writable=True, path_type=Path),
    default=None,
    help="File to write (defaults to <name>.py).",
)
def scaffold_plugin(name: str, kind: str, path: Path | None) -> None:
    """Generate a starter plugin implementation."""

    target = path or Path(f"{name.lower()}.py")
    if target.exists():
        raise click.ClickException(f"Target file {target} already exists.")

    template = _PLUGIN_TEMPLATES[kind.lower()]
    target.write_text(template.format(name=name, name_lower=name.lower()), encoding="utf-8")
    click.echo(f"Plugin scaffold written to {target}")


@main.group("plugin")
def plugin_group() -> None:
    """Inspect installed Metamorphic Guard plugins."""


@plugin_group.command("list")
@click.option(
    "--kind",
    type=click.Choice(["monitor", "dispatcher", "all"], case_sensitive=False),
    default="all",
    show_default=True,
    help="Filter by plugin kind.",
)
@click.option("--json", "json_flag", is_flag=True, help="Emit plugin list as JSON.")
def plugin_list(kind: str, json_flag: bool) -> None:
    registry = plugin_registry(kind)
    if not registry:
        click.echo("No plugins discovered.")
        return

    rows = []
    for key, definition in sorted(registry.items()):
        plugin_kind = "monitor" if definition.group == "metamorphic_guard.monitors" else "dispatcher"
        metadata = definition.metadata
        rows.append(
            {
                "name": metadata.name or definition.name,
                "entry": key,
                "kind": plugin_kind,
                "version": metadata.version,
                "sandbox": metadata.sandbox,
                "description": metadata.description,
            }
        )

    if json_flag:
        click.echo(json.dumps(rows, indent=2))
        return

    name_width = max(len(r["name"]) for r in rows)
    kind_width = max(len(r["kind"]) for r in rows)
    version_width = max(len(r["version"] or "-") for r in rows)

    header = f"{'NAME'.ljust(name_width)}  {'KIND'.ljust(kind_width)}  {'VERSION'.ljust(version_width)}  SANDBOX  DESCRIPTION"
    click.echo(header)
    click.echo("-" * len(header))
    for row in rows:
        name = row["name"].ljust(name_width)
        kind_str = row["kind"].ljust(kind_width)
        version = (row["version"] or "-").ljust(version_width)
        sandbox = "yes" if row["sandbox"] else "no"
        description = row["description"] or ""
        click.echo(f"{name}  {kind_str}  {version}  {sandbox:>3}   {description}")


@plugin_group.command("info")
@click.argument("plugin_name")
@click.option(
    "--kind",
    type=click.Choice(["monitor", "dispatcher", "all"], case_sensitive=False),
    default="all",
    show_default=True,
    help="Restrict lookup to a specific plugin kind.",
)
@click.option("--json", "json_flag", is_flag=True, help="Emit plugin metadata as JSON.")
def plugin_info(plugin_name: str, kind: str, json_flag: bool) -> None:
    registry = plugin_registry(kind)
    key = plugin_name.lower()
    definition = registry.get(key)
    if definition is None:
        for candidate in registry.values():
            if candidate.name.lower() == key:
                definition = candidate
                break
    if definition is None:
        available = sorted(registry.keys())
        raise click.ClickException(
            f"Plugin '{plugin_name}' not found. Available: {available if available else 'none'}"
        )

    entry_name = next((k for k, v in registry.items() if v is definition), key)
    metadata = definition.metadata

    payload = {
        "name": metadata.name or definition.name,
        "entry": entry_name,
        "kind": "monitor" if definition.group == "metamorphic_guard.monitors" else "dispatcher",
        "version": metadata.version,
        "description": metadata.description,
        "guard_min": metadata.guard_min,
        "guard_max": metadata.guard_max,
        "author": metadata.author,
        "url": metadata.url,
        "sandbox": metadata.sandbox,
        "extra": metadata.extra,
        "module": definition.module,
        "attr": definition.attr,
    }

    if json_flag:
        click.echo(json.dumps(payload, indent=2))
        return

    for key, value in payload.items():
        if value in (None, {}, ""):
            continue
        click.echo(f"{key}: {value}")


if __name__ == "__main__":
    main()
