"""OpenAPI contract test.

This test pins the public HTTP contract of backend-compliance. It loads the
FastAPI app, extracts the live OpenAPI schema, and compares it against a
checked-in baseline at ``tests/contracts/openapi.baseline.json``.

Rules:
  - Adding new paths/operations/fields → OK (additive change).
  - Removing a path, changing a method, changing a status code, removing or
    renaming a response/request field → FAIL. Such changes require updating
    every consumer (admin-compliance, developer-portal, SDKs) in the same
    change, then regenerating the baseline with:

        python tests/contracts/regenerate_baseline.py

    and explaining the contract change in the PR description.

The baseline is missing on first run — the test prints the command to create
it and skips. This is intentional: Phase 1 step 1 generates it fresh from the
current app state before any refactoring begins.
"""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any

import pytest

BASELINE_PATH = Path(__file__).parent / "openapi.baseline.json"


def _load_live_schema() -> dict[str, Any]:
    """Import the FastAPI app and extract its OpenAPI schema.

    Kept inside the function so that test collection does not fail if the app
    has import-time side effects that aren't satisfied in the test env.
    """
    from main import app  # type: ignore[import-not-found]

    return app.openapi()


def _collect_operations(schema: dict[str, Any]) -> dict[str, dict[str, Any]]:
    """Return a flat {f'{METHOD} {path}': operation} map for diffing."""
    out: dict[str, dict[str, Any]] = {}
    for path, methods in schema.get("paths", {}).items():
        for method, op in methods.items():
            if method.lower() in {"get", "post", "put", "patch", "delete", "options", "head"}:
                out[f"{method.upper()} {path}"] = op
    return out


@pytest.mark.contract
def test_openapi_no_breaking_changes() -> None:
    if not BASELINE_PATH.exists():
        pytest.skip(
            f"Baseline missing. Run: python {Path(__file__).parent}/regenerate_baseline.py"
        )

    baseline = json.loads(BASELINE_PATH.read_text())
    live = _load_live_schema()

    baseline_ops = _collect_operations(baseline)
    live_ops = _collect_operations(live)

    # 1. No operation may disappear.
    removed = sorted(set(baseline_ops) - set(live_ops))
    assert not removed, (
        f"Breaking change: {len(removed)} operation(s) removed from public API:\n  "
        + "\n  ".join(removed)
    )

    # 2. For operations that exist in both, response status codes must be a superset.
    for key, baseline_op in baseline_ops.items():
        live_op = live_ops[key]
        baseline_codes = set((baseline_op.get("responses") or {}).keys())
        live_codes = set((live_op.get("responses") or {}).keys())
        missing = baseline_codes - live_codes
        assert not missing, (
            f"Breaking change: {key} no longer returns status code(s) {sorted(missing)}"
        )

    # 3. Required request-body fields may not be added (would break existing clients).
    for key, baseline_op in baseline_ops.items():
        live_op = live_ops[key]
        base_req = _required_body_fields(baseline_op)
        live_req = _required_body_fields(live_op)
        new_required = live_req - base_req
        assert not new_required, (
            f"Breaking change: {key} added required request field(s) {sorted(new_required)}"
        )


def _required_body_fields(op: dict[str, Any]) -> set[str]:
    rb = op.get("requestBody") or {}
    content = rb.get("content") or {}
    for media in content.values():
        schema = media.get("schema") or {}
        return set(schema.get("required") or [])
    return set()
