#!/usr/bin/env python3
"""Build a compact vSphere REST param index from official Automation OpenAPI.

Source (default):
  https://raw.githubusercontent.com/vmware/vcf-api-specs/main/specifications/vsphere/openapi/automation/vcenter.yaml

Output:
  app/vsphere/rest/param_index.json

Keys are ``VERB /api/...`` matching the simulator catalog (OpenAPI servers already
use ``/api`` as the base). Paths that OpenAPI encodes as
``/vcenter/vm/{vm}/power?action=start`` are collapsed onto the template path with
an ``action`` query field (enum of all observed actions).

Requires PyYAML at generation time only (not a runtime dependency).
"""

from __future__ import annotations

import argparse
import json
import re
import sys
import urllib.request
from pathlib import Path
from typing import Any

ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
    sys.path.insert(0, str(ROOT))

from app.vsphere.rest.param_fields import body_fields_from_example  # noqa: E402

DEFAULT_URL = (
    "https://raw.githubusercontent.com/vmware/vcf-api-specs/main/"
    "specifications/vsphere/openapi/automation/vcenter.yaml"
)
OUT = ROOT / "app" / "vsphere" / "rest" / "param_index.json"

_PATH_QUERY = re.compile(r"^(?P<path>[^?]+)(?:\?(?P<query>.*))?$")
_ENUM_CAP = 24

# Prefer lab seed identifiers when property names match.
# Filter examples must AND-match on every profile (small / large / demo-cluster):
# web-01 (vm-101) is always POWERED_ON on host-11.
_LAB_EXAMPLES: dict[str, Any] = {
    "vm": "vm-101",
    "vms": ["vm-101"],
    "name": "web-01",
    "names": ["web-01"],
    "host": "host-11",
    "hosts": ["host-11"],
    "folder": "group-v23",
    "folders": ["group-v23"],
    "datastore": "datastore-31",
    "datastores": ["datastore-31"],
    "datacenter": "datacenter-21",
    "datacenters": ["datacenter-21"],
    "cluster": "domain-c21",
    "clusters": ["domain-c21"],
    "resource_pool": "resgroup-22",
    "resource_pools": ["resgroup-22"],
    "network": "network-41",
    "networks": ["network-41"],
    "power_states": ["POWERED_ON"],
    "guest_os": "OTHER_GUEST_64",
    "guest_OS": "OTHER_GUEST_64",
    "action": "start",
    "category_id": "urn:vmomi:InventoryServiceCategory:demo:GLOBAL",
    "tag_id": "urn:vmomi:InventoryServiceTag:demo:GLOBAL",
    "item_id": "item-demo",
    "library_id": "library-demo",
    "task": "task-1",
    "snapshot": "snapshot-1",
    "count": 2,
    "size_mib": 2048,
    "size_MiB": 2048,
    "memory_size_MiB": 2048,
    "cpu_count": 2,
}


def _load_yaml(path: Path) -> dict[str, Any]:
    try:
        import yaml
    except ImportError as exc:  # pragma: no cover
        raise SystemExit("PyYAML is required to generate the param index") from exc
    data = yaml.safe_load(path.read_text(encoding="utf-8"))
    if not isinstance(data, dict):
        raise SystemExit("OpenAPI root must be a mapping")
    return data


def _fetch(url: str, dest: Path) -> None:
    dest.parent.mkdir(parents=True, exist_ok=True)
    print(f"Fetching {url}", file=sys.stderr)
    with urllib.request.urlopen(url, timeout=120) as resp:  # noqa: S310
        dest.write_bytes(resp.read())
    print(f"Wrote {dest} ({dest.stat().st_size} bytes)", file=sys.stderr)


def _resolve_ref(spec: dict[str, Any], ref: str) -> dict[str, Any]:
    if not ref.startswith("#/"):
        return {}
    node: Any = spec
    for part in ref[2:].split("/"):
        if not isinstance(node, dict):
            return {}
        node = node.get(part)
    return node if isinstance(node, dict) else {}


