"""`meshnet-node doctor` — prove the selected shard actually runs.

The doctor answers one question: *would the model/shard/recipe this node is
configured to serve really execute here?* It answers it the only way that is
not a guess — by loading the selection through the production backend path and
pushing a bounded, real forward through the selected layers. Generic hardware
probing (is there a GPU, can Torch allocate a tensor) proves nothing about a
shard and is deliberately not what this reports on.

Two shapes of probe, chosen by where the shard sits, never by which model it is:

* head shard — tokenize a short prompt, embed it, run this shard's layers.
* mid/tail shard — synthesize a small hidden-state tensor in the same wire
  format peers send, and push it through `forward_bytes`. A tail shard decodes
  it, which also exercises the final norm and `lm_head`.

Everything here is model-agnostic: `model_id` is opaque, and no vendor or kernel
name is a branch. Failures are reported as a category plus an actionable hint
(never a raw traceback, unless the caller asks for one) and produce a *failed*
capability report — a failure is evidence too, and NCA-003 refuses to register
without a fresh passing one.
"""

from __future__ import annotations

import base64
import struct
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Mapping, Sequence

from .capability import (
    STATUS_FAILED,
    STATUS_PASSED,
    CapabilityReport,
    build_capability_report,
)
from .native_backend import NativeWorkerBackendAdapter
from .recipe_manifest import (
    DEFAULT_RECIPE_ID,
    Recipe,
    RecipeManifest,
    RecipeManifestError,
    load_recipe_manifest,
)

# The probe is deliberately tiny: enough tokens to drive every layer in the
# shard once, small enough that `doctor` costs seconds beyond the model load.
PROBE_TOKENS = 4
PROBE_PROMPT = "meshnet capability probe"

# Failure categories. These are what an operator acts on, so they name the thing
# to fix, not the exception that surfaced it.
CATEGORY_NO_MODEL = "no-model-selected"
CATEGORY_MISSING_DEPENDENCY = "missing-dependency"
CATEGORY_MODEL_UNAVAILABLE = "model-unavailable"
CATEGORY_INSUFFICIENT_MEMORY = "insufficient-memory"
CATEGORY_INVALID_SHARD = "invalid-shard"
CATEGORY_UNSUPPORTED_RECIPE = "unsupported-recipe"
CATEGORY_LOAD_FAILED = "load-failed"
CATEGORY_FORWARD_FAILED = "forward-failed"

CATEGORY_HINTS: Mapping[str, str] = {
    CATEGORY_NO_MODEL: (
        "No model is selected. Pass --model <repo-or-name>, or run `meshnet-node` "
        "once to save a config."
    ),
    CATEGORY_MISSING_DEPENDENCY: (
        "The model runtime is not installed. Install the node's model extras "
        "(torch, transformers, safetensors, accelerate, bitsandbytes)."
    ),
    CATEGORY_MODEL_UNAVAILABLE: (
        "The model files could not be read. Check the model id, --download-dir, "
        "and that the artifact is downloaded or reachable."
    ),
    CATEGORY_INSUFFICIENT_MEMORY: (
        "This shard does not fit in memory. Serve fewer layers (--shard-start / "
        "--shard-end) or use a smaller quantization (-q int8, -q nf4)."
    ),
    CATEGORY_INVALID_SHARD: (
        "The requested layer range does not exist in this model. Check "
        "--shard-start / --shard-end against the model's layer count."
    ),
    CATEGORY_UNSUPPORTED_RECIPE: (
        "The recipe asks for an execution setting this backend cannot apply. "
        "Select a different recipe with --recipe."
    ),
    CATEGORY_LOAD_FAILED: (
        "The shard could not be loaded. Re-run with --debug for the full traceback."
    ),
    CATEGORY_FORWARD_FAILED: (
        "The shard loaded but could not execute a forward pass. This node cannot "
        "serve this model/shard; re-run with --debug for the full traceback."
    ),
}


class DoctorError(RuntimeError):
    """A validation failure with an operator-facing category and hint."""

    def __init__(self, category: str, message: str) -> None:
        super().__init__(message)
        self.category = category

    @property
    def hint(self) -> str:
        return CATEGORY_HINTS.get(self.category, "")


@dataclass(frozen=True)
class DoctorSelection:
    """The one model/shard/config combination startup would load."""

    model_id: str
    shard_start: int
    shard_end: int
    quantization: str = "auto"
    cache_dir: Path | None = None
    force_cpu: bool = False

    @property
    def shard_label(self) -> str:
        return f"layers {self.shard_start}–{self.shard_end}"


