"""Sync contract surface probe against a live simulator (majors 6–9).

Mirrors ``app/surface_probe.py`` classification and path/body synthesis, but
talks HTTP to ``API_URL`` instead of an in-process ASGI app.

Layer A matrix:
- every declared path+verb (GET/PUT/POST/DELETE)
- synthetic HEAD on each GET path (histogram only; not counted in declared)
- ticket + CSRF on mutations; form-urlencoded bodies
- Proxmox ``{ "data": … }`` envelope checks on 2xx
"""

from __future__ import annotations

import json
import os
import re
import time
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any
from urllib.parse import quote

import httpx

# Bundled revisions — keep in sync with app/web/contract_catalog.py
MAJOR_REVISIONS: dict[int, tuple[str, str]] = {
    6: ("6.4-15", "96cd7121e75cdb3efd58f79ca988f6b235a2f28e6f7eae276ae243f65d8a6724"),
    7: ("7.4-16", "2cf632fa6ea4939ca9cb7998ade688150db25b0684600f53ac0ca95730f1d99f"),
    8: ("8.4.5", "fce6db0a784b3a9b447895895fc6ff4b4437c2dce82e5f3db99227af217726fa"),
    9: ("9.2.3", "e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1"),
}

_PATH_RE = re.compile(r"\{([^{}]+)\}")
_FORBIDDEN = re.compile(
    r"not supported in the emulator|not implemented in the simulator|"
    r"handler pending for this contract method|is not supported in the (emulator|simulator)",
    re.I,
)
_UPID_RE = re.compile(
    r"^UPID:[A-Za-z0-9][A-Za-z0-9_-]*:"
    r"[0-9A-Fa-f]{8}:[0-9A-Fa-f]{8}:[0-9A-Fa-f]{8}:"
    r"[A-Za-z0-9_-]+:[^:]*:[^:]+:$"
)

_PATH_PARAM_EXAMPLES: dict[str, object] = {
    "node": "pve1",
    "vmid": 100,
    "storage": "local",
    "pool": "testpool",
    "userid": "root@pam",
    "tokenid": "automation",
    "realm": "pam",
    "group": "admins",
    "role": "Administrator",
    "upid": "UPID:pve1:00000001:00000001:65000001:qmstart:100:root@pam:",
    "snapname": "snap1",
    "volume": "local:100/vm-100-disk-0.qcow2",
    "disk": "scsi0",
    "iface": "net0",
    "key": "cpu",
    "digest": "00000000",
    "name": "example",
    "size": "1G",
    "filename": "vm-100-disk-0.raw",
    "certificates": "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n",
    "contact": "mailto:admin@example.com",
    "clustername": "lab",
}

_EXTRA_PATH: dict[str, object] = {
    "groupid": "admins",
    "roleid": "Administrator",
    "zone": "localnet",
    "vnet": "vnet0",
    "subnet": "10.0.0.0-24",
    "controller": "evpn1",
    "dns": "dns1",
    "ipam": "pve",
    "flag": "noout",
    "osdid": "0",
    "monid": "0",
    "id": "example",
    "cputype": "custom1",
    "pci-id-or-mapping": "0000:00:1f.0",
    "rule": "rule1",
    "sid": "vm:100",
    "pos": "0",
    "cidr": "10.0.0.0/24",
    "tokenid": "automation",
    "fabric_id": "fab1",
    "node_id": "pve1",
    "url_seq": "1",
    "route-map-id": "rm1",
    "order": "10",
    "userid": "root@pam",
    "realm": "pam",
    "name": "example",
    "plugin": "example",
    "target": "example",
}

CRITICAL_BUCKETS = frozenset(
    {
        "unimplemented_501",
        "unsupported_message",
        "server_5xx",
        "exception",
        "empty_success_body",
        "wrong_returns_envelope",
    }
)

