"""Controlled safetensors-versus-GGUF recipe benchmark.

This is a *model recipe* benchmark, unlike
:mod:`meshnet_node.route_session_benchmark`, which is a transport harness.  It
answers one question: on one machine, with one model revision and one fixed
workload, what do the Transformers/safetensors recipe and the whole-model
llama.cpp/GGUF recipes actually cost in speed, memory, fit, and output drift?

Two ideas keep the answer honest.

**Lanes.**  A recipe belongs to exactly one :class:`Lane`.  The quality lane
holds near-lossless recipes (bf16/f16 weights) whose outputs may legitimately be
compared for numerical agreement.  The performance-fit lane holds quantized
recipes (Q8_0, Q4_K_M, ...).  Quantized recipes are judged on speed, memory, and
artifact size only; their drift is *reported* but never read as evidence that
Q4 and bf16 are numerically equivalent, because they are not.  The lane is a
property of the recipe, so nothing downstream can quietly cross the boundary.

**Drivers.**  The measurement core here is pure and runtime-free: it drives a
:class:`RecipeDriver` and computes metrics.  Real runtimes live in
:mod:`meshnet_node.recipe_drivers` and are imported only on demand, which keeps
the default test suite deterministic, GPU-free and model-download-free while the
same code path produces the real evidence.
"""

from __future__ import annotations

import argparse
import json
import statistics
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import asdict, dataclass, field
from difflib import SequenceMatcher
from enum import Enum
from pathlib import Path
from typing import Any, Mapping, Protocol, Sequence

# Layout of the report document produced by :func:`build_report`.
REPORT_SCHEMA_VERSION = 1


class Lane(str, Enum):
    """Why a recipe is being measured at all.

    ``QUALITY`` recipes carry near-lossless weights, so comparing their output
    with the reference recipe is a meaningful correctness signal.
    ``PERFORMANCE_FIT`` recipes carry quantized weights: they exist to be faster
    or to fit, and their drift is descriptive, never a pass/fail equivalence
    claim.
    """

    QUALITY = "quality"
    PERFORMANCE_FIT = "performance-fit"


class BenchmarkError(RuntimeError):
    """Raised when a benchmark cannot be run as specified."""


@dataclass(frozen=True)
class SamplingPolicy:
    """The sampling policy every recipe must be given, identically.

    Greedy by default: sampling noise would otherwise be indistinguishable from
    quantization drift, and the whole point of the quality lane is to tell those
    two apart.
    """

    temperature: float = 0.0
    top_p: float = 1.0
    top_k: int = 1
    seed: int = 1234
    max_output_tokens: int = 64

    def to_dict(self) -> dict:
        return asdict(self)


@dataclass(frozen=True)
class PromptSpec:
    """One fixed prompt, tagged with the context length it is meant to exercise."""

    id: str
    text: str
    context_class: str = "short"

    def to_dict(self) -> dict:
        return asdict(self)


@dataclass(frozen=True)
class BenchmarkPlan:
    """The controlled variables: identical for every recipe in a report.

    A plan is the experiment.  If two recipes were measured under different
    plans, their numbers are not comparable and the report must not pretend they
    are, so the plan is recorded once at the top of the document rather than
    per-recipe.
    """

    plan_id: str
    model_id: str
    model_revision: str
    prompts: tuple[PromptSpec, ...]
    sampling: SamplingPolicy = SamplingPolicy()
    concurrency_levels: tuple[int, ...] = (1, 4)
    repeats: int = 1
    warmup_requests: int = 1

    def __post_init__(self) -> None:
        if not self.prompts:
            raise BenchmarkError("a benchmark plan needs at least one prompt")
        if not self.concurrency_levels or any(level < 1 for level in self.concurrency_levels):
            raise BenchmarkError("concurrency levels must all be >= 1")
        if self.repeats < 1:
            raise BenchmarkError("repeats must be >= 1")
        if 1 not in self.concurrency_levels or 4 not in self.concurrency_levels:
            raise BenchmarkError("a controlled baseline must include concurrency levels 1 and 4")

    def to_dict(self) -> dict:
        return {
            "plan_id": self.plan_id,
            "model_id": self.model_id,
            "model_revision": self.model_revision,
            "prompts": [prompt.to_dict() for prompt in self.prompts],
            "sampling": self.sampling.to_dict(),
            "concurrency_levels": list(self.concurrency_levels),
            "repeats": self.repeats,
            "warmup_requests": self.warmup_requests,
        }