@dataclass(frozen=True)
class RecipeResult:
    """One recipe's validation outcome, with the report it produced."""

    recipe: Recipe
    report: CapabilityReport
    category: str | None = None
    error: BaseException | None = None

    @property
    def passed(self) -> bool:
        return self.report.passed

    @property
    def hint(self) -> str:
        return CATEGORY_HINTS.get(self.category or "", "")


@dataclass(frozen=True)
class DoctorResult:
    """The outcome of a doctor run over one or more recipes."""

    selection: DoctorSelection
    results: tuple[RecipeResult, ...] = ()

    @property
    def passed(self) -> bool:
        return bool(self.results) and all(r.passed for r in self.results)

    @property
    def reports(self) -> tuple[CapabilityReport, ...]:
        return tuple(r.report for r in self.results)

    @property
    def exit_code(self) -> int:
        return 0 if self.passed else 1


# --- selection: the same resolution startup performs ------------------------


def resolve_selection(
    cfg: Mapping[str, Any],
    *,
    detect_layers: Callable[[str, Path | None], int | None] | None = None,
) -> DoctorSelection:
    """Resolve config + flags into the selection startup would load.

    This mirrors `startup.run_startup`: the same model id, the same
    `bf16`→`bfloat16` quantization normalization, and the same shard default of
    the whole model when no range is pinned. It deliberately does *not* ask the
    tracker for a gap assignment — the doctor is an offline check of what this
    node can run, and startup re-validates whatever range it is finally given.
    """
    model_id = _selected_model_id(cfg)
    if not model_id:
        raise DoctorError(
            CATEGORY_NO_MODEL, "no model is selected in config or flags"
        )

    cache_dir = Path(cfg["download_dir"]) if cfg.get("download_dir") else None
    quantization = str(cfg.get("quantization") or "auto").replace("bf16", "bfloat16")

    shard_start = cfg.get("shard_start")
    shard_end = cfg.get("shard_end")
    if shard_start is None or shard_end is None:
        detect = detect_layers or _detect_layers
        total = detect(model_id, cache_dir)
        if total is None:
            raise DoctorError(
                CATEGORY_MODEL_UNAVAILABLE,
                f"could not read the layer count from the {model_id} config; "
                "pass --shard-start and --shard-end explicitly",
            )
        shard_start = 0 if shard_start is None else shard_start
        shard_end = total - 1 if shard_end is None else shard_end

    if shard_start < 0 or shard_end < shard_start:
        raise DoctorError(
            CATEGORY_INVALID_SHARD,
            f"invalid shard range {shard_start}–{shard_end}: start must be "
            "non-negative and not greater than end",
        )

    return DoctorSelection(
        model_id=model_id,
        shard_start=int(shard_start),
        shard_end=int(shard_end),
        quantization=quantization,
        cache_dir=cache_dir,
        force_cpu=bool(cfg.get("force_cpu", False)),
    )


def _selected_model_id(cfg: Mapping[str, Any]) -> str | None:
    """The HF repo startup would load, resolving a catalog alias if needed."""
    hf_repo = str(cfg.get("model_hf_repo") or "").strip()
    if hf_repo:
        return hf_repo
    name = str(cfg.get("model_name") or "").strip()
    if not name:
        return None
    from .model_catalog import resolve_model_alias

    preset = resolve_model_alias(name)
    if preset is not None and preset.hf_repo:
        return preset.hf_repo
    return name if "/" in name else None


def _detect_layers(model_id: str, cache_dir: Path | None) -> int | None:
    from .startup import _detect_num_layers

    return _detect_num_layers(model_id, cache_dir=cache_dir)


# --- the bounded real forward ----------------------------------------------


@dataclass(frozen=True)
class ProbeInput:
    """A synthetic hidden-state payload in the same wire format peers send."""

    body: bytes
    shape: list[int]
    attention_mask_header: str | None
    position_ids_header: str | None


def _int64_header(rows: Sequence[Sequence[int]]) -> str:
    """Encode an int64 tensor as `shape:base64`, matching the backend's format."""
    flat = [int(v) for row in rows for v in row]
    raw = struct.pack(f"<{len(flat)}q", *flat)
    shape = f"{len(rows)},{len(rows[0])}" if rows else "0"
    return f"{shape}:{base64.b64encode(raw).decode('ascii')}"


