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

import click

from .config import ConfigLoadError, load_config
from .harness import run_eval
from .specs import list_tasks
from .util import write_report
from .reporting import render_html_report, render_junit_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,
)
from .policy import load_policy_file, PolicyLoadError


_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)]
""",
}


def _flatten_dict(value: Any, prefix: str = "") -> Dict[str, Any]:
    items: Dict[str, Any] = {}
    if isinstance(value, dict):
        for key, val in value.items():
            key_str = str(key)
            new_prefix = f"{prefix}.{key_str}" if prefix else key_str
            items.update(_flatten_dict(val, new_prefix))
    elif isinstance(value, list):
        for idx, val in enumerate(value):
            new_prefix = f"{prefix}[{idx}]" if prefix else f"[{idx}]"
            items.update(_flatten_dict(val, new_prefix))
    else:
        items[prefix or ""] = value
    return items


def _load_report(path: Path) -> Dict[str, Any]:
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except json.JSONDecodeError as exc:
        raise click.ClickException(f"Failed to parse report JSON ({exc})") from exc


def _get_nested(data: Dict[str, Any], path: Sequence[str]) -> Any:
    current: Any = data
    for part in path:
        if not isinstance(current, dict):
            raise KeyError(".".join(path))
        if part not in current:
            raise KeyError(".".join(path))
        current = current[part]
    return current


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,
            "min_delta": config.min_delta,  # improve_delta is deprecated, use min_delta
        "min_pass_rate": config.min_pass_rate,
            "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,
        "power_target": config.power_target,
        "policy": str(config.policy) if config.policy else None,
        "stability": config.stability,
        }
    )

    if config.relation_correction == "holm":
        default_map["mr_fwer"] = True
    elif config.relation_correction == "fdr":
        default_map["mr_fdr"] = True

    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 _parse_policy_preset(value: str) -> Dict[str, Any]:
    raw = value.strip()
    if not raw:
        raise click.ClickException("Policy preset cannot be empty.")

    name, _, param_str = raw.partition(":")
    name = name.strip().lower()
    if name not in {"noninferiority", "superiority"}:
        raise click.ClickException(
            f"Unknown policy preset '{name}'. Supported presets: noninferiority, superiority."
        )

    params: Dict[str, str] = {}
    if param_str:
        for token in param_str.split(","):
            token = token.strip()
            if not token:
                continue
            key, sep, val = token.partition("=")
            if not sep:
                raise click.ClickException(
                    f"Invalid policy preset parameter '{token}'. Expected key=value."
                )
            params[key.strip().lower()] = val.strip()

    def _get_float(key: str, default: Optional[float] = None) -> Optional[float]:
        if key not in params:
            return default
        try:
            return float(params[key])
        except ValueError:
            raise click.ClickException(f"Policy preset parameter '{key}' must be numeric.")

    margin = _get_float("margin", 0.0) or 0.0
    pass_rate = _get_float("pass_rate")
    alpha_override = _get_float("alpha")
    power_override = _get_float("power")

    violation_cap: Optional[int] = None
    if "violation_cap" in params:
        try:
            violation_cap = int(params["violation_cap"])
        except ValueError:
            raise click.ClickException("Policy preset parameter 'violation_cap' must be an integer.")

    min_delta = margin if name == "superiority" else -margin

    gating: Dict[str, Any] = {"min_delta": min_delta}
    if pass_rate is not None:
        gating["min_pass_rate"] = pass_rate
    if alpha_override is not None:
        gating["alpha"] = alpha_override
    if power_override is not None:
        gating["power_target"] = power_override
    if violation_cap is not None:
        gating["violation_cap"] = violation_cap

    quality_policy: Dict[str, Any] = {"min_delta": min_delta}
    if pass_rate is not None:
        quality_policy["min_pass_rate"] = pass_rate

    parameters: Dict[str, Any] = {"margin": margin}
    if pass_rate is not None:
        parameters["pass_rate"] = pass_rate
    if alpha_override is not None:
        parameters["alpha"] = alpha_override
    if power_override is not None:
        parameters["power"] = power_override
    if violation_cap is not None:
        parameters["violation_cap"] = violation_cap

    label_parts = [f"margin={margin:.4f}"]
    if pass_rate is not None:
        label_parts.append(f"pass_rate={pass_rate:.4f}")
    if alpha_override is not None:
        label_parts.append(f"alpha={alpha_override:.4f}")
    if power_override is not None:
        label_parts.append(f"power={power_override:.4f}")
    if violation_cap is not None:
        label_parts.append(f"violation_cap={violation_cap}")

    descriptor = {
        "type": "preset",
        "name": name,
        "parameters": parameters,
        "label": f"{name}({', '.join(label_parts)})" if label_parts else name,
    }

    return {
        "source": "preset",
        "name": name,
        "parameters": parameters,
        "gating": gating,
        "policy": {"quality": quality_policy},
        "descriptor": descriptor,
    }


def _resolve_policy_option(value: str) -> Dict[str, Any]:
    candidate = value.strip()
    if not candidate:
        raise click.ClickException("Policy value cannot be empty.")

    path = Path(candidate)
    if path.exists():
        if not path.is_file():
            raise click.ClickException(f"Policy path must be a file: {path}")
        payload = load_policy_file(path)
        payload["source"] = "file"

        descriptor: Dict[str, Any] = {"type": "file", "path": str(path)}
        raw_section = payload.get("raw", {})
        if isinstance(raw_section, dict):
            policy_name = raw_section.get("name")
            if isinstance(policy_name, str):
                descriptor["name"] = policy_name
                descriptor["label"] = policy_name
        payload["descriptor"] = descriptor

        gating_cfg = payload.get("gating", {})
        normalized_gating: Dict[str, Any] = {}
        for key, val in gating_cfg.items():
            if key == "violation_cap":
                try:
                    normalized_gating[key] = int(val)
                except (TypeError, ValueError):
                    raise click.ClickException("Policy value 'violation_cap' must be an integer.")
            else:
                try:
                    normalized_gating[key] = float(val)
                except (TypeError, ValueError):
                    raise click.ClickException(f"Policy value '{key}' must be numeric.")
        payload["gating"] = normalized_gating

        policy_dict: Dict[str, Any] = {}
        if isinstance(raw_section, dict) and isinstance(raw_section.get("policy"), dict):
            policy_dict = raw_section["policy"]  # type: ignore[assignment]
        else:
            quality_policy: Dict[str, Any] = {}
            if "min_delta" in normalized_gating:
                quality_policy["min_delta"] = normalized_gating["min_delta"]
            if "min_pass_rate" in normalized_gating:
                quality_policy["min_pass_rate"] = normalized_gating["min_pass_rate"]
            if quality_policy:
                policy_dict["quality"] = quality_policy

        payload["policy"] = policy_dict
        return payload

    return _parse_policy_preset(candidate)


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(
        "--sequential-method",
        type=click.Choice(["none", "pocock", "obrien-fleming", "sprt"], case_sensitive=False),
        default="none",
        show_default=True,
        help="Sequential testing method for iterative PR workflows (alpha-spending or SPRT).",
    ),
    click.option(
        "--max-looks",
        type=int,
        default=1,
        show_default=True,
        help="Maximum number of looks/interim analyses for sequential testing (default: 1 = no sequential testing).",
    ),
    click.option(
        "--look-number",
        type=int,
        default=1,
        show_default=True,
        help="Current look number for sequential testing (1-indexed).",
    ),
    click.option(
        "--min-delta",
        "--improve-delta",  # Deprecated alias
        default=0.02,
        show_default=True,
        help="Minimum improvement threshold for adoption (--improve-delta is deprecated, use --min-delta)",
    ),
    click.option(
        "--min-pass-rate",
        type=float,
        default=0.80,
        show_default=True,
        help="Minimum candidate pass rate required for adoption.",
    ),
    click.option(
        "--power-target",
        type=float,
        default=0.8,
        show_default=True,
        help="Desired statistical power for detecting improvements (used for guidance).",
    ),
    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(
        "--policy",
        type=str,
        default=None,
        help=(
            "Policy to apply. Provide a TOML path or use presets like "
            "'noninferiority:margin=0.00' or 'superiority:margin=0.02'."
        ),
    ),
    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",
                "bootstrap-bca",
                "bootstrap-cluster",
                "bootstrap-cluster-bca",
                "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(
        "--junit-report",
        "--junit-xml",  # Deprecated alias
        type=click.Path(dir_okay=False, writable=True, path_type=Path),
        default=None,
        help="Optional destination for a JUnit XML report (for CI integration).",
    ),
    click.option(
        "--queue-config",
        type=str,
        default=None,
        help="JSON configuration for the queue dispatcher (experimental).",
    ),
    click.option(
        "--replay-input",
        type=click.Path(exists=True, dir_okay=False, path_type=Path),
        default=None,
        help="Replay explicit test case inputs from a JSON file.",
    ),
    click.option(
        "--monitor",
        "monitor_names",
        multiple=True,
        help="Enable built-in monitors (e.g., 'latency').",
    ),
    click.option(
        "--mr-fwer",
        is_flag=True,
        default=False,
        help="Apply Holm-Bonferroni correction to metamorphic relation p-values.",
    ),
    click.option(
        "--mr-fdr",
        is_flag=True,
        default=False,
        help="Apply Benjamini-Hochberg FDR correction to metamorphic relation p-values.",
    ),
    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=True,
        show_default=True,
        help="Execute third-party plugins in isolated subprocesses (default: enabled for security).",
    ),
    click.option(
        "--allow-unsafe-plugins",
        is_flag=True,
        default=False,
        help="Allow plugins to run without sandboxing (security risk). Equivalent to --no-sandbox-plugins.",
    ),
    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.",
    ),
    click.option(
        "--otlp-endpoint",
        type=str,
        default=None,
        help="OpenTelemetry OTLP endpoint URL (e.g., 'http://localhost:4317') for trace export.",
    ),
    click.option(
        "--stability",
        type=int,
        default=1,
        show_default=True,
        help="Run evaluation N times and require consistent decisions (consensus). Reports flakiness if decisions differ.",
    ),
    click.option(
        "--shrink-violations/--no-shrink-violations",
        default=False,
        show_default=True,
        help="Shrink failing test cases to minimal counterexamples for easier debugging.",
    ),
]


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,
    sequential_method: str,
    max_looks: int,
    look_number: int,
    min_delta: float,  # Renamed from improve_delta
    min_pass_rate: 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,
    junit_report: Path | None,
    queue_config: str | None,
    monitor_names: Sequence[str],
    mr_fwer: bool,
    mr_fdr: bool,
    alert_webhooks: Sequence[str],
    sandbox_plugins: Optional[bool],
    allow_unsafe_plugins: 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],
    otlp_endpoint: Optional[str],
    replay_input: Path | None,
    policy: str | None,
    power_target: float,
    stability: int,
    shrink_violations: bool,
) -> 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
        
        # Configure OpenTelemetry if endpoint provided
        if otlp_endpoint:
            try:
                from .telemetry import configure_telemetry
                from . import __version__
                
                configured = configure_telemetry(
                    endpoint=otlp_endpoint,
                    service_name="metamorphic-guard",
                    service_version=__version__,
                    enabled=True,
                )
                if configured:
                    click.echo(f"OpenTelemetry tracing enabled: {otlp_endpoint}", err=True)
                else:
                    click.echo(
                        "Warning: OpenTelemetry not available. Install with: pip install metamorphic-guard[otel]",
                        err=True,
                    )
            except ImportError:
                click.echo(
                    "Warning: OpenTelemetry not available. Install with: pip install metamorphic-guard[otel]",
                    err=True,
                )

        explicit_inputs = None
        if replay_input is not None:
            try:
                payload = json.loads(replay_input.read_text(encoding="utf-8"))
            except Exception as exc:
                raise click.ClickException(f"Failed to read replay input JSON: {exc}") from exc

            if isinstance(payload, dict) and "cases" in payload:
                cases_payload = payload["cases"]
            else:
                cases_payload = payload

            if not isinstance(cases_payload, list):
                raise click.ClickException("Replay input JSON must be a list or an object with a 'cases' list.")

            explicit_inputs = []
            for entry in cases_payload:
                if isinstance(entry, dict) and "input" in entry:
                    raw_args = entry["input"]
                else:
                    raw_args = entry

                if not isinstance(raw_args, (list, tuple)):
                    raise click.ClickException("Each replayed case must be a sequence (list/tuple) of arguments.")

                explicit_inputs.append(tuple(raw_args))

        effective_n = len(explicit_inputs) if explicit_inputs is not None else n

        policy_payload = None
        if policy is not None:
            try:
                policy_payload = _resolve_policy_option(policy)
            except PolicyLoadError as exc:
                raise click.ClickException(str(exc)) from exc

            gating_cfg = policy_payload.get("gating", {})

            def _maybe_float(value: Any, label: str) -> float:
                try:
                    return float(value)
                except (TypeError, ValueError):
                    raise click.ClickException(f"Policy value '{label}' must be numeric.")

            if "min_delta" in gating_cfg:
                min_delta = _maybe_float(gating_cfg["min_delta"], "min_delta")
            if "min_pass_rate" in gating_cfg:
                min_pass_rate = _maybe_float(gating_cfg["min_pass_rate"], "min_pass_rate")
            if "alpha" in gating_cfg:
                alpha = _maybe_float(gating_cfg["alpha"], "alpha")
            if "power_target" in gating_cfg:
                power_target = _maybe_float(gating_cfg["power_target"], "power_target")
            if "violation_cap" in gating_cfg:
                try:
                    violation_cap = int(gating_cfg["violation_cap"])
                except (TypeError, ValueError):
                    raise click.ClickException("Policy value 'violation_cap' must be an integer.")

            descriptor = policy_payload.get("descriptor", {})
            if policy_payload.get("source") == "preset":
                label = descriptor.get("label") or policy_payload.get("name") or policy
                click.echo(f"Using policy preset: {label}")
            else:
                click.echo(f"Using policy file: {descriptor.get('path', policy)}")

            if not policy_version:
                derived_version = descriptor.get("name") or descriptor.get("label") or policy_payload.get("name")
                if isinstance(derived_version, str):
                    policy_version = derived_version

        click.echo(f"Running evaluation: {task}")
        click.echo(f"Baseline: {baseline}")
        click.echo(f"Candidate: {candidate}")
        click.echo(f"Test cases: {effective_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)

        # Handle sandboxing: default to True, allow opt-out via --allow-unsafe-plugins
        effective_sandbox = sandbox_plugins if sandbox_plugins is not None else True
        if allow_unsafe_plugins:
            effective_sandbox = False
            click.echo("⚠️  Warning: Unsafe plugins enabled (sandboxing disabled)", err=True)

        if mr_fwer and mr_fdr:
            raise click.ClickException("Cannot combine --mr-fwer and --mr-fdr.")
        relation_correction = None
        if mr_fwer:
            relation_correction = "holm"
        elif mr_fdr:
            relation_correction = "fdr"

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

        # Stability runs: execute evaluation N times and check for consensus
        stability_runs: List[Dict[str, Any]] = []
        decisions: List[bool] = []
        
        if stability > 1:
            click.echo(f"Running stability check: {stability} runs required for consensus")
        
        for run_idx in range(stability):
            if stability > 1:
                click.echo(f"Stability run {run_idx + 1}/{stability}...")
            
            # Use different seeds for each run to detect flakiness
            run_seed = seed + run_idx if stability > 1 else seed
            
            run_result = run_eval(
                task_name=task,
                baseline_path=baseline,
                candidate_path=candidate,
                n=n,
                seed=run_seed,
                timeout_s=timeout_s,
                mem_mb=mem_mb,
                alpha=alpha,
                violation_cap=violation_cap,
                parallel=parallel,
                improve_delta=min_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,
                explicit_inputs=explicit_inputs,
                min_pass_rate=min_pass_rate,
                power_target=power_target,
                policy_config=policy_payload,
                shrink_violations=shrink_violations,
                sequential_method=sequential_method,
                max_looks=max_looks,
                look_number=look_number,
                relation_correction=relation_correction,
            )
            
            run_decision = run_result.get("decision", {})
            run_adopt = run_decision.get("adopt", False)
            decisions.append(run_adopt)
            stability_runs.append({
                "run": run_idx + 1,
                "seed": run_seed,
                "decision": run_adopt,
                "reason": run_decision.get("reason"),
                "delta_pass_rate": run_result.get("delta_pass_rate"),
                "delta_ci": run_result.get("delta_ci"),
            })
        
        # Use the last run's result as the primary result
        result = run_result
        
        # Check for consensus
        all_adopt = all(decisions)
        all_reject = not any(decisions)
        consensus = all_adopt or all_reject
        
        if stability > 1:
            adopt_count = sum(decisions)
            click.echo(f"\nStability results: {adopt_count}/{stability} runs adopted")
            if not consensus:
                click.echo(
                    f"⚠️  FLAKY: Decisions inconsistent across {stability} runs. "
                    f"Adopt: {adopt_count}, Reject: {stability - adopt_count}",
                    err=True,
                )
                # Override decision to reject on flakiness
                result["decision"] = {
                    "adopt": False,
                    "reason": f"flaky: inconsistent decisions across {stability} runs ({adopt_count} adopt, {stability - adopt_count} reject)",
                }
            else:
                click.echo(f"✓ Consensus: All {stability} runs {'adopted' if all_adopt else 'rejected'}")
        
        # Add stability metadata to result
        if stability > 1:
            result["stability"] = {
                "runs": stability,
                "consensus": consensus,
                "adopt_count": sum(decisions),
                "reject_count": stability - sum(decisions),
                "run_details": stability_runs,
            }

        decision = result.get("decision", {})
        result.setdefault("config", {})["sandbox_plugins"] = effective_sandbox
        if relation_correction:
            result["config"]["relation_correction"] = relation_correction
        stats = result.get("statistics") or {}
        if stats:
            click.echo(
                f"Power estimate (Δ ≥ {stats.get('min_delta')}): "
                f"{stats.get('power_estimate', 0.0):.3f} "
                f"(target {stats.get('power_target', 0.8):.2f})"
            )
            if stats.get("recommended_n"):
                click.echo(f"Suggested n for target power: {stats['recommended_n']}")
            paired_stats = stats.get("paired")
            if paired_stats:
                click.echo(
                    "Discordant (baseline>candidate / candidate>baseline): "
                    f"{paired_stats.get('baseline_only', 0)}/"
                    f"{paired_stats.get('candidate_only', 0)} "
                    f"(McNemar p={paired_stats.get('mcnemar_p', 1.0):.3f})"
                )

        relation_coverage = result.get("relation_coverage") or {}
        categories = relation_coverage.get("categories") or {}
        if categories:
            correction_info = relation_coverage.get("correction")
            if correction_info:
                click.echo(
                    f"Relation correction: {correction_info.get('method')} "
                    f"(alpha={correction_info.get('alpha')})"
                )
            click.echo("Relation coverage (candidate pass rate):")
            for category, cat_stats in categories.items():
                candidate_total = cat_stats.get("candidate_total", 0)
                candidate_failures = cat_stats.get("candidate_failures", 0)
                candidate_pass_rate = cat_stats.get("candidate_pass_rate")
                if candidate_pass_rate is None:
                    rate_str = "n/a"
                else:
                    rate_str = f"{candidate_pass_rate:.3f}"
                click.echo(
                    f"  {category}: {rate_str} "
                    f"(failures {candidate_failures}/{candidate_total})"
                )

        if ci_method.lower() == "bootstrap":
            baseline_rate = result["baseline"]["pass_rate"]
            candidate_rate = result["candidate"]["pass_rate"]
            if effective_n < 100 or min(baseline_rate, candidate_rate) < 0.05 or max(baseline_rate, candidate_rate) > 0.95:
                click.echo(
                    "Warning: Bootstrap intervals can be unstable for small samples or extreme pass rates. "
                    "Consider using --ci-method=newcombe.",
                    err=True,
                )

        report_path = 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)

        replay_info = result.get("replay") or {}
        cases_path = None
        if report_dir:
            cases_path = report_path.with_name(report_path.stem + "_cases.json")
            cases_path.write_text(json.dumps(result.get("cases", []), indent=2), encoding="utf-8")
            click.echo(f"Replay cases saved to: {cases_path}")

        if replay_info and cases_path is not None:
            replay_cmd = [
                "metamorphic-guard",
                "--task",
                replay_info.get("task", task),
                "--baseline",
                baseline,
                "--candidate",
                candidate,
                "--replay-input",
                str(cases_path),
                "--min-delta",
                str(min_delta),
                "--min-pass-rate",
                str(min_pass_rate),
                "--ci-method",
                ci_method,
            ]
            click.echo("Replay command:")
            click.echo("  " + " ".join(replay_cmd))

        if junit_report is not None:
            render_junit_report(result, junit_report)
            click.echo(f"JUnit report written to {junit_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": effective_sandbox,
                    },
                )
            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=effective_sandbox,
        )

        # Export trace to OpenTelemetry if enabled
        if otlp_endpoint:
            try:
                from .telemetry import trace_evaluation
                trace_evaluation(
                    task_name=task,
                    baseline_path=baseline,
                    candidate_path=candidate,
                    n=n,
                    result=result,
                )
            except Exception:
                # Silently fail if telemetry export fails
                pass

        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", "executor", "mutant", "judge", "task", "relation", "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}")


@main.command("report")
@click.argument("json_report", type=click.Path(exists=True, path_type=Path))
@click.option(
    "--output",
    "-o",
    type=click.Path(path_type=Path),
    default=None,
    help="Output HTML file path (defaults to <json_report>.html)",
)
def report_command(json_report: Path, output: Path | None) -> None:
    """Generate an HTML report from a JSON report file."""
    import json as json_lib

    try:
        with open(json_report, "r", encoding="utf-8") as f:
            payload = json_lib.load(f)
    except json_lib.JSONDecodeError as e:
        click.echo(f"Error: Invalid JSON in {json_report}: {e}", err=True)
        sys.exit(1)
    except Exception as e:
        click.echo(f"Error reading {json_report}: {e}", err=True)
        sys.exit(1)

    output_path = output or json_report.with_suffix(".html")
    try:
        render_html_report(payload, output_path)
        click.echo(f"HTML report written to {output_path}")
    except Exception as e:
        click.echo(f"Error generating HTML report: {e}", err=True)
        sys.exit(1)


@main.command("provenance-diff")
@click.argument("report_a", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.argument("report_b", type=click.Path(exists=True, dir_okay=False, path_type=Path))
def provenance_diff_command(report_a: Path, report_b: Path) -> None:
    """Compare sandbox provenance between two reports."""

    try:
        data_a = json.loads(report_a.read_text(encoding="utf-8"))
        data_b = json.loads(report_b.read_text(encoding="utf-8"))
    except json.JSONDecodeError as exc:
        click.echo(f"Error: Failed to parse report JSON ({exc})", err=True)
        sys.exit(1)

    sandbox_a = (data_a.get("provenance") or {}).get("sandbox")
    sandbox_b = (data_b.get("provenance") or {}).get("sandbox")

    if sandbox_a is None and sandbox_b is None:
        click.echo("Neither report contains sandbox provenance.")
        return

    flat_a = _flatten_dict(sandbox_a or {}, prefix="sandbox")
    flat_b = _flatten_dict(sandbox_b or {}, prefix="sandbox")

    all_keys = sorted(set(flat_a.keys()) | set(flat_b.keys()))
    differences: List[str] = []

    for key in all_keys:
        value_a = flat_a.get(key)
        value_b = flat_b.get(key)
        if value_a == value_b:
            continue
        differences.append(
            f"- {key}: {value_a!r} != {value_b!r}"
        )

    if not differences:
        click.echo("Sandbox provenance matches.")
        return

    click.echo("Sandbox provenance differences:")
    for diff in differences:
        click.echo(diff)


def _parse_metric_threshold(ctx: click.Context, param: click.Option, value: Tuple[str, ...]) -> Dict[Tuple[str, Tuple[str, ...]], float]:
    thresholds: Dict[Tuple[str, Tuple[str, ...]], float] = {}
    for entry in value:
        if "=" not in entry:
            raise click.BadParameter("Expected format metric:path=value", ctx=ctx, param=param)
        left, right = entry.split("=", 1)
        try:
            threshold = float(right)
        except ValueError as exc:
            raise click.BadParameter(f"Threshold must be numeric: {entry}") from exc
        if ":" not in left:
            raise click.BadParameter("Expected format metric:path=value", ctx=ctx, param=param)
        metric_name, raw_path = left.split(":", 1)
        path = tuple(part for part in raw_path.split(".") if part)
        if not path:
            raise click.BadParameter(f"Path portion empty in {entry}", ctx=ctx, param=param)
        thresholds[(metric_name, path)] = threshold
    return thresholds


@main.command("regression-guard")
@click.argument("baseline_report", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.argument("candidate_report", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
    "--metric-threshold",
    "metric_thresholds",
    multiple=True,
    callback=_parse_metric_threshold,
    help="Guard metric deltas: metric:path=value enforces |candidate metric[path]| <= value.",
)
@click.option(
    "--require-provenance-match",
    is_flag=True,
    default=False,
    help="Fail if sandbox provenance fingerprints differ between reports.",
)
def regression_guard_command(
    baseline_report: Path,
    candidate_report: Path,
    metric_thresholds: Dict[Tuple[str, Tuple[str, ...]], float],
    require_provenance_match: bool,
) -> None:
    """Fail the build when metrics regress or provenance changes unexpectedly."""

    baseline = _load_report(baseline_report)
    candidate = _load_report(candidate_report)

    violations: List[str] = []

    if require_provenance_match:
        base_sandbox = _flatten_dict(
            (baseline.get("provenance") or {}).get("sandbox") or {},
            prefix="sandbox",
        )
        cand_sandbox = _flatten_dict(
            (candidate.get("provenance") or {}).get("sandbox") or {},
            prefix="sandbox",
        )
        if base_sandbox != cand_sandbox:
            diff_keys = sorted(set(base_sandbox.keys()) ^ set(cand_sandbox.keys()))
            changed_keys = [
                key
                for key in sorted(set(base_sandbox.keys()) & set(cand_sandbox.keys()))
                if base_sandbox[key] != cand_sandbox[key]
            ]
            message_lines = ["Sandbox provenance mismatch detected."]
            if diff_keys:
                message_lines.append(f"Missing keys: {', '.join(diff_keys)}")
            if changed_keys:
                sample = ", ".join(changed_keys[:5])
                message_lines.append(f"Changed keys: {sample}")
            violations.append("\n".join(message_lines))

    candidate_metrics = candidate.get("metrics") or {}
    for (metric_name, path), max_value in metric_thresholds.items():
        metric_entry = candidate_metrics.get(metric_name)
        if metric_entry is None:
            violations.append(f"Metric '{metric_name}' missing in candidate report.")
            continue
        try:
            metric_value = _get_nested(metric_entry, path)
        except KeyError:
            violations.append(
                f"Metric '{metric_name}' missing path '{'.'.join(path)}' in candidate report."
            )
            continue
        if not isinstance(metric_value, (int, float)):
            violations.append(
                f"Metric '{metric_name}' path '{'.'.join(path)}' is not numeric (value={metric_value!r})."
            )
            continue
        if abs(float(metric_value)) > max_value:
            violations.append(
                f"Metric '{metric_name}' path '{'.'.join(path)}' exceeded {max_value} (observed {metric_value})."
            )

    if violations:
        for line in violations:
            click.echo(f"FAIL: {line}", err=True)
        sys.exit(1)

    click.echo("Regression guard passed.")


@main.command("replay")
@click.option("--from", "report_file", required=True, type=click.Path(exists=True, path_type=Path), help="JSON report file to replay")
@click.option("--baseline", type=str, default=None, help="Override baseline path (default: from report)")
@click.option("--candidate", type=str, default=None, help="Override candidate path (default: from report)")
@click.option("--execute/--no-execute", default=False, show_default=True, help="Execute the replay evaluation (default: print command only)")
@click.pass_context
def replay_command(
    ctx: click.Context,
    report_file: Path,
    baseline: str | None,
    candidate: str | None,
    execute: bool,
) -> None:
    """
    Replay an evaluation from a JSON report file.
    
    Extracts seed, test cases, and configuration from a previous evaluation report
    and either prints the replay command or executes it.
    
    Examples:
    
    \b
    # Print replay command
    metamorphic-guard replay --from reports/report_20240101_120000.json
    
    \b
    # Execute replay immediately
    metamorphic-guard replay --from reports/report_20240101_120000.json --execute
    """
    import json as json_lib
    
    try:
        with open(report_file, "r", encoding="utf-8") as f:
            report_data = json_lib.load(f)
    except json_lib.JSONDecodeError as e:
        click.echo(f"Error: Invalid JSON in {report_file}: {e}", err=True)
        sys.exit(1)
    except Exception as e:
        click.echo(f"Error reading {report_file}: {e}", err=True)
        sys.exit(1)
    
    # Extract replay information
    replay_info = report_data.get("replay") or {}
    task_name = replay_info.get("task") or report_data.get("task")
    seed = replay_info.get("seed") or report_data.get("seed", 42)
    
    # Get baseline/candidate paths
    baseline_path = baseline
    candidate_path = candidate
    
    if not baseline_path:
        baseline_path = replay_info.get("baseline_path")
    if not candidate_path:
        candidate_path = replay_info.get("candidate_path")
    
    if not baseline_path or not candidate_path:
        click.echo("Error: Could not determine baseline/candidate paths from report", err=True)
        click.echo("  Use --baseline and --candidate to specify paths", err=True)
        sys.exit(1)
    
    # Get cases
    cases = report_data.get("cases", [])
    if not cases:
        click.echo("Error: No test cases found in report", err=True)
        sys.exit(1)
    
    # Create temporary cases file
    import tempfile
    cases_file = Path(tempfile.mkdtemp()) / "replay_cases.json"
    cases_file.write_text(json_lib.dumps(cases, indent=2), encoding="utf-8")
    
    # Get configuration from report
    config = report_data.get("config", {})
    min_delta = config.get("min_delta", 0.02)
    min_pass_rate = config.get("min_pass_rate", 0.80)
    ci_method = config.get("ci_method", "newcombe")
    alpha = config.get("alpha", 0.05)
    
    # Build replay command
    replay_cmd = [
        "metamorphic-guard",
        "evaluate",
        "--task", task_name,
        "--baseline", baseline_path,
        "--candidate", candidate_path,
        "--seed", str(seed),
        "--replay-input", str(cases_file),
        "--min-delta", str(min_delta),
        "--min-pass-rate", str(min_pass_rate),
        "--ci-method", ci_method,
        "--alpha", str(alpha),
    ]
    
    if execute:
        click.echo(f"Replaying evaluation from {report_file}")
        click.echo(f"Task: {task_name}, Seed: {seed}, Cases: {len(cases)}")
        click.echo("")
        
        # Invoke evaluate command
        try:
            # Remove 'metamorphic-guard' from command and invoke
            ctx.invoke(
                evaluate_command,
                task=task_name,
                baseline=baseline_path,
                candidate=candidate_path,
                n=len(cases),  # Will be overridden by replay-input
                seed=seed,
                timeout_s=config.get("timeout_s", 2.0),
                mem_mb=config.get("mem_mb", 512),
                alpha=alpha,
                min_delta=min_delta,
                min_pass_rate=min_pass_rate,
                violation_cap=config.get("violation_cap", 25),
                parallel=config.get("parallel", 1),
                bootstrap_samples=config.get("bootstrap_samples", 1000),
                ci_method=ci_method,
                rr_ci_method=config.get("rr_ci_method", "log"),
                report_dir=None,
                dispatcher=config.get("dispatcher", "local"),
                executor=None,
                executor_config=None,
                export_violations=None,
                html_report=None,
                junit_report=None,
                queue_config=None,
                monitor_names=[],
                alert_webhooks=[],
                sandbox_plugins=None,
                log_file=None,
                log_json=None,
                metrics_enabled=None,
                metrics_port=None,
                metrics_host="0.0.0.0",
                failed_artifact_limit=None,
                failed_artifact_ttl_days=None,
                policy_version=None,
                otlp_endpoint=None,
                replay_input=cases_file,
                policy=None,
                power_target=config.get("power_target", 0.8),
                stability=config.get("stability", 1),
                shrink_violations=config.get("shrink_violations", False),
            )
        except Exception as e:
            click.echo(f"Error during replay: {e}", err=True)
            sys.exit(1)
        finally:
            # Clean up temp file
            try:
                cases_file.unlink()
                cases_file.parent.rmdir()
            except Exception:
                pass
    else:
        click.echo("Replay command:")
        click.echo("  " + " ".join(replay_cmd))
        click.echo("")
        click.echo("To execute this replay, run:")
        click.echo(f"  metamorphic-guard replay --from {report_file} --execute")


@main.command("power")
@click.option("--baseline-rate", required=True, type=float, help="Expected baseline pass rate (0-1)")
@click.option("--lift", type=float, default=None, help="Expected improvement (pass-rate delta). If not provided, calculates MDE.")
@click.option("--n", type=int, default=None, help="Sample size (number of test cases). If not provided, calculates required n.")
@click.option("--alpha", default=0.05, show_default=True, help="Significance level")
@click.option("--power-target", default=0.8, show_default=True, help="Desired statistical power")
@click.option("--min-delta", default=0.02, show_default=True, help="Minimum detectable effect threshold")
def power_command(
    baseline_rate: float,
    lift: float | None,
    n: int | None,
    alpha: float,
    power_target: float,
    min_delta: float,
) -> None:
    """
    Calculate statistical power or required sample size for pass-rate comparisons.
    
    Examples:
    
    \b
    # Calculate required sample size for 2% lift detection
    metamorphic-guard power --baseline-rate 0.92 --lift 0.02
    
    \b
    # Calculate power for given sample size
    metamorphic-guard power --baseline-rate 0.92 --lift 0.02 --n 400
    
    \b
    # Calculate minimum detectable effect for given sample size
    metamorphic-guard power --baseline-rate 0.92 --n 400
    """
    from .power import calculate_power, calculate_sample_size, estimate_mde
    
    if not (0 <= baseline_rate <= 1):
        click.echo("Error: baseline-rate must be between 0 and 1", err=True)
        sys.exit(1)
    
    if lift is not None and not (0 <= lift <= 1):
        click.echo("Error: lift must be between 0 and 1", err=True)
        sys.exit(1)
    
    if n is not None and n <= 0:
        click.echo("Error: n must be positive", err=True)
        sys.exit(1)
    
    click.echo("=" * 60)
    click.echo("Power Analysis")
    click.echo("=" * 60)
    click.echo(f"Baseline pass rate: {baseline_rate:.3f}")
    click.echo(f"Alpha (significance level): {alpha}")
    click.echo(f"Power target: {power_target}")
    click.echo(f"Minimum detectable effect: {min_delta}")
    click.echo("")
    
    if lift is not None:
        candidate_rate = min(1.0, baseline_rate + lift)
        click.echo(f"Expected improvement: {lift:.3f} (candidate rate: {candidate_rate:.3f})")
        
        if n is None:
            # Calculate required sample size
            try:
                required_n = calculate_sample_size(
                    baseline_rate=baseline_rate,
                    min_delta=lift,
                    alpha=alpha,
                    power_target=power_target,
                )
                click.echo(f"\nRequired sample size (n): {required_n}")
                click.echo(f"  To detect Δ ≥ {lift:.3f} with {power_target:.0%} power at α={alpha}")
            except ValueError as e:
                click.echo(f"Error: {e}", err=True)
                sys.exit(1)
        else:
            # Calculate power for given sample size
            power = calculate_power(
                baseline_rate=baseline_rate,
                candidate_rate=candidate_rate,
                sample_size=n,
                alpha=alpha,
                min_delta=min_delta,
            )
            click.echo(f"\nSample size (n): {n}")
            click.echo(f"Statistical power: {power:.3f} ({power:.1%})")
            if power < power_target:
                click.echo(f"⚠️  Power below target ({power_target:.1%})", err=True)
            else:
                click.echo(f"✓ Power meets target ({power_target:.1%})")
    else:
        # Calculate MDE for given sample size
        if n is None:
            click.echo("Error: Either --lift or --n must be provided", err=True)
            sys.exit(1)
        
        try:
            mde = estimate_mde(
                baseline_rate=baseline_rate,
                sample_size=n,
                alpha=alpha,
                power_target=power_target,
            )
            click.echo(f"\nSample size (n): {n}")
            click.echo(f"Minimum detectable effect (MDE): {mde:.3f} ({mde:.1%})")
            click.echo(f"  With {n} cases, you can detect improvements ≥ {mde:.3f} with {power_target:.0%} power")
            
            if mde > min_delta:
                click.echo(f"⚠️  MDE ({mde:.3f}) exceeds threshold ({min_delta:.3f})", err=True)
                click.echo(f"   Consider increasing n or lowering --min-delta")
        except ValueError as e:
            click.echo(f"Error: {e}", err=True)
            sys.exit(1)
    
    click.echo("")


@main.command("stability-audit")
@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 per run")
@click.option("--seed-start", default=42, show_default=True, help="Starting seed value")
@click.option("--num-seeds", default=10, show_default=True, help="Number of different seeds to test")
@click.option("--min-delta", default=0.02, show_default=True, help="Minimum improvement threshold")
@click.option("--min-pass-rate", default=0.80, show_default=True, help="Minimum candidate pass rate")
@click.option("--ci-method", default="newcombe", show_default=True, help="CI method to use")
@click.option("--output", type=click.Path(path_type=Path), default=None, help="Output JSON file for audit results")
def stability_audit_command(
    task: str,
    baseline: str,
    candidate: str,
    n: int,
    seed_start: int,
    num_seeds: int,
    min_delta: float,
    min_pass_rate: float,
    ci_method: str,
    output: Path | None,
) -> None:
    """Run stability audit across multiple seeds to detect flakiness."""
    from .stability_audit import run_stability_audit, audit_to_report
    
    click.echo(f"Running stability audit: {num_seeds} runs with seeds {seed_start}..{seed_start + num_seeds - 1}")
    click.echo(f"Task: {task}, Baseline: {baseline}, Candidate: {candidate}")
    
    try:
        audit_result = run_stability_audit(
            task_name=task,
            baseline_path=baseline,
            candidate_path=candidate,
            n=n,
            seed_start=seed_start,
            num_seeds=num_seeds,
            improve_delta=min_delta,
            min_pass_rate=min_pass_rate,
            ci_method=ci_method,
        )
        
        # Print report
        report = audit_to_report(audit_result)
        click.echo(report)
        
        # Save to file if requested
        if output:
            output.write_text(json.dumps(audit_result, indent=2), encoding="utf-8")
            click.echo(f"\nAudit results saved to: {output}")
        
        # Exit with error if flaky
        if audit_result["flaky"]:
            click.echo("\n⚠️  Flakiness detected! Exiting with error code.", err=True)
            sys.exit(1)
        
    except Exception as e:
        click.echo(f"Error running stability audit: {e}", err=True)
        sys.exit(1)


@main.command("bundle")
@click.argument("report", type=click.Path(exists=True, path_type=Path))
@click.option(
    "--output",
    "-o",
    type=click.Path(path_type=Path),
    default=None,
    help="Output bundle file path (defaults to <report>.tgz)",
)
@click.option(
    "--baseline",
    type=click.Path(exists=True, path_type=Path),
    default=None,
    help="Path to baseline implementation file",
)
@click.option(
    "--candidate",
    type=click.Path(exists=True, path_type=Path),
    default=None,
    help="Path to candidate implementation file",
)
def bundle_command(
    report: Path,
    output: Path | None,
    baseline: Path | None,
    candidate: Path | None,
) -> None:
    """Create a reproducible bundle from an evaluation report."""
    from .bundle import create_repro_bundle
    
    output_path = output or report.with_suffix(".tgz")
    
    try:
        bundle_path = create_repro_bundle(
            report_path=report,
            output_path=output_path,
            baseline_path=baseline,
            candidate_path=candidate,
        )
        click.echo(f"Repro bundle created: {bundle_path}")
    except Exception as e:
        click.echo(f"Error creating bundle: {e}", err=True)
        sys.exit(1)


@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

    # Determine kind from group
    kind_map = {
        "metamorphic_guard.monitors": "monitor",
        "metamorphic_guard.dispatchers": "dispatcher",
        "metamorphic_guard.executors": "executor",
        "metamorphic_guard.mutants": "mutant",
        "metamorphic_guard.judges": "judge",
        "metamorphic_guard.tasks": "task",
        "metamorphic_guard.relations": "relation",
    }
    plugin_kind = kind_map.get(definition.group, "unknown")
    
    payload = {
        "name": metadata.name or definition.name,
        "entry": entry_name,
        "kind": plugin_kind,
        "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()