# Path suffixes that real PVE usually runs via fork_worker → UPID string.
_WORKER_SUFFIXES = (
    "/status/start",
    "/status/stop",
    "/status/shutdown",
    "/status/reboot",
    "/status/reset",
    "/status/suspend",
    "/status/resume",
    "/clone",
    "/migrate",
    "/remote_migrate",
    "/snapshot",
    "/rollback",
    "/template",
    "/resize",
    "/move_disk",
    "/move_volume",
    "/vzdump",
    "/apt/update",
)


def contracts_root() -> Path:
    env = os.environ.get("CONTRACTS_ROOT")
    if env:
        return Path(env)
    # Runner mounts repo at /workspace; local runs use repo-relative path.
    here = Path(__file__).resolve()
    candidates = [
        Path("/workspace/contracts"),
        here.parents[3] / "contracts",  # pulumi-tests/pulumi/pvelib → repo
        Path.cwd() / "contracts",
        Path.cwd().parent / "contracts",
    ]
    for path in candidates:
        if path.is_dir():
            return path
    raise FileNotFoundError("contracts/ directory not found; set CONTRACTS_ROOT")


def load_snapshot(major: int) -> dict[str, Any]:
    version, revision = MAJOR_REVISIONS[major]
    path = contracts_root() / revision / "snapshot.json"
    data = json.loads(path.read_text(encoding="utf-8"))
    if data.get("source_version") != version:
        # Still usable; keep declared version from manifest pairing.
        pass
    return data


def _path_value(name: str) -> str:
    value = _PATH_PARAM_EXAMPLES.get(name)
    if value is None:
        value = _EXTRA_PATH.get(name, "example")
    return str(value)


def render_path(template: str) -> str:
    def replace(match: re.Match[str]) -> str:
        return quote(str(_path_value(match.group(1))), safe="@._-")

    return _PATH_RE.sub(replace, template)


def _schema_example(schema: dict[str, Any] | None, *, name: str | None = None) -> Any:
    if not schema:
        return "example"
    if schema.get("default") is not None:
        return schema["default"]
    enum = schema.get("enum") or []
    if enum:
        return enum[0]
    if name is not None:
        hinted = _PATH_PARAM_EXAMPLES.get(name)
        if hinted is not None:
            return hinted
    typ = schema.get("type")
    if typ == "array":
        items = schema.get("items")
        return [_schema_example(items)] if items else []
    if typ == "object":
        props = schema.get("properties") or {}
        return {
            key: _schema_example(defn, name=key)
            for key, defn in props.items()
            if not (isinstance(defn, dict) and defn.get("optional"))
        }
    if typ == "boolean":
        return False
    if typ == "integer":
        minimum = schema.get("minimum")
        return int(minimum) if minimum is not None else 1
    if typ == "number":
        minimum = schema.get("minimum")
        return float(minimum) if minimum is not None else 1.0
    return "example"


def body_for(method: dict[str, Any], path_template: str) -> dict[str, Any]:
    path_names = set(_PATH_RE.findall(path_template))
    payload: dict[str, Any] = {}
    for parameter in method.get("parameters") or []:
        name = parameter.get("name")
        if not name or name in path_names:
            continue
        definition = parameter.get("definition") or {}
        if definition.get("optional"):
            continue
        payload[name] = _schema_example(definition, name=name)
    return payload


def classify(status: int, text: str) -> str:
    if _FORBIDDEN.search(text or ""):
        return "unsupported_message"
    if status == 501:
        return "unimplemented_501"
    if 200 <= status < 300:
        return "success_2xx"
    if status in {401, 403}:
        return "auth_401_403"
    if status in {400, 404, 405, 409, 412, 422, 423}:
        return "client_4xx"
    if status >= 500:
        return "server_5xx"
    return f"other_{status}"


def returns_type(method: dict[str, Any]) -> str:
    returns = method.get("returns") or {}
    if isinstance(returns, dict):
        return str(returns.get("type") or "")
    return ""


def looks_like_worker(path_template: str, verb: str) -> bool:
    if verb.upper() not in {"POST", "PUT"}:
        return False
    lowered = path_template.lower()
    return any(lowered.endswith(suffix) for suffix in _WORKER_SUFFIXES)