def build_probe_input(hidden_size: int, tokens: int = PROBE_TOKENS) -> ProbeInput:
    """Build a bounded mid-shard probe: `tokens` positions of bfloat16 zeros.

    Zeros are a legitimate hidden state; what is being proven is that the
    layers execute on this device, not that the output means anything. The
    payload is built with plain bytes so callers need no Torch import.
    """
    if hidden_size <= 0:
        raise DoctorError(
            CATEGORY_FORWARD_FAILED,
            "the backend reports no hidden size, so no probe tensor can be built",
        )
    ones = [[1] * tokens]
    positions = [list(range(tokens))]
    return ProbeInput(
        body=b"\x00" * (tokens * hidden_size * 2),  # bfloat16 == 2 bytes
        shape=[1, tokens, hidden_size],
        attention_mask_header=_int64_header(ones),
        position_ids_header=_int64_header(positions),
    )


def probe_forward(backend: Any, *, tokens: int = PROBE_TOKENS) -> dict:
    """Run one bounded real forward through the shard `backend` holds.

    Returns a small detail dict for the human summary. Raises `DoctorError`
    (category `forward-failed`) if the shard cannot execute or returns nothing.
    """
    is_head = bool(getattr(backend, "is_head", False))
    is_tail = bool(getattr(backend, "is_tail", False))

    try:
        if is_head:
            output = backend.encode_prompt(PROBE_PROMPT)
            kind = "prompt"
            if is_tail:
                # A head+tail shard owns the lm_head too. Re-entering above the
                # last layer runs no layer again — it only decodes — so the whole
                # selected shard is covered without a second forward through it.
                output = backend.forward_bytes(
                    output.body,
                    output.shape,
                    output.attention_mask_header,
                    output.position_ids_header,
                    start_layer=int(getattr(backend, "shard_end", 0)) + 1,
                )
                kind = "prompt+decode"
        else:
            probe = build_probe_input(int(getattr(backend, "hidden_size", 0) or 0))
            output = backend.forward_bytes(
                probe.body,
                probe.shape,
                probe.attention_mask_header,
                probe.position_ids_header,
                start_layer=getattr(backend, "shard_start", None),
            )
            kind = "hidden-states"
    except DoctorError:
        raise
    except Exception as exc:
        raise DoctorError(CATEGORY_FORWARD_FAILED, _describe(exc)) from exc

    return {"probe": kind, "tokens": tokens, **_describe_output(output)}


def _describe_output(output: Any) -> dict:
    """Validate the forward produced real output, and summarize it."""
    if output is None:
        raise DoctorError(
            CATEGORY_FORWARD_FAILED, "the shard forward returned no output"
        )

    token_id = getattr(output, "token_id", None)
    if token_id is not None:  # tail shard: decoded a token
        return {"output": "token", "token_id": int(token_id)}

    body = getattr(output, "body", None)
    shape = list(getattr(output, "shape", []) or [])
    if not body or not shape:
        raise DoctorError(
            CATEGORY_FORWARD_FAILED,
            "the shard forward returned an empty hidden-state payload",
        )
    return {"output": "hidden-states", "shape": shape}


# --- running the doctor -----------------------------------------------------


def default_load_backend(
    selection: DoctorSelection,
    recipe: Recipe,
) -> Any:
    """Load the shard through the exact path startup uses."""
    from .torch_server import _load_backend

    return _load_backend(
        selection.model_id,
        selection.shard_start,
        selection.shard_end,
        selection.quantization,
        selection.cache_dir,
        force_cpu=selection.force_cpu,
        recipe_params=recipe.params,
    )


def select_recipes(
    manifest: RecipeManifest,
    *,
    recipe_id: str | None = None,
    all_recipes: bool = False,
) -> tuple[Recipe, ...]:
    """The recipes to validate: the selected one, or every one on request.

    `--all-recipes` is the only way to pay for validating recipes the node was
    not asked to serve; ordinary onboarding validates exactly one.
    """
    if all_recipes:
        if recipe_id is not None:
            raise DoctorError(
                CATEGORY_UNSUPPORTED_RECIPE,
                "--recipe and --all-recipes are mutually exclusive",
            )
        return manifest.recipes
    try:
        return (manifest.require(recipe_id or DEFAULT_RECIPE_ID),)
    except RecipeManifestError as exc:
        raise DoctorError(CATEGORY_UNSUPPORTED_RECIPE, str(exc)) from exc


