"""Full REST verb×path×majors matrix for pulumi-tests (Layer A HTTP contract).

Probes ``methods_for_major(M)`` for majors 6–9 with session auth.
Verbs: whatever is in IMPLEMENTED (GET/PUT/PATCH/POST/DELETE) plus a synthetic
HEAD for every GET path. Pass requires ``critical == 0`` and
``probed == declared`` per major and in aggregate.

100% coverage here means the HTTP contract matrix — not pulumi-vsphere resource
count (Layer B).
"""

from __future__ import annotations

import json
import os
import re
import secrets
import ssl
import urllib.error
import urllib.request
from base64 import b64encode
from collections import Counter
from typing import Any
from urllib.parse import urlencode

from app.vsphere.contracts.matrix import VERSIONS, methods_for_major
from app.vsphere.rest.coverage import CORE_IMPLEMENTED, IMPLEMENTED

_PATH_SUBS = {
    "{vm}": "vm-101",
    "{host}": "host-11",
    "{datastore}": "datastore-31",
    "{task}": "task-1",
    "{snapshot}": "snapshot-missing",
    "{category_id}": "cat-lab-1",
    "{tag_id}": "tag-lab-1",
    "{item_id}": "item-ubuntu",
    "{library_item_id}": "item-ubuntu",
    "{library_id}": "lib-local-1",
    "{folder}": "group-v23",
    "{datacenter}": "datacenter-21",
    "{cluster}": "domain-c21",
    "{resource_pool}": "resgroup-22",
    "{permission_id}": "999999",
    "{policy}": "policy-default",
    "{disk}": "2000",
    "{nic}": "4000",
    "{cdrom}": "3000",
    "{floppy}": "8000",
    "{port}": "9000",
    "{adapter}": "1000",
    "{provider}": "vsphere.local",
    "{supervisor}": "supervisor-1",
    "{namespace}": "ns-lab-1",
    "{role}": "ReadOnly",
    "{zone}": "zone-1",
    "{project}": "project-1",
    "{domain}": "lab.local",
    "{service}": "vsphere-ui",
    "{depot}": "depot-1",
    "{component}": "component-1",
    "{image}": "image-1",
    "{draft}": "draft-1",
    "{connection}": "connection-1",
    "{vpc}": "vpc-1",
    "{subnet}": "subnet-1",
    "{session_id}": "session-lab-1",
    "{download_session_id}": "session-lab-1",
    "{update_session_id}": "session-lab-1",
    "{subscription_id}": "sub-1",
    "{usage_id}": "usage-1",
    "{version}": "1",
    "{chain}": "chain-1",
    "{node}": "node-1",
    "{profile}": "profile-1",
    "{interface}": "nic0",
    "{core}": "core-1",
    "{network}": "network-41",
    "{commit}": "commit-lab-1",
}

_ACCEPT_CLIENT = {400, 401, 403, 404, 405, 409, 412, 422}

_SKIP_DELETE = frozenset(
    {
        ("DELETE", "/api/session"),
        ("DELETE", "/rest/com/vmware/cis/session"),
    }
)

# Collection / entity GETs that must be non-empty after small seed (major 9).
_INVENTORY_CRITICAL = {
    "/api/vcenter/vm",
    "/api/vcenter/host",
    "/api/vcenter/datastore",
    "/api/vcenter/network",
    "/api/vcenter/cluster",
    "/api/vcenter/datacenter",
    "/api/vcenter/folder",
    "/api/vcenter/resource-pool",
    "/api/cis/tagging/category",
    "/api/cis/tagging/tag",
    "/api/content/library",
    "/api/content/local-library",
    "/api/vcenter/network/dvs",
    "/api/vcenter/storage/policies",
    "/api/vcenter/privilege",
    "/api/vcenter/authorization/roles",
    "/api/vcenter/authorization/permissions",
    "/api/cis/tasks",
    "/api/esx/settings/clusters/{cluster}/software",
    "/api/vcenter/namespace-management/supervisors/{supervisor}/summary",
    "/api/appliance/access/ssh",
    "/api/appliance/services",
}

# GET paths that may legally return empty/null bodies.
_EMPTY_OK_GET = frozenset(
    {
        "/api/session",
        "/rest/com/vmware/cis/session",
    }
)


def _base() -> str:
    explicit = os.environ.get("VSPHERE_BASE")
    if explicit:
        return explicit.rstrip("/")
    server = os.environ.get("VSPHERE_SERVER", "localhost")
    if server.startswith("http://") or server.startswith("https://"):
        return server.rstrip("/")
    return f"https://{server}"


def _creds() -> tuple[str, str]:
    return (
        os.environ.get("VSPHERE_USER", "administrator@vsphere.local"),
        os.environ.get("VSPHERE_PASSWORD", "VMware1!"),
    )