def classify_envelope(
    *,
    status: int,
    text: str,
    method: dict[str, Any],
    path_template: str,
    verb: str,
) -> str | None:
    """Return an extra critical bucket for 2xx envelope/returns mismatches, else None."""

    if not (200 <= status < 300) or verb.upper() == "HEAD":
        return None
    returns = method.get("returns") or {}
    rtype = returns_type(method)
    try:
        payload = json.loads(text) if text else None
    except json.JSONDecodeError:
        return "empty_success_body"
    if not isinstance(payload, dict) or "data" not in payload:
        return "empty_success_body"
    data = payload.get("data")
    if rtype == "null":
        return None
    if rtype == "string":
        if data is None:
            return "wrong_returns_envelope"
        if looks_like_worker(path_template, verb):
            if not isinstance(data, str) or not _UPID_RE.fullmatch(data):
                return "wrong_returns_envelope"
            return None
        if isinstance(data, str):
            return None
        # Older PVE schemas sometimes mark list endpoints as opaque ``string``
        # (empty properties, no description). Accept non-null structured data.
        if isinstance(returns, dict) and not (
            returns.get("description") or returns.get("properties") or returns.get("enum")
        ):
            return None
        return "wrong_returns_envelope"
    if rtype in {"array", "object"} and data is None:
        return "wrong_returns_envelope"
    return None