@dataclass(frozen=True)
class RecipeSpec:
    """One runtime recipe under test.

    ``is_reference`` marks the single recipe every other recipe's output drift is
    measured against — the current Transformers/safetensors route, which
    decision Gate 8 keeps as the correctness backend.
    """

    id: str
    runtime: str
    weight_format: str
    weight_quantization: str
    lane: Lane
    device: str
    artifact_path: str = ""
    source_model_id: str = ""
    source_model_revision: str = ""
    artifact_sha256: str = ""
    is_reference: bool = False
    notes: str = ""

    def to_dict(self) -> dict:
        data = asdict(self)
        data["lane"] = self.lane.value
        return data


@dataclass(frozen=True)
class LoadStats:
    """What loading the recipe cost, before any token is generated."""

    artifact_bytes: int
    load_ms: float
    rss_bytes: int = 0
    vram_bytes: int = 0
    backend_detail: str = ""

    def to_dict(self) -> dict:
        return asdict(self)


@dataclass(frozen=True)
class GenerationSample:
    """One completed generation as reported by a driver.

    ``prefill_ms``/``decode_ms`` are the runtime's own split where it exposes one
    (llama.cpp does); drivers that cannot split honestly report ``prefill_ms`` as
    the time to the first token.  ``queue_wait_ms`` separates time spent waiting
    for a runtime slot from time spent computing, so a concurrency-4 TTFT is not
    silently read as a slower prefill.
    """

    text: str
    prompt_tokens: int
    decode_tokens: int
    ttft_ms: float
    prefill_ms: float
    decode_ms: float
    total_ms: float
    queue_wait_ms: float = 0.0


class RecipeDriver(Protocol):
    """The seam every runtime implements; the measurement core knows nothing else."""

    def load(self) -> LoadStats:
        """Load the artifact and return its cost."""

    def generate(self, prompt: str, sampling: SamplingPolicy) -> GenerationSample:
        """Run one complete generation under the given sampling policy."""

    def memory_probe(self) -> tuple[int, int]:
        """Return ``(rss_bytes, vram_bytes)`` observed right now."""

    def close(self) -> None:
        """Release the runtime."""


@dataclass(frozen=True)
class RequestOutcome:
    """One request attempt, successful or not.

    A failure is a first-class result, not an exception that aborts the run: a
    recipe that cannot sustain concurrency 4 has told us something, and the
    report must carry it rather than lose it.
    """

    recipe_id: str
    concurrency: int
    prompt_id: str
    repeat: int
    ok: bool
    latency_ms: float
    ttft_ms: float = 0.0
    prefill_ms: float = 0.0
    decode_ms: float = 0.0
    queue_wait_ms: float = 0.0
    prompt_tokens: int = 0
    decode_tokens: int = 0
    text: str = ""
    error: str = ""

    def to_dict(self) -> dict:
        return asdict(self)


