#!/usr/bin/env python3
"""Probe every registered vSphere REST method for majors 6–9 (GET/POST/PATCH/PUT/DELETE).

Acceptable statuses: 2xx, 400/404/405/409/422 (validation / missing id).
Fail on: 5xx, unexpected exceptions, empty inventory on seeded GETs.
Lab policy: catalog floors are browse-only — runtime never expects HTTP 501.
"""

from __future__ import annotations

import argparse
import json
import os
import secrets
import ssl
import sys
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, catalog_entries_for_major, methods_for_major
from app.vsphere.rest.coverage import IMPLEMENTED

BASE = os.getenv("VSPHERE_BASE", "https://localhost")
USER = os.getenv("VSPHERE_USER", "administrator@vsphere.local")
PASSWORD = os.getenv("VSPHERE_PASSWORD", "VMware1!")

_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_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}


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


def _concrete(path: str) -> str:
    import re

    out = path
    for key, value in _PATH_SUBS.items():
        out = out.replace(key, value)
    # Any remaining {param} tokens from the Broadcom universe.
    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]:
    url = f"{BASE}{_concrete(path)}"
    req = urllib.request.Request(url, data=data, method=method, headers=headers)
    try:
        with urllib.request.urlopen(req, context=_ctx()) 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:
    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 SystemExit(f"session failed: {code} {body[:200]}")
    return json.loads(body)