def _deref(spec: dict[str, Any], schema: dict[str, Any] | None) -> dict[str, Any]:
    if not schema:
        return {}
    if "$ref" in schema:
        resolved = _resolve_ref(spec, str(schema["$ref"]))
        merged = dict(resolved)
        for key, value in schema.items():
            if key != "$ref":
                merged[key] = value
        return _deref(spec, merged) if ("$ref" in merged or "allOf" in merged) else merged
    if "allOf" in schema and isinstance(schema["allOf"], list):
        merged: dict[str, Any] = {}
        props: dict[str, Any] = {}
        required: list[str] = []
        for part in schema["allOf"]:
            if not isinstance(part, dict):
                continue
            resolved = _deref(spec, part)
            for key, value in resolved.items():
                if key == "properties" and isinstance(value, dict):
                    props.update(value)
                elif key == "required" and isinstance(value, list):
                    required.extend(str(item) for item in value)
                elif key not in {"properties", "required"}:
                    merged[key] = value
        for key, value in schema.items():
            if key == "allOf":
                continue
            if key == "properties" and isinstance(value, dict):
                props.update(value)
            elif key == "required" and isinstance(value, list):
                required.extend(str(item) for item in value)
            else:
                merged[key] = value
        if props:
            merged["properties"] = props
        if required:
            merged["required"] = list(dict.fromkeys(required))
        if "type" not in merged and props:
            merged["type"] = "object"
        return merged
    return schema


def _short_desc(text: Any) -> str | None:
    if not isinstance(text, str) or not text.strip():
        return None
    line = text.strip().split("\n", 1)[0].strip()
    if len(line) > 160:
        return line[:157] + "..."
    return line


def _example_for(
    spec: dict[str, Any],
    name: str,
    schema: dict[str, Any],
    *,
    depth: int = 0,
) -> Any:
    schema = _deref(spec, schema)
    if name in _LAB_EXAMPLES:
        return _LAB_EXAMPLES[name]
    if "example" in schema:
        return schema["example"]
    if "default" in schema:
        return schema["default"]
    enum = schema.get("enum")
    if isinstance(enum, list) and enum:
        return enum[0]
    typ = schema.get("type")
    if typ == "array":
        items = schema.get("items") if isinstance(schema.get("items"), dict) else {}
        item_ex = _example_for(spec, name.rstrip("s") or name, items, depth=depth + 1)
        return [item_ex] if item_ex is not None else []
    if typ == "object" or "properties" in schema:
        if depth >= 3:
            return {}
        props = schema.get("properties") if isinstance(schema.get("properties"), dict) else {}
        req = set(schema.get("required") or [])
        # Prefer required props; include a few well-known optional lab fields.
        keys = list(req)
        for extra in ("name", "placement", "cpu", "memory", "description", "spec", "create_spec"):
            if extra in props and extra not in keys:
                keys.append(extra)
        if not keys:
            keys = list(props.keys())[:6]
        out: dict[str, Any] = {}
        for key in keys:
            prop = props.get(key)
            if not isinstance(prop, dict):
                continue
            out[key] = _example_for(spec, key, prop, depth=depth + 1)
        return out
    if typ == "boolean":
        return False
    if typ == "integer":
        return int(schema["minimum"]) if isinstance(schema.get("minimum"), (int, float)) else 1
    if typ == "number":
        return float(schema["minimum"]) if isinstance(schema.get("minimum"), (int, float)) else 1.0
    if typ == "string" or typ is None:
        return "example"
    return None


def _field_from_schema(
    spec: dict[str, Any],
    name: str,
    schema: dict[str, Any],
    *,
    optional: bool,
    description: str | None = None,
) -> dict[str, Any]:
    schema = _deref(spec, schema)
    typ = schema.get("type")
    if typ is None and "properties" in schema:
        typ = "object"
    if typ is None and "items" in schema:
        typ = "array"
    enum = schema.get("enum") if isinstance(schema.get("enum"), list) else []
    if len(enum) > _ENUM_CAP:
        enum = enum[:_ENUM_CAP]
    example = _example_for(spec, name, schema)
    return {
        "name": name,
        "type": typ or "string",
        "optional": optional,
        "description": _short_desc(description or schema.get("description")),
        "enum": enum,
        "example": example,
    }