def _ctx() -> ssl.SSLContext | None:
    if not _base().startswith("https://"):
        return None
    return ssl._create_unverified_context()  # noqa: S323


def concrete_path(path: str) -> str:
    out = path
    for key, value in _PATH_SUBS.items():
        out = out.replace(key, value)
    return re.sub(r"\{([A-Za-z0-9_]+)\}", r"probe-\1", out)


def request(
    method: str,
    path: str,
    *,
    headers: dict[str, str],
    data: bytes | None = None,
) -> tuple[int, str]:
    if "?" in path:
        base_path, query = path.split("?", 1)
        url = f"{_base()}{concrete_path(base_path)}?{query}"
    else:
        url = f"{_base()}{concrete_path(path)}"
    req = urllib.request.Request(url, data=data, method=method, headers=headers)
    try:
        with urllib.request.urlopen(req, context=_ctx(), timeout=120) as resp:  # noqa: S310
            body = resp.read().decode("utf-8", errors="replace")
            return int(resp.status), body
    except urllib.error.HTTPError as error:
        body = error.read().decode("utf-8", errors="replace")
        return int(error.code), body


def login() -> str:
    user, password = _creds()
    basic = b64encode(f"{user}:{password}".encode()).decode()
    code, body = request("POST", "/api/session", headers={"Authorization": f"Basic {basic}"})
    if code not in {200, 201}:
        raise RuntimeError(f"session failed: {code} {body[:200]}")
    return json.loads(body)


def declared_routes(major: int) -> list[tuple[str, str]]:
    """Registry routes for a major, plus synthetic HEAD for each GET."""

    methods = methods_for_major(major)
    base = [(verb, path) for (verb, path) in sorted(methods) if (verb, path) not in _SKIP_DELETE]
    heads = [("HEAD", path) for verb, path in base if verb == "GET"]
    # Keep HEAD adjacent after its GET in verb order during probe via sort key.
    return base + heads


def _payload_for(verb: str, path: str) -> tuple[str, bytes | None]:
    if verb not in {"POST", "PUT", "PATCH"}:
        return path, None

    if path.endswith("/power") and verb == "POST":
        if "/guest/power" in path:
            return f"{path}?action=reboot", b"{}"
        return f"{path}?action=start", b"{}"

    if path.endswith("/maintenance") and verb == "POST":
        return f"{path}?action=enter", b"{}"

    if path.endswith("/folder/{folder}") and verb == "POST":
        # Do not rename seed folder MOIDs (breaks /Datacenter/vm/... inventory paths).
        return (
            "/api/vcenter/folder/folder-missing-matrix?action=rename",
            json.dumps({"name": "folder-renamed-probe"}).encode(),
        )

    if path == "/api/cis/tasks" and verb == "POST":
        return f"{path}?action=list", json.dumps({"filter_spec": {}}).encode()

    suffix = secrets.token_hex(4)
    bodies: dict[str, dict[str, Any]] = {
        "/api/vcenter/vm": {
            "name": f"matrix-probe-vm-{suffix}",
            "placement": {"folder": "group-v23", "host": "host-11", "datastore": "datastore-31"},
            "cpu_count": 1,
            "memory_size_MiB": 512,
        },
        # Missing parent → 404 (client_4xx). Avoid creating extra Datacenter/Cluster
        # trees that leave a second ResourcePool named "Resources" for govmomi.
        "/api/vcenter/datacenter": {"name": f"probe-dc-{suffix}", "folder": "folder-missing-matrix"},
        "/api/vcenter/cluster": {"name": f"probe-cluster-{suffix}", "folder": "folder-missing-matrix"},
        "/api/vcenter/folder": {"name": f"probe-folder-{suffix}", "parent": "group-v23"},
        "/api/vcenter/resource-pool": {
            "name": f"probe-rp-{suffix}",
            "parent": "resgroup-missing-matrix",
        },
        "/api/vcenter/network/dvs": {"name": f"probe-dvs-{suffix}"},
        "/api/vcenter/network/dvpg": {
            "name": f"probe-dvpg-{suffix}",
            "dvs": "dvs-51",
            "vlan_id": 10,
        },
        "/api/cis/tagging/category": {
            "create_spec": {
                "name": f"probe-cat-{suffix}",
                "description": "probe",
                "cardinality": "MULTIPLE",
                "associable_types": [],
            }
        },
        "/api/cis/tagging/tag": {
            "create_spec": {
                "name": f"probe-tag-{suffix}",
                "category_id": "missing-category",
                "description": "x",
            }
        },
        "/api/cis/tagging/tag-association": {
            "action": "list-attached-tags",
            "tag_id": "x",
            "object_id": {"type": "VirtualMachine", "id": "vm-101"},
        },
        "/api/content/local-library": {"create_spec": {"name": f"probe-lib-{suffix}"}},
        "/api/content/library/item": {
            "create_spec": {
                "library_id": "lib-missing",
                "name": f"probe-item-{suffix}",
                "type": "ovf",
            }
        },
        "/api/vcenter/ovf/library-item/{item_id}": {
            "deployment_spec": {"name": f"ovf-probe-{suffix}"},
            "target": {"folder": "group-v23", "host": "host-11", "datastore": "datastore-31"},
        },
        "/api/vcenter/authorization/permissions": {
            "principal": "readonly@vsphere.local",
            "role": "ReadOnly",
            "entity": "datacenter-21",
        },
        "/api/vcenter/datastore/{datastore}/files": {
            "path": f"/probe-{suffix}.txt",
            "size": 1,
            "type": "FILE",
        },
        "/api/vcenter/vm/{vm}/hardware/cpu": {"count": 2},
        "/api/vcenter/vm/{vm}/hardware/memory": {"size_MiB": 1024},
        "/api/vcenter/vm/{vm}/hardware/disk": {"type": "SCSI", "new_vmdk": {"capacity": 1024}},
        "/api/vcenter/vm/{vm}/hardware/ethernet": {
            "type": "VMXNET3",
            "backing": {"type": "STANDARD_PORTGROUP", "network": "network-41"},
        },
        "/api/vcenter/vm/{vm}/snapshots": {"name": f"probe-snap-{suffix}"},
        "/api/vcenter/vm/{vm}/snapshots/{snapshot}": {"action": "revert"},
        "/api/vcenter/vm/{vm}/clone": {
            "name": f"probe-clone-{suffix}",
            "placement": {"folder": "group-v23", "host": "host-11"},
        },
        "/api/vcenter/vm/{vm}/relocate": {"placement": {"host": "host-12"}},
        "/api/vcenter/vm/{vm}/tools": {"action": "upgrade"},
        "/api/vcenter/vm/{vm}/console/tickets": {"type": "WEBMKS"},
        "/api/vcenter/vm/{vm}/guest/customization": {"name": {"name": f"guest-probe-{suffix}"}},
        "/api/vcenter/vm/{vm}": {"action": "unregister"},
    }
    body = bodies.get(path, {})
    return path, json.dumps(body).encode()