def _payload_for(verb: str, path: str) -> tuple[str, bytes | None]:
    """Return (url_suffix_or_path, body_bytes). Path may gain query string."""

    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":
        return f"{path}?action=rename", json.dumps({"name": "folder-renamed-probe"}).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,
        },
        "/api/vcenter/datacenter": {"name": f"probe-dc-{suffix}"},
        "/api/vcenter/cluster": {"name": f"probe-cluster-{suffix}"},
        "/api/vcenter/folder": {"name": f"probe-folder-{suffix}", "parent": "group-v23"},
        "/api/vcenter/resource-pool": {"name": f"probe-rp-{suffix}", "parent": "resgroup-22"},
        "/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 SystemExit(f"contract apply major={major} failed: {code} {body[:200]}")
    return json.loads(body)


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)
    active = methods_for_major(major)
    verb_order = {"GET": 0, "PUT": 1, "PATCH": 2, "POST": 3, "DELETE": 4}
    entries = sorted(
        catalog_entries_for_major(major),
        key=lambda e: (verb_order.get(e["verb"], 9), e["path"]),
    )

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

    for entry in entries:
        verb = entry["verb"]
        path = entry["path"]
        # Don't tear down the probe session mid-run.
        if verb == "DELETE" and path in {"/api/session", "/rest/com/vmware/cis/session"}:
            continue
        # Don't destroy seeded datacenter/cluster/folder parents.
        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}",
        }:
            # Still hit the route, but against missing id → expect 4xx.
            if path.endswith("{vm}"):
                url_path = path.replace("{vm}", "vm-missing-matrix")
            elif path.endswith("{datacenter}"):
                url_path = path.replace("{datacenter}", "dc-missing")
            elif path.endswith("{cluster}"):
                url_path = path.replace("{cluster}", "cluster-missing")
            elif path.endswith("{folder}"):
                url_path = path.replace("{folder}", "folder-missing")
            else:
                url_path = path.replace("{resource_pool}", "rp-missing")
            code, body = _request(verb, 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
        if 200 <= code < 300:
            buckets["success_2xx"] += 1
            # Major 9 must return real seeded payloads, not synthetic stub markers.
            if major == 9 and verb == "GET" and body:
                if '"stub": true' in body or '"stub":true' in body:
                    buckets["stub_marker"] += 1
                    failures.append(
                        {
                            "major": major,
                            "verb": verb,
                            "path": path,
                            "status": code,
                            "body": body[:200],
                            "expected": "non-stub JSON from DB/inventory",
                        }
                    )
                elif path in {
                    "/api/vcenter/vm",
                    "/api/vcenter/host",
                    "/api/vcenter/datastore",
                    "/api/vcenter/network",
                    "/api/vcenter/cluster",
                    "/api/cis/tagging/category",
                    "/api/content/library",
                    "/api/esx/settings/clusters/{cluster}/software",
                    "/api/vcenter/namespace-management/supervisors/{supervisor}/summary",
                    "/api/appliance/access/ssh",
                    "/api/appliance/services",
                }:
                    try:
                        parsed = json.loads(body)
                    except json.JSONDecodeError:
                        parsed = None
                    empty = parsed in ([], {}, None) or parsed == ""
                    if empty or (isinstance(parsed, dict) and parsed == {}):
                        buckets["empty_inventory"] += 1
                        failures.append(
                            {
                                "major": major,
                                "verb": verb,
                                "path": path,
                                "status": code,
                                "body": body[:200],
                                "expected": "non-empty seeded data",
                            }
                        )
        elif code in _ACCEPT_CLIENT:
            buckets["client_4xx"] += 1
        elif code == 501:
            buckets["unexpected_501"] += 1
            failures.append(
                {"major": major, "verb": verb, "path": path, "status": code, "body": body[:200]}
            )
        elif code >= 500:
            buckets["server_5xx"] += 1
            failures.append(
                {"major": major, "verb": verb, "path": path, "status": code, "body": body[:200]}
            )
        else:
            buckets[f"other_{code}"] += 1
            failures.append(
                {"major": major, "verb": verb, "path": path, "status": code, "body": body[:200]}
            )

    # Paths above this major's catalog floor still must serve real data (no 501).
    above_floor = 0
    for (verb, path), _status in sorted(IMPLEMENTED.items()):
        if (verb, path) in active:
            continue
        if verb == "DELETE" and path in {"/api/session", "/rest/com/vmware/cis/session"}:
            continue
        url_path, data = _payload_for(verb, path)
        code, body = _request(verb, url_path, headers=headers, data=data)
        above_floor += 1
        probed += 1
        if code == 501:
            buckets["unexpected_501"] += 1
            failures.append(
                {
                    "major": major,
                    "verb": verb,
                    "path": path,
                    "status": code,
                    "body": body[:200],
                    "expected": "2xx/4xx (version gate disabled)",
                }
            )
        elif 200 <= code < 300:
            buckets["success_2xx"] += 1
        elif code in _ACCEPT_CLIENT:
            buckets["client_4xx"] += 1
        elif code >= 500:
            buckets["server_5xx"] += 1
            failures.append(
                {"major": major, "verb": verb, "path": path, "status": code, "body": body[:200]}
            )
        else:
            buckets[f"other_{code}"] += 1
            failures.append(
                {"major": major, "verb": verb, "path": path, "status": code, "body": body[:200]}
            )

    by_verb = Counter(e["verb"] for e in entries)
    return {
        "major": major,
        "version": applied.get("runtime_version"),
        "method_count": len(entries),
        "by_verb": dict(by_verb),
        "probed": probed,
        "above_floor_checked": above_floor,
        "buckets": dict(buckets),
        "failures": failures,
    }


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--majors", default="6,7,8,9", help="Comma-separated majors")
    args = parser.parse_args()
    majors = [int(x) for x in args.majors.split(",") if x.strip()]
    for major in majors:
        if major not in VERSIONS:
            raise SystemExit(f"unknown major {major}")

    session = _login()
    reports = []
    all_failures: list[dict[str, Any]] = []
    for major in majors:
        report = probe_major(major, session)
        reports.append(report)
        all_failures.extend(report["failures"])
        # Refresh session between majors (logout delete skipped during probe).
        session = _login()

    # Restore latest floor for the lab UI.
    _apply_major(9, {"vmware-api-session-id": session, "Content-Type": "application/json"})

    summary = {
        "base": BASE,
        "majors": reports,
        "total_failures": len(all_failures),
        "failures": all_failures[:80],
    }
    print(json.dumps(summary, indent=2))
    return 1 if all_failures else 0


if __name__ == "__main__":
    raise SystemExit(main())
