#!/usr/bin/env python3
"""
Check compatibility between django-paradedb's api.json and a released pg_search schema.

The schema is downloaded from the corresponding ParadeDB GitHub release and
checked in both directions:

  Forward:  every symbol in api.json is present in the schema (detects removals/renames).
  Reverse:  every pdb.* symbol in the schema is either in api.json or in apiignore.json
            (surfaces new paradedb APIs that haven't been wrapped yet).

Example:
    uv run --no-sync python scripts/check_schema_compat.py 0.22.0

The ignore list is read automatically from apiignore.json (repo root) if it exists.
"""

import argparse
import json
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path

_ROOT_DIR = Path(__file__).resolve().parent.parent
_API_FILE = _ROOT_DIR / "api.json"
_IGNORE_FILE = _ROOT_DIR / "apiignore.json"
_SCHEMA_FILE_NAME = "pg_search.schema.sql"


def normalize(sql: str) -> str:
    """Strip double-quotes around identifiers so pdb."score" matches pdb.score."""
    return re.sub(r'"([^"]+)"', r"\1", sql)


def extract_from_api(path: Path) -> dict:
    """Read api.json and return {functions: [...], operators: [...], types: [...]}."""
    data = json.loads(path.read_text())
    return {
        "functions": sorted(set(data["functions"].values())),
        "operators": sorted(set(data["operators"].values())),
        "types": sorted(set(data["types"].values())),
    }


def scan_schema_symbols(schema: str) -> dict:
    """Extract all pdb.* functions/aggregates/types and all operators from the schema."""
    functions = sorted(
        {
            m.lower()
            for m in re.findall(
                r"(?:FUNCTION|AGGREGATE)\s+(pdb\.\w+)\s*\(", schema, re.IGNORECASE
            )
        }
    )
    types = sorted(
        {m.lower() for m in re.findall(r"TYPE\s+(pdb\.\w+)\b", schema, re.IGNORECASE)}
    )
    operators = sorted(
        set(re.findall(r"OPERATOR\s+(?:\w+\.)?([^\s(]+)\s*\(", schema, re.IGNORECASE))
    )
    return {"functions": functions, "operators": operators, "types": types}


def check_function(schema: str, qualified_name: str) -> bool:
    dot = qualified_name.rfind(".")
    if dot == -1:
        name_pattern = re.escape(qualified_name)
        schema_pattern = r"\S+\."
    else:
        name_pattern = re.escape(qualified_name[dot + 1 :])
        schema_pattern = re.escape(qualified_name[: dot + 1])
    pattern = rf"(?:FUNCTION|AGGREGATE)\s+{schema_pattern}{name_pattern}\s*\("
    return bool(re.search(pattern, schema, re.IGNORECASE))


def check_operator(schema: str, symbol: str) -> bool:
    pattern = rf"OPERATOR\s+(?:\w+\.)?{re.escape(symbol)}\s*\("
    return bool(re.search(pattern, schema, re.IGNORECASE))


def check_type(schema: str, qualified_name: str) -> bool:
    dot = qualified_name.rfind(".")
    if dot == -1:
        pattern = rf"TYPE\s+\S*{re.escape(qualified_name)}\b"
    else:
        pattern = rf"TYPE\s+{re.escape(qualified_name)}\b"
    return bool(re.search(pattern, schema, re.IGNORECASE))


def normalize_ignored_symbols(ignored: dict, kind: str) -> set[str]:
    """Return ignored symbols for a kind from list- or grouped-dict layouts."""
    raw_value = ignored.get(kind, [])
    if isinstance(raw_value, list):
        return set(raw_value)
    if isinstance(raw_value, dict):
        normalized: set[str] = set()
        for group_name, symbols in raw_value.items():
            if not isinstance(group_name, str):
                raise ValueError(f"Ignore group key for {kind!r} must be a string.")
            if not isinstance(symbols, list):
                raise ValueError(
                    f"Ignore group {group_name!r} for {kind!r} must be a list."
                )
            normalized.update(symbols)
        return normalized
    raise ValueError(f"apiignore section {kind!r} must be a list or object.")


def normalize_version(version: str) -> str:
    return version.removeprefix("v")


def parse_args(argv: list[str]) -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description=(
            "Download pg_search.schema.sql for a ParadeDB release and check it "
            "against this repo's api.json."
        )
    )
    parser.add_argument("version", help="ParadeDB version to check, for example 0.22.0")
    return parser.parse_args(argv)