def apply_major(major: int, headers: dict[str, str]) -> dict[str, Any]:
    params = urlencode({"major": major})
    code, body = request(
        "POST",
        f"/ui/api/contract/apply?{params}",
        headers=headers,
    )
    if code >= 400:
        raise RuntimeError(f"contract apply major={major} failed: {code} {body[:200]}")
    return json.loads(body)


def _classify(verb: str, path: str) -> str:
    lookup = verb if verb != "HEAD" else "GET"
    if (lookup, path) in CORE_IMPLEMENTED:
        return "deep"
    status = IMPLEMENTED.get((lookup, path))
    if status == "stub":
        return "stub"
    return "deep" if status == "implemented" else "unknown"


def _is_empty_payload(body: str) -> bool:
    if not body or not body.strip():
        return True
    try:
        parsed = json.loads(body)
    except json.JSONDecodeError:
        return False
    return parsed in ([], {}, None, "")


def _record_result(
    *,
    major: int,
    verb: str,
    path: str,
    code: int,
    body: str,
    buckets: Counter[str],
    failures: list[dict[str, Any]],
    deep_stub: Counter[str],
) -> None:
    kind = _classify(verb, path)
    deep_stub[kind] += 1

    def _fail(expected: str, *, bucket: str) -> None:
        buckets[bucket] += 1
        failures.append(
            {
                "major": major,
                "verb": verb,
                "path": path,
                "kind": kind,
                "status": code,
                "critical": True,
                "body": body[:200],
                "expected": expected,
            }
        )

    if 200 <= code < 300:
        buckets["success_2xx"] += 1
        if kind == "stub":
            buckets["stub_ok"] += 1
        # HEAD bodies are always empty by design.
        if verb == "HEAD":
            return
        if major == 9 and verb == "GET" and path not in _EMPTY_OK_GET:
            if '"stub": true' in body or '"stub":true' in body:
                if path in _INVENTORY_CRITICAL or kind == "deep":
                    _fail("non-stub JSON", bucket="stub_marker")
            elif path in _INVENTORY_CRITICAL and _is_empty_payload(body):
                _fail("non-empty seeded data", bucket="empty_inventory")
        return

    # Synthetic HEAD must be served (middleware); 405 is critical for HEAD only.
    if verb == "HEAD" and code == 405:
        _fail("HEAD supported via GET route", bucket="head_405")
        return

    if code in _ACCEPT_CLIENT:
        buckets["client_4xx"] += 1
        return

    if code == 501:
        _fail("no 501 on registered route", bucket="unexpected_501")
        return

    if code >= 500:
        _fail("no 5xx", bucket="server_5xx")
        return

    _fail(f"unexpected status {code}", bucket=f"other_{code}")