def _percentile(values: Sequence[float], percentile: float) -> float:
    """Nearest-rank percentile; 0.0 for an empty sample."""
    ordered = sorted(values)
    if not ordered:
        return 0.0
    rank = max(1, -(-len(ordered) * percentile // 100))
    return round(ordered[int(rank) - 1], 4)


def _mean(values: Sequence[float]) -> float:
    return round(statistics.fmean(values), 4) if values else 0.0


@dataclass(frozen=True)
class ConcurrencyMetrics:
    """Aggregate metrics for one recipe at one concurrency level."""

    concurrency: int
    requests: int
    failures: int
    wall_ms: float
    ttft_p50_ms: float
    ttft_p95_ms: float
    latency_p50_ms: float
    latency_p95_ms: float
    prefill_tokens_per_sec: float
    decode_tokens_per_sec: float
    aggregate_decode_tokens_per_sec: float
    peak_rss_bytes: int
    peak_vram_bytes: int
    failure_reasons: tuple[str, ...] = ()

    def to_dict(self) -> dict:
        data = asdict(self)
        data["failure_reasons"] = list(self.failure_reasons)
        return data


def summarize_concurrency(
    outcomes: Sequence[RequestOutcome],
    *,
    concurrency: int,
    wall_ms: float,
    peak_rss_bytes: int,
    peak_vram_bytes: int,
) -> ConcurrencyMetrics:
    """Aggregate one recipe/concurrency cell.

    Per-request rates are averaged over successful requests; aggregate
    throughput is total decoded tokens over the wall clock of the whole cell,
    which is the only figure that credits a runtime for overlapping work.
    """
    ok = [outcome for outcome in outcomes if outcome.ok]
    failures = [outcome for outcome in outcomes if not outcome.ok]
    decode_tokens = sum(outcome.decode_tokens for outcome in ok)

    prefill_rates = [
        outcome.prompt_tokens / (outcome.prefill_ms / 1000)
        for outcome in ok
        if outcome.prefill_ms > 0 and outcome.prompt_tokens
    ]
    decode_rates = [
        outcome.decode_tokens / (outcome.decode_ms / 1000)
        for outcome in ok
        if outcome.decode_ms > 0 and outcome.decode_tokens
    ]
    return ConcurrencyMetrics(
        concurrency=concurrency,
        requests=len(outcomes),
        failures=len(failures),
        wall_ms=round(wall_ms, 4),
        ttft_p50_ms=_percentile([outcome.ttft_ms for outcome in ok], 50),
        ttft_p95_ms=_percentile([outcome.ttft_ms for outcome in ok], 95),
        latency_p50_ms=_percentile([outcome.latency_ms for outcome in ok], 50),
        latency_p95_ms=_percentile([outcome.latency_ms for outcome in ok], 95),
        prefill_tokens_per_sec=_mean(prefill_rates),
        decode_tokens_per_sec=_mean(decode_rates),
        aggregate_decode_tokens_per_sec=round(decode_tokens / max(1e-6, wall_ms / 1000), 4),
        peak_rss_bytes=peak_rss_bytes,
        peak_vram_bytes=peak_vram_bytes,
        failure_reasons=tuple(sorted({outcome.error for outcome in failures if outcome.error})),
    )


@dataclass
class RecipeMeasurement:
    """Everything measured for one recipe across every concurrency level."""

    recipe: RecipeSpec
    load: LoadStats
    metrics: dict[int, ConcurrencyMetrics] = field(default_factory=dict)
    outcomes: list[RequestOutcome] = field(default_factory=list)
    unavailable_reason: str = ""

    @property
    def available(self) -> bool:
        return not self.unavailable_reason

    def outputs_by_prompt(self) -> dict[str, str]:
        """First successful output per prompt, at the lowest concurrency measured.

        Drift is a property of the recipe, not of load: concurrency must not
        change greedy output, so the least-contended sample is the fair one.
        """
        best: dict[str, tuple[int, str]] = {}
        for outcome in self.outcomes:
            if not outcome.ok:
                continue
            seen = best.get(outcome.prompt_id)
            if seen is None or outcome.concurrency < seen[0]:
                best[outcome.prompt_id] = (outcome.concurrency, outcome.text)
        return {prompt_id: text for prompt_id, (_, text) in best.items()}

    def to_dict(self) -> dict:
        return {
            "recipe": self.recipe.to_dict(),
            "available": self.available,
            "unavailable_reason": self.unavailable_reason,
            "load": self.load.to_dict(),
            "concurrency": {
                str(level): metrics.to_dict() for level, metrics in sorted(self.metrics.items())
            },
            "outcomes": [outcome.to_dict() for outcome in self.outcomes],
        }


@dataclass(frozen=True)
class DriftReport:
    """Output drift of one recipe against the reference recipe.

    ``advisory`` is true for every performance-fit recipe: the number is
    published, but a Q4 recipe disagreeing with bf16 is expected behaviour, not a
    defect, and no gate may read it as one.
    """

    recipe_id: str
    lane: Lane
    reference_id: str
    compared_prompts: int
    exact_match_rate: float
    mean_similarity: float
    advisory: bool
    per_prompt: tuple[dict[str, Any], ...] = ()

    def to_dict(self) -> dict:
        return {
            "recipe_id": self.recipe_id,
            "lane": self.lane.value,
            "reference_id": self.reference_id,
            "compared_prompts": self.compared_prompts,
            "exact_match_rate": self.exact_match_rate,
            "mean_similarity": self.mean_similarity,
            "advisory": self.advisory,
            "per_prompt": list(self.per_prompt),
        }


def _first_divergence(left: str, right: str) -> int:
    """Index of the first differing character, or -1 when the strings agree."""
    for index, (a, b) in enumerate(zip(left, right)):
        if a != b:
            return index
    return -1 if len(left) == len(right) else min(len(left), len(right))


def compute_drift(
    measurement: RecipeMeasurement,
    reference: RecipeMeasurement,
) -> DriftReport:
    """Compare one recipe's greedy outputs with the reference recipe's."""
    reference_outputs = reference.outputs_by_prompt()
    outputs = measurement.outputs_by_prompt()
    shared = sorted(set(outputs) & set(reference_outputs))

    per_prompt: list[dict[str, Any]] = []
    exact = 0
    similarities: list[float] = []
    for prompt_id in shared:
        got, want = outputs[prompt_id], reference_outputs[prompt_id]
        matches = got == want
        exact += matches
        similarity = round(SequenceMatcher(None, want, got).ratio(), 4)
        similarities.append(similarity)
        per_prompt.append({
            "prompt_id": prompt_id,
            "exact_match": matches,
            "similarity": similarity,
            "first_divergence_char": _first_divergence(want, got),
            "reference_text": want,
            "recipe_text": got,
        })

    return DriftReport(
        recipe_id=measurement.recipe.id,
        lane=measurement.recipe.lane,
        reference_id=reference.recipe.id,
        compared_prompts=len(shared),
        exact_match_rate=round(exact / len(shared), 4) if shared else 0.0,
        mean_similarity=_mean(similarities),
        advisory=measurement.recipe.lane is Lane.PERFORMANCE_FIT,
        per_prompt=tuple(per_prompt),
    )


class _PeakMemory:
    """Continuously sample a driver's memory while requests are in flight."""

    def __init__(self, driver: RecipeDriver) -> None:
        self._driver = driver
        self.peak_rss = 0
        self.peak_vram = 0

    def sample(self) -> None:
        try:
            rss, vram = self._driver.memory_probe()
        except Exception:  # a probe must never fail a benchmark run
            return
        self.peak_rss = max(self.peak_rss, rss)
        self.peak_vram = max(self.peak_vram, vram)

    def sample_until(self, stop: threading.Event, interval_s: float = 0.01) -> None:
        """Sample until ``stop`` is set, including one final post-request probe."""
        while not stop.wait(interval_s):
            self.sample()
        self.sample()


def _run_request(
    driver: RecipeDriver,
    recipe: RecipeSpec,
    prompt: PromptSpec,
    sampling: SamplingPolicy,
    concurrency: int,
    repeat: int,
    memory: _PeakMemory,
) -> RequestOutcome:
    started = time.monotonic()
    try:
        sample = driver.generate(prompt.text, sampling)
    except Exception as exc:  # a failed request is data, not a crashed benchmark
        return RequestOutcome(
            recipe_id=recipe.id,
            concurrency=concurrency,
            prompt_id=prompt.id,
            repeat=repeat,
            ok=False,
            latency_ms=round((time.monotonic() - started) * 1000, 4),
            error=f"{type(exc).__name__}: {exc}",
        )
    finally:
        memory.sample()

    return RequestOutcome(
        recipe_id=recipe.id,
        concurrency=concurrency,
        prompt_id=prompt.id,
        repeat=repeat,
        ok=True,
        latency_ms=round(sample.total_ms, 4),
        ttft_ms=round(sample.ttft_ms, 4),
        prefill_ms=round(sample.prefill_ms, 4),
        decode_ms=round(sample.decode_ms, 4),
        queue_wait_ms=round(sample.queue_wait_ms, 4),
        prompt_tokens=sample.prompt_tokens,
        decode_tokens=sample.decode_tokens,
        text=sample.text,
    )


def measure_recipe(
    driver: RecipeDriver,
    recipe: RecipeSpec,
    plan: BenchmarkPlan,
) -> RecipeMeasurement:
    """Load one recipe and run the whole plan against it.

    The driver is closed exactly once, whatever happens, so a recipe that dies at
    concurrency 4 still releases its weights before the next recipe loads.
    """
    load = driver.load()
    measurement = RecipeMeasurement(recipe=recipe, load=load)

    try:
        for _ in range(plan.warmup_requests):
            try:
                driver.generate(plan.prompts[0].text, plan.sampling)
            except Exception:  # a failing warmup is reported by the real requests
                break

        for concurrency in plan.concurrency_levels:
            # Measure each cell independently and sample while requests are in
            # flight; post-request probes alone miss transient KV/workspace peaks.
            memory = _PeakMemory(driver)
            memory.sample()
            stop_sampling = threading.Event()
            sampler = threading.Thread(
                target=memory.sample_until,
                args=(stop_sampling,),
                name=f"recipe-memory-{recipe.id}-c{concurrency}",
                daemon=True,
            )
            requests = [
                (prompt, repeat)
                for repeat in range(plan.repeats)
                for prompt in plan.prompts
                for _ in range(concurrency)
            ]
            started = time.monotonic()
            sampler.start()
            try:
                with ThreadPoolExecutor(max_workers=concurrency) as pool:
                    outcomes = list(pool.map(
                        lambda item: _run_request(
                            driver, recipe, item[0], plan.sampling, concurrency, item[1], memory
                        ),
                        requests,
                    ))
            finally:
                stop_sampling.set()
                sampler.join()
            wall_ms = (time.monotonic() - started) * 1000

            measurement.outcomes.extend(outcomes)
            measurement.metrics[concurrency] = summarize_concurrency(
                outcomes,
                concurrency=concurrency,
                wall_ms=wall_ms,
                peak_rss_bytes=memory.peak_rss,
                peak_vram_bytes=memory.peak_vram,
            )
    finally:
        driver.close()

    return measurement


def build_report(
    plan: BenchmarkPlan,
    measurements: Sequence[RecipeMeasurement],
    *,
    host: dict[str, Any],
    evidence_class: str,
    provenance: Mapping[str, Any] | None = None,
) -> dict:
    """Assemble the machine-readable benchmark document.

    ``evidence_class`` is one of ``synthetic``, ``local-real`` or
    ``multi-machine-real`` and is never inferred: a report that cannot say how it
    was produced cannot be trusted by a release gate.
    """
    if evidence_class not in {"synthetic", "local-real", "multi-machine-real"}:
        raise BenchmarkError(f"unknown evidence class {evidence_class!r}")
    if evidence_class != "synthetic" and not isinstance(provenance, Mapping):
        raise BenchmarkError("non-synthetic reports require canonical signed provenance")

    references = [m for m in measurements if m.recipe.is_reference]
    if len(references) != 1:
        raise BenchmarkError(
            f"exactly one reference recipe is required, got {len(references)}"
        )
    reference = references[0]
    if reference.recipe.lane is not Lane.QUALITY:
        raise BenchmarkError("the reference recipe must sit in the quality lane")

    drift = [
        compute_drift(measurement, reference).to_dict()
        for measurement in measurements
        if measurement is not reference and measurement.available
    ]
    report = {
        "schema_version": REPORT_SCHEMA_VERSION,
        "evidence_class": evidence_class,
        "plan": plan.to_dict(),
        "host": host,
        "reference_recipe_id": reference.recipe.id,
        "recipes": [measurement.to_dict() for measurement in measurements],
        "drift": drift,
    }
    if provenance is not None:
        report["provenance"] = dict(provenance)
    return report


def format_summary(report: dict) -> str:
    """Render the human-readable companion to the JSON artifact."""
    plan = report["plan"]
    lines = [
        f"Recipe benchmark {plan['plan_id']} ({report['evidence_class']})",
        f"model {plan['model_id']}@{plan['model_revision']}",
    ]
    for entry in report["recipes"]:
        recipe = entry["recipe"]
        if not entry["available"]:
            lines.append(f"{recipe['id']:38} UNAVAILABLE: {entry['unavailable_reason']}")
            continue
        artifact_gb = entry["load"]["artifact_bytes"] / 1e9
        for level, metrics in entry["concurrency"].items():
            lines.append(
                f"{recipe['id']:38} [{recipe['lane']:16}] c={level:>2} "
                f"ttft p50/p95 {metrics['ttft_p50_ms']:8.1f}/{metrics['ttft_p95_ms']:8.1f} ms; "
                f"prefill {metrics['prefill_tokens_per_sec']:7.1f} tok/s; "
                f"decode {metrics['decode_tokens_per_sec']:6.1f} tok/s; "
                f"aggregate {metrics['aggregate_decode_tokens_per_sec']:7.1f} tok/s; "
                f"rss {metrics['peak_rss_bytes'] / 1e9:5.2f} GB; "
                f"vram {metrics['peak_vram_bytes'] / 1e9:5.2f} GB; "
                f"artifact {artifact_gb:5.2f} GB; failures {metrics['failures']}"
            )
    for entry in report["drift"]:
        tag = "advisory" if entry["advisory"] else "gated"
        lines.append(
            f"drift {entry['recipe_id']:32} vs {entry['reference_id']:28} "
            f"exact {entry['exact_match_rate']:.2f}; similarity {entry['mean_similarity']:.3f} ({tag})"
        )
    return "\n".join(lines)


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(
        description="Run the controlled safetensors-versus-GGUF recipe benchmark"
    )
    parser.add_argument("--config", type=Path, required=True, help="benchmark configuration JSON")
    parser.add_argument(
        "--profile",
        choices=("contract-v1", "gpu-diagnostic"),
        default="contract-v1",
        help="validation and provenance profile (GPU diagnostics are not v1-eligible)",
    )
    parser.add_argument("--json-out", type=Path, help="write the JSON report to this path")
    parser.add_argument("--summary-out", type=Path, help="write the text summary to this path")
    args = parser.parse_args(argv)

    from .recipe_drivers import (  # heavy runtimes: import on demand
        run_configured_benchmark,
        run_configured_gpu_diagnostic,
    )

    runner = (
        run_configured_gpu_diagnostic
        if args.profile == "gpu-diagnostic"
        else run_configured_benchmark
    )
    report = runner(json.loads(args.config.read_text(encoding="utf-8")))
    summary = format_summary(report)
    if args.json_out:
        args.json_out.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
    if args.summary_out:
        args.summary_out.write_text(summary + "\n", encoding="utf-8")
    print(summary)
    return 0


if __name__ == "__main__":  # pragma: no cover - CLI entry point
    raise SystemExit(main())