def download_schema(version: str, destination_dir: Path) -> Path:
    if shutil.which("gh") is None:
        print(
            "❌ GitHub CLI (gh) is required to download pg_search.schema.sql.",
            file=sys.stderr,
        )
        raise SystemExit(1)

    normalized_version = normalize_version(version)
    schema_path = destination_dir / _SCHEMA_FILE_NAME

    try:
        subprocess.run(
            [
                "gh",
                "release",
                "download",
                f"v{normalized_version}",
                "--repo",
                "paradedb/paradedb",
                "--pattern",
                _SCHEMA_FILE_NAME,
            ],
            cwd=destination_dir,
            check=True,
        )
    except subprocess.CalledProcessError as exc:
        print(
            f"❌ Failed to download {_SCHEMA_FILE_NAME} for ParadeDB v{normalized_version}.",
            file=sys.stderr,
        )
        print(
            "Verify that the release exists and that gh has access to GitHub.",
            file=sys.stderr,
        )
        raise SystemExit(exc.returncode) from exc

    if not schema_path.exists():
        print(
            f"❌ Download completed but {_SCHEMA_FILE_NAME} was not found.",
            file=sys.stderr,
        )
        raise SystemExit(1)

    return schema_path


def run_checks(schema_path: Path, api_path: Path) -> int:
    """Run forward and reverse compatibility checks."""

    if not schema_path.exists():
        print(f"❌ Schema file not found: {schema_path}", file=sys.stderr)
        return 1
    if not api_path.exists():
        print(f"❌ api.json not found: {api_path}", file=sys.stderr)
        return 1

    schema = normalize(schema_path.read_text())
    deps = extract_from_api(api_path)
    ignored = json.loads(_IGNORE_FILE.read_text()) if _IGNORE_FILE.exists() else {}

    rc = 0

    # ------------------------------------------------------------------
    # Forward check: every symbol in api.json must exist in the schema.
    # ------------------------------------------------------------------
    missing: list[tuple[str, str]] = []
    for fn in deps.get("functions", []):
        if not check_function(schema, fn):
            missing.append(("function", fn))
    for op in deps.get("operators", []):
        if not check_operator(schema, op):
            missing.append(("operator", op))
    for typ in deps.get("types", []):
        if not check_type(schema, typ):
            missing.append(("type", typ))

    total_api = sum(len(v) for v in deps.values() if isinstance(v, list))
    if missing:
        print(
            f"❌ Forward check: {len(missing)}/{total_api} api.json symbols missing from schema:"
        )
        for kind, name in missing:
            print(f"   {kind}: {name}")
        print(
            "\nThese symbols were removed or renamed in this version of pg_search.\n"
            "Update django-paradedb to handle the API change, then update api.json."
        )
        rc = 1
    else:
        print(f"✅ Forward check: all {total_api} api.json symbols present in schema.")

    # ------------------------------------------------------------------
    # Reverse check: every pdb.* symbol in the schema must be in api.json
    # or explicitly ignored in apiignore.json.
    # ------------------------------------------------------------------
    schema_symbols = scan_schema_symbols(schema)
    uncovered: list[tuple[str, str]] = []
    for kind in ("functions", "operators", "types"):
        api_set = set(deps.get(kind, []))
        ignore_set = normalize_ignored_symbols(ignored, kind)
        for sym in schema_symbols.get(kind, []):
            if sym not in api_set and sym not in ignore_set:
                uncovered.append((kind, sym))

    total_schema = sum(len(v) for v in schema_symbols.values())
    if uncovered:
        print(
            f"\n⚠️  Reverse check: {len(uncovered)} schema symbols not covered by api.json:"
        )
        for kind, name in uncovered:
            print(f"   {kind}: {name}")
        print(
            "\nThese are paradedb APIs not yet wrapped by django-paradedb.\n"
            "Either add them to api.json or add them to apiignore.json."
        )
        rc = 1
    else:
        print(f"✅ Reverse check: all {total_schema} schema symbols accounted for.")

    return rc


def main(argv: list[str] | None = None) -> int:
    args = parse_args(sys.argv[1:] if argv is None else argv)

    with tempfile.TemporaryDirectory(prefix="schema-compat-") as tmp_dir:
        schema_path = download_schema(args.version, Path(tmp_dir))
        return run_checks(schema_path, _API_FILE)


if __name__ == "__main__":
    sys.exit(main())