def probe_major(major: int, session: str) -> dict[str, Any]:
    headers = {
        "vmware-api-session-id": session,
        "Content-Type": "application/json",
        "Accept": "application/json",
    }
    applied = apply_major(major, headers)
    routes = declared_routes(major)
    declared = len(routes)

    verb_order = {"GET": 0, "HEAD": 1, "PUT": 2, "PATCH": 3, "POST": 4, "DELETE": 5}
    routes = sorted(routes, key=lambda item: (verb_order.get(item[0], 9), item[1]))

    buckets: Counter[str] = Counter()
    deep_stub: Counter[str] = Counter()
    failures: list[dict[str, Any]] = []
    probed = 0
    by_verb: Counter[str] = Counter()

    for verb, path in routes:
        by_verb[verb] += 1
        if verb == "DELETE" and path in {
            "/api/vcenter/datacenter/{datacenter}",
            "/api/vcenter/cluster/{cluster}",
            "/api/vcenter/folder/{folder}",
            "/api/vcenter/resource-pool/{resource_pool}",
            "/api/vcenter/vm/{vm}",
            "/api/content/local-library/{library_id}",
            "/api/content/library/item/{library_item_id}",
        }:
            # Avoid destroying seed MOIDs — probe missing ids (expect 404).
            url_path = path
            for token, missing in (
                ("{vm}", "vm-missing-matrix"),
                ("{datacenter}", "dc-missing"),
                ("{cluster}", "cluster-missing"),
                ("{folder}", "folder-missing"),
                ("{resource_pool}", "rp-missing"),
                ("{library_id}", "lib-missing-matrix"),
                ("{library_item_id}", "item-missing-matrix"),
            ):
                url_path = url_path.replace(token, missing)
            code, body = request(verb, url_path, headers=headers)
        elif verb == "HEAD":
            url_path = path
            if path == "/api/content/library/item":
                url_path = f"{path}?library_id=lib-local-1"
            code, body = request("HEAD", url_path, headers=headers)
        else:
            url_path, data = _payload_for(verb, path)
            if verb == "GET" and path == "/api/content/library/item":
                url_path = f"{url_path}?library_id=lib-local-1"
            code, body = request(verb, url_path, headers=headers, data=data)

        probed += 1
        _record_result(
            major=major,
            verb=verb,
            path=path,
            code=code,
            body=body,
            buckets=buckets,
            failures=failures,
            deep_stub=deep_stub,
        )

    critical = sum(1 for f in failures if f.get("critical"))
    coverage_ok = probed == declared and critical == 0
    return {
        "major": major,
        "version": applied.get("runtime_version"),
        "declared": declared,
        "method_count": len(methods_for_major(major)),
        "by_verb": dict(by_verb),
        "probed": probed,
        "probed_eq_declared": probed == declared,
        "buckets": dict(buckets),
        "deep_vs_stub": dict(deep_stub),
        "failures": failures,
        "failed": len(failures),
        "critical": critical,
        "coverage_line": f"{probed - critical}/{declared}",
        "ok": coverage_ok,
    }


def run_rest_matrix(*, majors: list[int] | None = None) -> dict[str, Any]:
    """Probe IMPLEMENTED × majors (+ HEAD). Returns summary for suite JSON/HTML."""

    if majors is None:
        majors = [6, 7, 8, 9]
    for major in majors:
        if major not in VERSIONS:
            raise ValueError(f"unknown major {major}")

    session = login()
    reports: list[dict[str, Any]] = []
    all_failures: list[dict[str, Any]] = []
    verb_totals: Counter[str] = Counter()
    declared_total = 0
    probed_total = 0
    critical_total = 0

    for major in majors:
        report = probe_major(major, session)
        reports.append(report)
        all_failures.extend(report["failures"])
        declared_total += int(report["declared"])
        probed_total += int(report["probed"])
        critical_total += int(report["critical"])
        for verb, count in report["by_verb"].items():
            verb_totals[verb] += count
        session = login()

    apply_major(9, {"vmware-api-session-id": session, "Content-Type": "application/json"})

    coverage_line = f"{probed_total - critical_total}/{declared_total}"
    ok = critical_total == 0 and probed_total == declared_total and all(r["ok"] for r in reports)
    return {
        "base": _base(),
        "majors": reports,
        "by_verb": dict(verb_totals),
        "declared": declared_total,
        "probed": probed_total,
        "total": probed_total,
        "critical": critical_total,
        "failed": critical_total,
        "failures": all_failures[:120],
        "coverage_line": coverage_line,
        "probed_eq_declared": probed_total == declared_total,
        "ok": ok,
    }