def run_doctor(
    selection: DoctorSelection,
    *,
    manifest: RecipeManifest | None = None,
    recipe_id: str | None = None,
    all_recipes: bool = False,
    load_backend: Callable[[DoctorSelection, Recipe], Any] | None = None,
    now: Callable[[], float] | None = None,
) -> DoctorResult:
    """Validate the selection, one bounded real forward per recipe.

    Never raises for a validation failure: every recipe yields a report, passed
    or failed, so the caller can write the evidence out either way. `DoctorError`
    only escapes for input the caller got wrong (an unknown recipe id).
    """
    manifest = manifest or load_recipe_manifest()
    recipes = select_recipes(manifest, recipe_id=recipe_id, all_recipes=all_recipes)
    clock = now or time.time
    load = load_backend or default_load_backend

    results = [
        _validate_recipe(selection, recipe, manifest, load, clock)
        for recipe in recipes
    ]
    return DoctorResult(selection=selection, results=tuple(results))


def validate_loaded_backend(
    backend: Any,
    selection: DoctorSelection,
    recipe: Recipe,
    manifest: RecipeManifest,
    *,
    now: Callable[[], float] | None = None,
) -> RecipeResult:
    """Validate a shard that is already loaded, without loading it a second time.

    Startup calls this on the very backend that would serve traffic, so the proof
    it produces is about that object, not about a re-load that might have landed
    on a different device.
    """
    return _validate_recipe(
        selection, recipe, manifest, lambda *_: backend, now or time.time
    )


def _validate_recipe(
    selection: DoctorSelection,
    recipe: Recipe,
    manifest: RecipeManifest,
    load_backend: Callable[[DoctorSelection, Recipe], Any],
    clock: Callable[[], float],
) -> RecipeResult:
    started = time.monotonic()
    backend: Any = None
    category: str | None = None
    error: BaseException | None = None
    diagnostics: list[str] = []
    try:
        backend = load_backend(selection, recipe)
        probe_forward(backend)
    except DoctorError as exc:
        category, error = exc.category, exc
        diagnostics = [str(exc), exc.hint]
    except Exception as exc:  # noqa: BLE001 — every failure becomes a report
        category = classify_failure(exc)
        error = exc
        diagnostics = [_describe(exc), CATEGORY_HINTS.get(category, "")]
    duration_ms = int((time.monotonic() - started) * 1000)

    device = _backend_device(backend, selection)
    # Only the native adapter has an authoritative immutable GGUF report and
    # deployment pin.  The Transformers path deliberately remains dark: a
    # model/config fingerprint is not an exact ArtifactIdentity.
    identity = backend.identity if isinstance(backend, NativeWorkerBackendAdapter) else None
    model_id = selection.model_id if identity is None else identity.artifact.artifact_id
    shard_start = selection.shard_start if identity is None else identity.shard_start
    shard_end = selection.shard_end if identity is None else identity.shard_end - 1
    recipe_id = recipe.id if identity is None else identity.recipe.recipe_id
    recipe_version = recipe.version if identity is None else identity.recipe.recipe_version
    catalogue_version = (
        manifest.catalogue_version if identity is None else identity.recipe.catalogue_version
    )
    backend_id = recipe.backend_id if identity is None else identity.recipe.backend_id
    quantization = (
        selection.quantization if identity is None else identity.recipe.weight_quantization
    )
    runtime = _runtime_versions()
    model_config = _model_config(backend)
    revision = None
    if identity is not None:
        revision = identity.artifact.revision
        model_config = "sha256:" + identity.artifact.architecture_digest
        runtime = {**runtime, "native_runtime": identity.recipe.runtime_version}
    report = build_capability_report(
        model_id=model_id,
        shard_start=shard_start,
        shard_end=shard_end,
        recipe_id=recipe_id,
        recipe_version=recipe_version,
        catalogue_version=catalogue_version,
        backend_id=backend_id,
        device=device,
        device_name=_backend_device_name(device),
        quantization=quantization,
        runtime=runtime,
        revision=revision,
        model_config=model_config,
        status=STATUS_FAILED if category else STATUS_PASSED,
        duration_ms=duration_ms,
        diagnostics=[d for d in diagnostics if d] or None,
        validated_at=clock(),
        identity=identity,
    )
    if category:
        return RecipeResult(
            recipe=recipe, report=report, category=category, error=error
        )
    return RecipeResult(recipe=recipe, report=report)


