#!/usr/bin/env python3
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#

"""Generate the removal history (``scripts/deprecations/removals.json``).

Records symbols that were deprecated in a prior release and have since been
removed, so downstream tooling — the pipecat-context-hub ``check_deprecation``
query, IDE plugins, docs — can still answer "``X`` was removed in ``V``, use
``Y``" after the symbol (and its ``.. deprecated::`` marker) is gone from the
source. ``deprecations.json`` stays a pure snapshot of the current tree; this is
its additive, cumulative companion.

Removals are detected by diffing the **previous released tag's**
``deprecations.json`` against the one in the current working tree: any subject
that was present then and is gone now was removed in the release being prepared.
Run during release-prep, **before the tag is cut**, with the release version::

    uv run python scripts/deprecations/generate_removals.py --version 2.0.0

The file is cumulative — prior entries are never rewritten — and fully
reconstructable from the tag history, so it is never primary data. The ``--version``
value is the release being prepared (the workflow supplies it); the previous tag
is discovered automatically.
"""

from __future__ import annotations

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

REPO_ROOT = Path(__file__).resolve().parents[2]
REGISTRY_PATH = Path(__file__).resolve().parent / "deprecations.json"
REMOVALS_PATH = Path(__file__).resolve().parent / "removals.json"
# Path used with `git show <tag>:<path>` — must be repo-relative and POSIX-style.
REGISTRY_REL = "scripts/deprecations/deprecations.json"
SCHEMA_VERSION = 1

_VERSION_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$")


def _version_key(tag: str) -> tuple[int, int, int] | None:
    """Sort key for a ``vX.Y.Z`` tag, or ``None`` if it isn't a release tag."""
    match = _VERSION_RE.match(tag)
    return tuple(int(g) for g in match.groups()) if match else None  # type: ignore[return-value]


def subjects_of(registry: dict) -> dict[str, dict]:
    """Map ``subject`` → record for a ``deprecations.json`` document."""
    return {r["subject"]: r for r in registry.get("deprecations", []) if r.get("subject")}


def compute_removals(
    prev_registry: dict | None,
    current_registry: dict,
    version: str,
    existing: list[dict],
) -> list[dict]:
    """Return the updated, cumulative list of removal records.

    A subject present in ``prev_registry`` and absent from ``current_registry`` was
    removed in ``version``. Its fields are carried forward from the previous
    deprecation record (its ``location`` is intentionally dropped — it points at
    source that no longer exists). Already-recorded subjects are left untouched, so
    repeated runs are idempotent.
    """
    prev = subjects_of(prev_registry) if prev_registry else {}
    current = set(subjects_of(current_registry))
    known = {r["subject"] for r in existing}

    removals = list(existing)
    for subject, rec in prev.items():
        if subject in current or subject in known:
            continue
        removals.append(
            {
                "subject": subject,
                "module": rec.get("module"),
                "kind": rec.get("kind"),
                "deprecated_in": rec.get("deprecated_in"),
                "removed_in": version,
                "announced_removed_in": rec.get("removed_in"),
                "relation": rec.get("relation"),
                "replacement": rec.get("replacement"),
                "message": rec.get("message"),
            }
        )
    # Group by the release a symbol was removed in, then by name — deterministic
    # output for clean diffs, regardless of dict iteration order.
    removals.sort(key=lambda r: (r.get("removed_in") or "", r["subject"]))
    return removals


def build_document(removals: list[dict]) -> dict:
    """Wrap removal records in the registry document envelope."""
    return {
        "_comment": (
            "AUTO-GENERATED by scripts/deprecations/generate_removals.py at release-prep. "
            "Cumulative and reconstructable from tag history. DO NOT EDIT BY HAND."
        ),
        "schema_version": SCHEMA_VERSION,
        "removals": removals,
    }


def render(document: dict) -> str:
    """Deterministic JSON text (stable across runs for clean git diffs)."""
    return json.dumps(document, indent=2, sort_keys=False, ensure_ascii=False) + "\n"


def _git(*args: str) -> subprocess.CompletedProcess:
    return subprocess.run(["git", *args], cwd=REPO_ROOT, capture_output=True, text=True)


def previous_release_tag() -> str | None:
    """The highest ``vX.Y.Z`` tag whose tree carries ``deprecations.json``.

    At release-prep the version being released is not tagged yet, so the highest
    existing release tag is the previous release. Tags without the registry
    (pre-#4726) are skipped, which is what makes the first registry-bearing
    release an empty baseline.
    """
    listed = _git("tag", "--list", "v*")
    best: str | None = None
    best_key: tuple[int, int, int] | None = None
    for tag in listed.stdout.split():
        key = _version_key(tag)
        if key is None:
            continue
        if _git("cat-file", "-e", f"{tag}:{REGISTRY_REL}").returncode != 0:
            continue
        if best_key is None or key > best_key:
            best, best_key = tag, key
    return best


def registry_at_tag(tag: str) -> dict | None:
    """Load ``deprecations.json`` as it stood at ``tag``, or ``None`` if absent."""
    proc = _git("show", f"{tag}:{REGISTRY_REL}")
    if proc.returncode != 0:
        return None
    return json.loads(proc.stdout)


def load_existing_removals() -> list[dict]:
    """Existing removal records, or ``[]`` if the file doesn't exist yet."""
    if not REMOVALS_PATH.exists():
        return []
    return json.loads(REMOVALS_PATH.read_text(encoding="utf-8")).get("removals", [])


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "--version",
        required=True,
        help="Release version being prepared (e.g. 2.0.0). Stamped as removed_in.",
    )
    args = parser.parse_args()

    version = args.version.lstrip("v")
    if not _VERSION_RE.match(version):
        print(f"--version must be X.Y.Z, got {args.version!r}", file=sys.stderr)
        return 1

    if not REGISTRY_PATH.exists():
        print(
            f"{REGISTRY_PATH.name} not found — run generate.py first.",
            file=sys.stderr,
        )
        return 1
    current_registry = json.loads(REGISTRY_PATH.read_text(encoding="utf-8"))

    prev_tag = previous_release_tag()
    prev_registry = registry_at_tag(prev_tag) if prev_tag else None
    existing = load_existing_removals()

    removals = compute_removals(prev_registry, current_registry, version, existing)
    REMOVALS_PATH.write_text(render(build_document(removals)), encoding="utf-8")

    new_count = len(removals) - len(existing)
    if prev_registry is None:
        print(
            "No previous release tag carries deprecations.json — baseline release "
            "(no removals can be detected yet)."
        )
    print(
        f"Wrote {len(removals)} removal record(s) "
        f"({new_count} new in {version}; previous tag: {prev_tag}) "
        f"to {REMOVALS_PATH.relative_to(REPO_ROOT)}"
    )
    return 0


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