def _collect_params(
    spec: dict[str, Any],
    operation: dict[str, Any],
    path_item_params: list[dict[str, Any]],
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
    query: list[dict[str, Any]] = []
    path: list[dict[str, Any]] = []
    seen: set[tuple[str, str]] = set()
    for raw in list(path_item_params) + list(operation.get("parameters") or []):
        if not isinstance(raw, dict):
            continue
        param = _deref(spec, raw) if "$ref" in raw else raw
        name = str(param.get("name") or "")
        location = str(param.get("in") or "")
        if not name or location not in {"query", "path"}:
            continue
        key = (location, name)
        if key in seen:
            continue
        seen.add(key)
        schema = param.get("schema") if isinstance(param.get("schema"), dict) else {}
        field = _field_from_schema(
            spec,
            name,
            schema,
            optional=not bool(param.get("required")),
            description=param.get("description") if isinstance(param.get("description"), str) else None,
        )
        if location == "query":
            query.append(field)
        else:
            path.append(field)
    return query, path


def _body_from_operation(
    spec: dict[str, Any],
    operation: dict[str, Any],
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
    request_body = operation.get("requestBody")
    if not isinstance(request_body, dict):
        return [], {}
    request_body = _deref(spec, request_body) if "$ref" in request_body else request_body
    content = request_body.get("content") if isinstance(request_body.get("content"), dict) else {}
    media = content.get("application/json") or next(iter(content.values()), None)
    if not isinstance(media, dict):
        return [], {}
    schema = _deref(spec, media.get("schema") if isinstance(media.get("schema"), dict) else {})
    if not schema:
        return [], {}
    props = schema.get("properties") if isinstance(schema.get("properties"), dict) else {}
    if props:
        example = _example_for(spec, "", schema)
        example_dict = example if isinstance(example, dict) else {}
        # PARAM drawer leaves come from nested body_example (placement.host, …).
        return body_fields_from_example(example_dict), example_dict
    # Body is a naked $ref / non-object — still emit an example blob.
    example = _example_for(spec, "body", schema)
    example_dict = example if isinstance(example, dict) else {}
    return body_fields_from_example(example_dict), example_dict


def _normalize_path_key(raw_path: str) -> tuple[str, dict[str, str]]:
    """Return (/api/... template, query extras from path key like action=start)."""
    match = _PATH_QUERY.match(raw_path)
    if not match:
        return raw_path, {}
    path = match.group("path")
    query_raw = match.group("query") or ""
    extras: dict[str, str] = {}
    if query_raw:
        for part in query_raw.split("&"):
            if "=" in part:
                key, value = part.split("=", 1)
                extras[key] = value
            elif part:
                extras[part] = ""
    if not path.startswith("/api/"):
        path = "/api" + path if path.startswith("/") else "/api/" + path
    return path, extras


def _merge_action_field(query_fields: list[dict[str, Any]], actions: set[str]) -> None:
    if not actions:
        return
    existing = next((field for field in query_fields if field["name"] == "action"), None)
    ordered = sorted(actions)
    if existing is None:
        query_fields.insert(
            0,
            {
                "name": "action",
                "type": "string",
                "optional": False,
                "description": "Operation action query parameter",
                "enum": ordered,
                "example": ordered[0],
            },
        )
        return
    enum = list(dict.fromkeys([*list(existing.get("enum") or []), *ordered]))
    existing["enum"] = enum
    existing["optional"] = False
    if not existing.get("example"):
        existing["example"] = enum[0]


def build_index(spec: dict[str, Any]) -> dict[str, Any]:
    paths = spec.get("paths") if isinstance(spec.get("paths"), dict) else {}
    # Accumulate by VERB + template path so ?action= variants collapse.
    buckets: dict[str, dict[str, Any]] = {}

    def ensure_bucket(verb: str, template: str) -> dict[str, Any]:
        key = f"{verb} {template}"
        return buckets.setdefault(
            key,
            {
                "verb": verb,
                "path": template,
                "query_fields": [],
                "path_fields": [],
                "body_fields": [],
                "body_example": {},
                "operation_ids": [],
                "actions": set(),
            },
        )

    def merge_operation(
        *,
        verb: str,
        template: str,
        operation: dict[str, Any],
        path_params: list[dict[str, Any]],
        action: str | None = None,
    ) -> None:
        key = f"{verb} {template}"
        if action:
            existing = buckets.get(key)
            # Do not pollute CreateSpec-style POSTs with clone/register actions.
            if existing is not None and (existing["body_fields"] or existing["body_example"]):
                return
            bucket = ensure_bucket(verb, template)
            bucket["actions"].add(action)
            _query, path_fields = _collect_params(spec, operation, path_params)
            for field in path_fields:
                names = {item["name"] for item in bucket["path_fields"]}
                if field["name"] not in names:
                    bucket["path_fields"].append(field)
            op_id = operation.get("operationId")
            if isinstance(op_id, str) and op_id not in bucket["operation_ids"]:
                bucket["operation_ids"].append(op_id)
            return

        bucket = ensure_bucket(verb, template)
        query_fields, path_fields = _collect_params(spec, operation, path_params)
        body_fields, body_example = _body_from_operation(spec, operation)
        for field in query_fields:
            names = {item["name"] for item in bucket["query_fields"]}
            if field["name"] not in names:
                bucket["query_fields"].append(field)
        for field in path_fields:
            names = {item["name"] for item in bucket["path_fields"]}
            if field["name"] not in names:
                bucket["path_fields"].append(field)
        if body_fields and not bucket["body_fields"]:
            bucket["body_fields"] = body_fields
        if body_example and not bucket["body_example"]:
            bucket["body_example"] = body_example
        op_id = operation.get("operationId")
        if isinstance(op_id, str) and op_id not in bucket["operation_ids"]:
            bucket["operation_ids"].append(op_id)

    # Pass 1: concrete paths without ?query — establish CreateSpec etc.
    # Pass 2: ?action= variants — attach only onto action-style endpoints.
    ordered_paths = sorted(paths.items(), key=lambda item: ("?" in str(item[0]), str(item[0])))
    for raw_path, path_item in ordered_paths:
        if not isinstance(path_item, dict):
            continue
        template, extras = _normalize_path_key(str(raw_path))
        path_params = [p for p in (path_item.get("parameters") or []) if isinstance(p, dict)]
        for verb, operation in path_item.items():
            if verb.startswith("x-") or verb == "parameters" or not isinstance(operation, dict):
                continue
            upper = verb.upper()
            if upper not in {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}:
                continue
            merge_operation(
                verb=upper,
                template=template,
                operation=operation,
                path_params=path_params,
                action=extras.get("action") or None,
            )

    methods: dict[str, Any] = {}
    for key, bucket in sorted(buckets.items()):
        actions: set[str] = bucket.pop("actions")
        _merge_action_field(bucket["query_fields"], actions)
        entry = {
            "verb": bucket["verb"],
            "path": bucket["path"],
            "query_fields": bucket["query_fields"],
            "path_fields": bucket["path_fields"],
            "body_fields": bucket["body_fields"],
            "body_example": bucket["body_example"],
            "operation_ids": bucket["operation_ids"],
        }
        methods[key] = entry

    info = spec.get("info") if isinstance(spec.get("info"), dict) else {}
    return {
        "source": "vmware/vcf-api-specs specifications/vsphere/openapi/automation/vcenter.yaml",
        "openapi": spec.get("openapi"),
        "title": info.get("title"),
        "version": info.get("version"),
        "method_count": len(methods),
        "methods": methods,
    }


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--url", default=DEFAULT_URL, help="OpenAPI YAML URL")
    parser.add_argument(
        "--input",
        type=Path,
        help="Local OpenAPI YAML (skips download)",
    )
    parser.add_argument(
        "--cache",
        type=Path,
        default=ROOT / ".cache" / "vcenter.openapi.yaml",
        help="Download cache path",
    )
    parser.add_argument("--output", type=Path, default=OUT)
    args = parser.parse_args()

    if args.input is not None:
        source = args.input
    else:
        if not args.cache.exists():
            _fetch(args.url, args.cache)
        source = args.cache

    spec = _load_yaml(source)
    index = build_index(spec)
    args.output.parent.mkdir(parents=True, exist_ok=True)
    args.output.write_text(json.dumps(index, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
    print(
        f"Wrote {args.output} methods={index['method_count']} version={index.get('version')}",
        file=sys.stderr,
    )
    # Spot-check critical routes.
    for probe in ("GET /api/vcenter/vm", "POST /api/vcenter/vm", "POST /api/vcenter/vm/{vm}/power"):
        entry = index["methods"].get(probe)
        if not entry:
            print(f"WARN missing {probe}", file=sys.stderr)
            continue
        print(
            f"OK {probe} query={len(entry['query_fields'])} "
            f"body_fields={len(entry['body_fields'])} "
            f"body_example_keys={list(entry['body_example'])[:6]}",
            file=sys.stderr,
        )
    return 0


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