def classify_failure(exc: BaseException) -> str:
    """Map a backend exception to an operator-facing category.

    Matches on the backend's own error types, never on model or vendor names.
    """
    from .model_backend import (
        InsufficientVRAMError,
        MissingModelDependencyError,
        PartialModelLoadUnsupported,
        UnsupportedRecipeParam,
    )

    if isinstance(exc, MissingModelDependencyError):
        return CATEGORY_MISSING_DEPENDENCY
    if isinstance(exc, InsufficientVRAMError):
        return CATEGORY_INSUFFICIENT_MEMORY
    if isinstance(exc, UnsupportedRecipeParam):
        return CATEGORY_UNSUPPORTED_RECIPE
    if isinstance(exc, PartialModelLoadUnsupported):
        return CATEGORY_LOAD_FAILED
    if isinstance(exc, ValueError):  # shard range vs. the model's real layers
        return CATEGORY_INVALID_SHARD
    if isinstance(exc, (FileNotFoundError, OSError)):
        return CATEGORY_MODEL_UNAVAILABLE
    return CATEGORY_LOAD_FAILED


def _describe(exc: BaseException) -> str:
    """A one-line, traceback-free description. Sanitized by the report."""
    text = str(exc).strip()
    return f"{type(exc).__name__}: {text}" if text else type(exc).__name__


def _backend_device(backend: Any, selection: DoctorSelection) -> str:
    device = getattr(backend, "device", None)
    if device is None:
        # The load failed, so no device was chosen — record the one that was asked for.
        return "cpu" if selection.force_cpu else "unknown"
    return str(getattr(device, "type", device))


def _backend_device_name(device: str) -> str | None:
    """The accelerator's name, when the shard actually landed on one."""
    if device != "cuda":
        return None
    from .hardware import detect_hardware

    try:
        return detect_hardware().get("gpu_name") or None
    except Exception:
        return None


def _model_config(backend: Any) -> Any:
    """The loaded model's config, for the report's fingerprint."""
    config = getattr(getattr(backend, "model", None), "config", None)
    to_dict = getattr(config, "to_dict", None)
    if not callable(to_dict):
        return None
    try:
        return to_dict()
    except Exception:
        return None


def _runtime_versions() -> dict[str, str]:
    """Versions of the stack that ran the forward — opaque labels, never branches."""
    versions: dict[str, str] = {}
    for name in ("torch", "transformers"):
        try:
            module = __import__(name)
        except Exception:
            continue
        version = getattr(module, "__version__", None)
        if version:
            versions[name] = str(version)
    return versions


# --- output -----------------------------------------------------------------

DEFAULT_REPORT_FILENAME = "capability.json"


def default_report_path() -> Path:
    from .config import config_path

    return config_path().parent / DEFAULT_REPORT_FILENAME


def write_reports(reports: Sequence[CapabilityReport], path: Path) -> Path:
    """Write the capability report(s) as JSON. A failed run writes too."""
    import json

    path.parent.mkdir(parents=True, exist_ok=True)
    if len(reports) == 1:
        path.write_text(reports[0].to_json(indent=2) + "\n", encoding="utf-8")
    else:
        payload = [r.to_dict() for r in reports]
        path.write_text(
            json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8"
        )
    return path


def render_result(result: DoctorResult, *, report_path: Path | None = None) -> str:
    """The human summary: what was validated, what to do if it failed."""
    selection = result.selection
    lines = [
        "meshnet-node doctor",
        f"  Model:        {selection.model_id}",
        f"  Shard:        {selection.shard_label}",
        f"  Quantization: {selection.quantization}",
        "",
    ]

    for item in result.results:
        mark = "PASS" if item.passed else "FAIL"
        device = item.report.backend.device
        lines.append(
            f"  [{mark}] recipe {item.recipe.id} (v{item.recipe.version}) "
            f"on {device} — {item.report.duration_ms} ms"
        )
        if not item.passed:
            for diagnostic in item.report.diagnostics:
                lines.append(f"         {diagnostic}")

    lines.append("")
    if result.passed:
        count = len(result.results)
        what = "recipe" if count == 1 else "recipes"
        lines.append(
            f"  OK — the selected shard ran a real forward for {count} {what}."
        )
    else:
        failed = [r for r in result.results if not r.passed]
        categories = ", ".join(dict.fromkeys(r.category or "unknown" for r in failed))
        lines.append(f"  FAILED — {categories}. This node cannot serve this shard.")

    if report_path is not None:
        lines.append(f"  Capability report: {report_path}")
    return "\n".join(lines)