def probe_major(
    client: httpx.Client,
    csrf: str,
    major: int,
    snapshot: dict[str, Any],
) -> dict[str, Any]:
    started = time.monotonic()
    apply = client.post("/ui/api/contract/apply", params={"major": major})
    apply.raise_for_status()
    applied = apply.json()

    by_verb: dict[str, Counter[str]] = defaultdict(Counter)
    failures: list[dict[str, Any]] = []
    method_results: list[dict[str, Any]] = []
    head_results: list[dict[str, Any]] = []
    get_paths: set[str] = set()

    methods: list[tuple[str, dict[str, Any]]] = [
        (path["path"], method)
        for path in snapshot.get("paths") or []
        for method in path.get("methods") or []
    ]
    order = {"GET": 0, "PUT": 1, "POST": 2, "DELETE": 3}
    methods.sort(key=lambda item: (order.get(str(item[1].get("verb", "")).upper(), 9), item[0]))

    for path_template, method in methods:
        verb = str(method.get("verb", "")).upper()
        url = f"/api2/json{render_path(path_template)}"
        headers = {"CSRFPreventionToken": csrf} if verb != "GET" else {}
        params = body_for(method, path_template)
        try:
            if verb == "GET":
                get_paths.add(path_template)
                response = client.get(url, headers=headers, params=params or None)
            elif verb == "PUT":
                response = client.put(url, data=params or {}, headers=headers)
            elif verb == "POST":
                response = client.post(url, data=params or {}, headers=headers)
            elif verb == "DELETE":
                # Proxmox accepts delete identifiers as form or query params.
                response = client.request("DELETE", url, data=params or {}, headers=headers)
            else:
                by_verb[verb or "UNKNOWN"]["exception"] += 1
                item = {
                    "verb": verb or "UNKNOWN",
                    "path": path_template,
                    "error": f"unsupported HTTP verb {verb!r}",
                    "bucket": "exception",
                    "ok": False,
                }
                failures.append(item)
                method_results.append(item)
                continue
        except Exception as exc:  # noqa: BLE001
            by_verb[verb]["exception"] += 1
            item = {
                "verb": verb,
                "path": path_template,
                "error": str(exc)[:200],
                "bucket": "exception",
                "ok": False,
            }
            failures.append(item)
            method_results.append(item)
            continue

        text = response.text
        bucket = classify(response.status_code, text)
        envelope = classify_envelope(
            status=response.status_code,
            text=text,
            method=method,
            path_template=path_template,
            verb=verb,
        )
        if envelope is not None:
            bucket = envelope
        by_verb[verb][bucket] += 1
        ok = bucket not in CRITICAL_BUCKETS
        item = {
            "verb": verb,
            "path": path_template,
            "status": response.status_code,
            "bucket": bucket,
            "ok": ok,
            "returns": returns_type(method),
        }
        if not ok:
            item["body"] = text[:240]
            failures.append(item)
        method_results.append(item)

    # Synthetic HEAD for every GET path (Starlette mirrors GET handlers).
    for path_template in sorted(get_paths):
        url = f"/api2/json{render_path(path_template)}"
        try:
            response = client.request("HEAD", url)
            text = response.text
            bucket = classify(response.status_code, text)
        except Exception as exc:  # noqa: BLE001
            bucket = "exception"
            text = str(exc)[:200]
            response = None
        by_verb["HEAD"][bucket] += 1
        ok = bucket not in CRITICAL_BUCKETS
        item = {
            "verb": "HEAD",
            "path": path_template,
            "status": getattr(response, "status_code", None),
            "bucket": bucket,
            "ok": ok,
            "synthetic": True,
        }
        if not ok:
            item["body"] = text[:240]
            item["error"] = text[:200]
            failures.append(item)
        head_results.append(item)

    version, _ = MAJOR_REVISIONS[major]
    declared = int(snapshot.get("method_count") or len(method_results))
    critical = len(failures)
    success_2xx = sum(c.get("success_2xx", 0) for c in by_verb.values())
    probed = len(method_results)
    empty_reasons: list[str] = []
    if not isinstance(applied, dict) or not applied:
        empty_reasons.append("apply response empty")
    elif applied.get("ok") is False:
        empty_reasons.append(f"apply not ok: {applied!r}")
    if declared <= 0:
        empty_reasons.append("declared method_count is 0")
    if probed <= 0:
        empty_reasons.append("no methods probed")
    if probed != declared:
        empty_reasons.append(
            f"incomplete coverage: probed {probed} of {declared} declared methods"
        )
    if success_2xx <= 0:
        empty_reasons.append("success_2xx count is 0")
    if empty_reasons:
        for reason in empty_reasons:
            failures.append(
                {
                    "verb": "META",
                    "path": f"major/{major}",
                    "bucket": "exception",
                    "ok": False,
                    "error": reason,
                }
            )
        critical = len(failures)

    verb_histogram = {
        verb: {
            "total": sum(counter.values()),
            "buckets": dict(counter),
        }
        for verb, counter in sorted(by_verb.items())
    }

    return {
        "major": major,
        "version": snapshot.get("source_version") or version,
        "apply": applied,
        "declared": declared,
        "probed": probed,
        "head_probed": len(head_results),
        "by_verb": {verb: dict(counter) for verb, counter in by_verb.items()},
        "verb_histogram": verb_histogram,
        "success_2xx": success_2xx,
        "client_4xx": sum(
            c.get("client_4xx", 0) + c.get("auth_401_403", 0) for c in by_verb.values()
        ),
        "failure_count": critical,
        "failures": failures,
        "ok": critical == 0,
        "time": time.monotonic() - started,
        "methods": method_results,
        "head_methods": head_results,
    }


def run_surface(
    *,
    majors: tuple[int, ...] = (6, 7, 8, 9),
    api_url: str | None = None,
) -> list[dict[str, Any]]:
    base = (api_url or os.environ.get("API_URL", "http://simulator:8006")).rstrip("/")
    user = os.environ.get("API_USER", "root@pam")
    password = os.environ.get("API_PASSWORD", "secret")
    results: list[dict[str, Any]] = []
    with httpx.Client(base_url=base, timeout=60.0) as client:
        # Prefer env credentials over hard-coded login body.
        ticket = client.post(
            "/api2/json/access/ticket",
            data={"username": user, "password": password},
        )
        ticket.raise_for_status()
        data = ticket.json()["data"]
        client.cookies.set("PVEAuthCookie", data["ticket"])
        csrf = str(data["CSRFPreventionToken"])
        for major in majors:
            snapshot = load_snapshot(major)
            results.append(probe_major(client, csrf, major, snapshot))
    return results
