"""Tests for `.gitea/scripts/ci-required-drift.py` — RFC internal#219 §4 + §6.

Covers the five drift-finding classes (F1, F1b, F2, F3a, F3b), the happy
path (no drift, no API mutation), and the idempotent path (existing
`[ci-drift]` issue is PATCHed in place, NOT duplicated).

Per the Five-Axis review on PR #112, the test suite must FAIL on the
pre-fix code where `find_open_issue()` returned `None` on transient
HTTP errors (causing the caller to POST a duplicate issue). We exercise
that path explicitly with `test_find_open_issue_raises_on_transient_error`.

Run:
    python3 -m pytest tests/test_ci_required_drift.py -v

Dependencies: stdlib + PyYAML (already required by the script itself).
No network. No live Gitea calls.
"""
from __future__ import annotations

import importlib.util
import json
import os
import sys
import textwrap
from pathlib import Path
from unittest import mock

import pytest


# --------------------------------------------------------------------------
# Module-import fixture
# --------------------------------------------------------------------------
# The script reads env vars at import-time (cheap globals, no IO). Tests
# set the env vars BEFORE importing so the module loads under a known
# config, then individual tests monkeypatch the `api()` callable and
# YAML file paths via tmp_path.
SCRIPT_PATH = (
    Path(__file__).resolve().parent.parent
    / ".gitea"
    / "scripts"
    / "ci-required-drift.py"
)


@pytest.fixture(scope="module")
def drift_module():
    """Import the script as a module. Env vars are pre-set so the
    module-level reads pass; tests then patch individual globals as
    needed."""
    env = {
        "GITEA_TOKEN": "test-token",
        "GITEA_HOST": "git.example.test",
        "REPO": "owner/repo",
        "BRANCHES": "main staging",
        "SENTINEL_JOB": "all-required",
        "AUDIT_WORKFLOW_PATH": ".gitea/workflows/audit-force-merge.yml",
        "CI_WORKFLOW_PATH": ".gitea/workflows/ci.yml",
        "DRIFT_LABEL": "tier:high",
    }
    with mock.patch.dict(os.environ, env, clear=False):
        spec = importlib.util.spec_from_file_location(
            "ci_required_drift", SCRIPT_PATH
        )
        m = importlib.util.module_from_spec(spec)
        spec.loader.exec_module(m)
        # Force-set the globals from env (they were captured at import
        # time before our mock.patch.dict took effect on subsequent
        # runs in the same pytest session).
        m.GITEA_TOKEN = env["GITEA_TOKEN"]
        m.GITEA_HOST = env["GITEA_HOST"]
        m.REPO = env["REPO"]
        m.BRANCHES = env["BRANCHES"].split()
        m.SENTINEL_JOB = env["SENTINEL_JOB"]
        m.AUDIT_WORKFLOW_PATH = env["AUDIT_WORKFLOW_PATH"]
        m.CI_WORKFLOW_PATH = env["CI_WORKFLOW_PATH"]
        m.DRIFT_LABEL = env["DRIFT_LABEL"]
        m.OWNER, m.NAME = "owner", "repo"
        m.API = f"https://{env['GITEA_HOST']}/api/v1"
        yield m


# --------------------------------------------------------------------------
# Fixture YAML — minimal but realistic ci.yml + audit-force-merge.yml
# --------------------------------------------------------------------------
def _write_ci_yaml(tmp_path: Path, *, jobs: dict, sentinel_needs: list[str]) -> Path:
    """Write a synthetic ci.yml with the given jobs + sentinel needs."""
    full_jobs = dict(jobs)
    full_jobs["all-required"] = {"runs-on": "ubuntu-latest", "needs": sentinel_needs}
    doc = {"name": "ci", "on": {"pull_request": {}}, "jobs": full_jobs}
    import yaml
    p = tmp_path / "ci.yml"
    p.write_text(yaml.safe_dump(doc), encoding="utf-8")
    return p


def _write_audit_yaml(tmp_path: Path, required_checks: list[str]) -> Path:
    """Write a synthetic audit-force-merge.yml with REQUIRED_CHECKS env."""
    block = "\n".join(required_checks)
    text = textwrap.dedent(
        f"""\
        name: audit-force-merge
        on:
          schedule:
            - cron: '*/30 * * * *'
        jobs:
          audit:
            runs-on: ubuntu-latest
            steps:
              - name: Run audit
                env:
                  REQUIRED_CHECKS: |
                    {block.replace(chr(10), chr(10) + '                    ')}
                run: bash .gitea/scripts/audit-force-merge.sh
        """
    )
    p = tmp_path / "audit-force-merge.yml"
    p.write_text(text, encoding="utf-8")
    return p


def _make_stub_api(responses: dict):
    """Build a fake `api()` callable.

    `responses` maps (method, path) tuples to either:
      - (status_int, body) → returned as-is
      - Exception instance → raised
    Calls are recorded in `.calls` for later assertion.
    """
    class StubApi:
        def __init__(self):
            self.calls: list[tuple] = []

        def __call__(self, method, path, *, body=None, query=None, expect_json=True):
            self.calls.append((method, path, body, query))
            key = (method, path)
            if key not in responses:
                raise AssertionError(
                    f"unexpected api call: {method} {path} (no stub registered)"
                )
            r = responses[key]
            if isinstance(r, Exception):
                raise r
            return r

    return StubApi()


# --------------------------------------------------------------------------
# Drift-class tests — pure detect_drift() coverage
# --------------------------------------------------------------------------
def _patch_paths(drift_module, monkeypatch, ci_yml: Path, audit_yml: Path):
    monkeypatch.setattr(drift_module, "CI_WORKFLOW_PATH", str(ci_yml))
    monkeypatch.setattr(drift_module, "AUDIT_WORKFLOW_PATH", str(audit_yml))


def test_f1_job_missing_from_sentinel_needs(drift_module, tmp_path, monkeypatch):
    """F1: a job exists in ci.yml but is NOT under sentinel.needs."""
    ci = _write_ci_yaml(
        tmp_path,
        jobs={
            "build": {"runs-on": "ubuntu-latest"},
            "test": {"runs-on": "ubuntu-latest"},  # missing from needs
        },
        sentinel_needs=["build"],
    )
    audit = _write_audit_yaml(tmp_path, ["ci / build (pull_request)"])
    _patch_paths(drift_module, monkeypatch, ci, audit)

    stub = _make_stub_api({
        ("GET", "/repos/owner/repo/branch_protections/main"): (
            200,
            {"status_check_contexts": ["ci / build (pull_request)"]},
        ),
    })
    monkeypatch.setattr(drift_module, "api", stub)

    findings, _ = drift_module.detect_drift("main")
    assert any("F1 —" in f and "test" in f for f in findings), findings


def test_f1b_sentinel_needs_typo(drift_module, tmp_path, monkeypatch):
    """F1b: sentinel.needs lists a job not present in ci.yml (typo).

    Per the prior fix, F1b uses jobs_all (the unfiltered set) so that
    event-gated jobs aren't false-positive typos."""
    ci = _write_ci_yaml(
        tmp_path,
        jobs={"build": {"runs-on": "ubuntu-latest"}},
        sentinel_needs=["build", "bulid"],  # typo'd
    )
    audit = _write_audit_yaml(tmp_path, ["ci / build (pull_request)"])
    _patch_paths(drift_module, monkeypatch, ci, audit)

    stub = _make_stub_api({
        ("GET", "/repos/owner/repo/branch_protections/main"): (
            200,
            {"status_check_contexts": ["ci / build (pull_request)"]},
        ),
    })
    monkeypatch.setattr(drift_module, "api", stub)

    findings, _ = drift_module.detect_drift("main")
    assert any("F1b" in f and "bulid" in f for f in findings), findings


def test_f1b_event_gated_job_not_flagged_as_typo(drift_module, tmp_path, monkeypatch):
    """F1b regression guard: event-gated jobs (with `if: github.event_name`)
    are in jobs_all and must NOT trigger F1b when listed in sentinel.needs.
    They DO trigger F1 if missing — but that's a different finding."""
    ci = _write_ci_yaml(
        tmp_path,
        jobs={
            "build": {"runs-on": "ubuntu-latest"},
            "pr-only": {
                "runs-on": "ubuntu-latest",
                "if": "github.event_name == 'pull_request'",
            },
        },
        sentinel_needs=["build", "pr-only"],  # event-gated, but real
    )
    audit = _write_audit_yaml(
        tmp_path,
        ["ci / build (pull_request)", "ci / pr-only (pull_request)"],
    )
    _patch_paths(drift_module, monkeypatch, ci, audit)

    stub = _make_stub_api({
        ("GET", "/repos/owner/repo/branch_protections/main"): (
            200,
            {
                "status_check_contexts": [
                    "ci / build (pull_request)",
                    "ci / pr-only (pull_request)",
                ]
            },
        ),
    })
    monkeypatch.setattr(drift_module, "api", stub)

    findings, _ = drift_module.detect_drift("main")
    assert not any("F1b" in f for f in findings), findings


def test_f2_protection_has_no_emitter(drift_module, tmp_path, monkeypatch):
    """F2: a `ci / ` prefixed context in protection has no job in ci.yml."""
    ci = _write_ci_yaml(
        tmp_path,
        jobs={"build": {"runs-on": "ubuntu-latest"}},
        sentinel_needs=["build"],
    )
    audit = _write_audit_yaml(tmp_path, ["ci / build (pull_request)"])
    _patch_paths(drift_module, monkeypatch, ci, audit)

    stub = _make_stub_api({
        ("GET", "/repos/owner/repo/branch_protections/main"): (
            200,
            {
                "status_check_contexts": [
                    "ci / build (pull_request)",
                    "ci / removed-job (pull_request)",  # F2
                ]
            },
        ),
    })
    monkeypatch.setattr(drift_module, "api", stub)

    findings, _ = drift_module.detect_drift("main")
    assert any("F2" in f and "removed-job" in f for f in findings), findings


def test_f3a_env_wider_than_protection(drift_module, tmp_path, monkeypatch):
    """F3a: REQUIRED_CHECKS env has a context NOT in protection."""
    ci = _write_ci_yaml(
        tmp_path,
        jobs={"build": {"runs-on": "ubuntu-latest"}},
        sentinel_needs=["build"],
    )
    audit = _write_audit_yaml(
        tmp_path,
        [
            "ci / build (pull_request)",
            "ci / ghost (pull_request)",  # only in env
        ],
    )
    _patch_paths(drift_module, monkeypatch, ci, audit)

    stub = _make_stub_api({
        ("GET", "/repos/owner/repo/branch_protections/main"): (
            200,
            {"status_check_contexts": ["ci / build (pull_request)"]},
        ),
    })
    monkeypatch.setattr(drift_module, "api", stub)

    findings, _ = drift_module.detect_drift("main")
    assert any("F3a" in f and "ghost" in f for f in findings), findings


def test_f3b_protection_wider_than_env(drift_module, tmp_path, monkeypatch):
    """F3b: protection has a context NOT in REQUIRED_CHECKS env."""
    ci = _write_ci_yaml(
        tmp_path,
        jobs={
            "build": {"runs-on": "ubuntu-latest"},
            "test": {"runs-on": "ubuntu-latest"},
        },
        sentinel_needs=["build", "test"],
    )
    audit = _write_audit_yaml(tmp_path, ["ci / build (pull_request)"])
    _patch_paths(drift_module, monkeypatch, ci, audit)

    stub = _make_stub_api({
        ("GET", "/repos/owner/repo/branch_protections/main"): (
            200,
            {
                "status_check_contexts": [
                    "ci / build (pull_request)",
                    "ci / test (pull_request)",  # only in protection
                ]
            },
        ),
    })
    monkeypatch.setattr(drift_module, "api", stub)

    findings, _ = drift_module.detect_drift("main")
    assert any("F3b" in f and "ci / test (pull_request)" in f for f in findings), findings


def test_happy_path_no_drift(drift_module, tmp_path, monkeypatch):
    """Happy path: ci.yml ↔ protection ↔ audit env all in alignment."""
    ci = _write_ci_yaml(
        tmp_path,
        jobs={
            "build": {"runs-on": "ubuntu-latest"},
            "test": {"runs-on": "ubuntu-latest"},
        },
        sentinel_needs=["build", "test"],
    )
    audit = _write_audit_yaml(
        tmp_path,
        [
            "ci / build (pull_request)",
            "ci / test (pull_request)",
            "ci / all-required (pull_request)",
        ],
    )
    _patch_paths(drift_module, monkeypatch, ci, audit)

    stub = _make_stub_api({
        ("GET", "/repos/owner/repo/branch_protections/main"): (
            200,
            {
                "status_check_contexts": [
                    "ci / build (pull_request)",
                    "ci / test (pull_request)",
                    "ci / all-required (pull_request)",
                ]
            },
        ),
    })
    monkeypatch.setattr(drift_module, "api", stub)

    findings, _ = drift_module.detect_drift("main")
    assert findings == [], findings


# --------------------------------------------------------------------------
# MUST-FIX 1: find_open_issue must raise on transient HTTP errors
# --------------------------------------------------------------------------
def test_find_open_issue_returns_none_on_no_match(drift_module, monkeypatch):
    """Search succeeded, no match → return None (the OK path)."""
    stub = _make_stub_api({
        ("GET", "/repos/owner/repo/issues"): (200, []),
    })
    monkeypatch.setattr(drift_module, "api", stub)
    assert drift_module.find_open_issue("[ci-drift] foo") is None


def test_find_open_issue_returns_match(drift_module, monkeypatch):
    """Search succeeded, matching issue exists → return it."""
    issue = {"number": 42, "title": "[ci-drift] foo"}
    stub = _make_stub_api({
        ("GET", "/repos/owner/repo/issues"): (200, [issue]),
    })
    monkeypatch.setattr(drift_module, "api", stub)
    assert drift_module.find_open_issue("[ci-drift] foo") == issue


def test_find_open_issue_raises_on_transient_error(drift_module, monkeypatch):
    """Search FAILED (HTTP 500) → raise ApiError, do NOT return None.

    This is the regression class from PR #112's Five-Axis review:
    returning None caused file_or_update() to take the else branch and
    POST a duplicate issue. The fix is for api() to raise; tests pin
    that contract by exercising the failure path explicitly.
    """
    stub = _make_stub_api({
        ("GET", "/repos/owner/repo/issues"): drift_module.ApiError(
            "GET /repos/owner/repo/issues → HTTP 500: gateway timeout"
        ),
    })
    monkeypatch.setattr(drift_module, "api", stub)
    with pytest.raises(drift_module.ApiError):
        drift_module.find_open_issue("[ci-drift] foo")


# --------------------------------------------------------------------------
# Idempotent path: existing issue is PATCHed, NOT duplicated
# --------------------------------------------------------------------------
def test_file_or_update_patches_existing_issue(drift_module, monkeypatch):
    """When an open `[ci-drift]` issue exists, file_or_update PATCHes it
    and does NOT POST a duplicate."""
    title = drift_module.title_for("main")
    issue = {"number": 7, "title": title}

    stub = _make_stub_api({
        ("GET", "/repos/owner/repo/issues"): (200, [issue]),
        ("PATCH", "/repos/owner/repo/issues/7"): (200, {"number": 7}),
    })
    monkeypatch.setattr(drift_module, "api", stub)

    drift_module.file_or_update(
        "main",
        ["F2 — ci / removed-job (pull_request) has no emitter"],
        {"branch": "main"},
    )

    methods = [c[0] for c in stub.calls]
    assert "PATCH" in methods, stub.calls
    assert "POST" not in methods, (
        f"expected NO POST when issue exists (idempotent path), got: {stub.calls}"
    )


def test_file_or_update_posts_new_issue_when_none_exists(drift_module, monkeypatch):
    """When no open `[ci-drift]` issue exists, file_or_update POSTs one."""
    stub = _make_stub_api({
        ("GET", "/repos/owner/repo/issues"): (200, []),
        ("POST", "/repos/owner/repo/issues"): (201, {"number": 99}),
        ("GET", "/repos/owner/repo/labels"): (200, [{"id": 10, "name": "tier:high"}]),
        ("POST", "/repos/owner/repo/issues/99/labels"): (200, []),
    })
    monkeypatch.setattr(drift_module, "api", stub)

    drift_module.file_or_update(
        "main",
        ["F2 — ci / removed-job (pull_request) has no emitter"],
        {"branch": "main"},
    )

    methods_paths = [(c[0], c[1]) for c in stub.calls]
    assert ("POST", "/repos/owner/repo/issues") in methods_paths, stub.calls
    # Label apply is best-effort but should be attempted on the happy path:
    assert ("POST", "/repos/owner/repo/issues/99/labels") in methods_paths, stub.calls


# --------------------------------------------------------------------------
# --dry-run flag
# --------------------------------------------------------------------------
def test_dry_run_skips_all_api_writes(drift_module, monkeypatch, capsys):
    """--dry-run: detector still runs, but no GET/POST/PATCH issue calls."""
    stub = _make_stub_api({})  # any api call would assert
    monkeypatch.setattr(drift_module, "api", stub)

    drift_module.file_or_update(
        "main",
        ["F2 — ci / removed-job (pull_request) has no emitter"],
        {"branch": "main"},
        dry_run=True,
    )

    assert stub.calls == [], f"dry-run must not call api(), got: {stub.calls}"
    captured = capsys.readouterr()
    assert "[dry-run]" in captured.out
    assert "[ci-drift]" in captured.out  # title rendered to stdout


def test_dry_run_flag_parsed(drift_module):
    """--dry-run is wired into argparse."""
    ns = drift_module._parse_args(["--dry-run"])
    assert ns.dry_run is True
    ns = drift_module._parse_args([])
    assert ns.dry_run is False


# --------------------------------------------------------------------------
# api() helper: raises on non-2xx + on JSON-decode failure when expected
# --------------------------------------------------------------------------
def test_api_raises_on_non_2xx(drift_module, monkeypatch):
    """api() must raise ApiError on HTTP 500 — the duplicate-issue
    regression class from PR #112's review depends on this."""
    class FakeHTTPError(Exception):
        def __init__(self):
            self.code = 500
        def read(self):
            return b"internal server error"

    def fake_urlopen(req, timeout=30):
        import urllib.error
        raise urllib.error.HTTPError(
            req.full_url, 500, "Internal Server Error", {}, None  # type: ignore
        )

    monkeypatch.setattr(drift_module.urllib.request, "urlopen", fake_urlopen)

    with pytest.raises(drift_module.ApiError) as excinfo:
        drift_module.api("GET", "/repos/owner/repo/issues")
    assert "HTTP 500" in str(excinfo.value)


def test_api_raises_on_json_decode_when_expected(drift_module, monkeypatch):
    """api(expect_json=True) raises ApiError if body is not valid JSON.

    This closes the prior `{"_raw": ...}` fallthrough that callers
    could misinterpret as "JSON response with one key called _raw".
    """
    class FakeResp:
        status = 200
        def read(self):
            return b"not-json\n\n"
        def __enter__(self):
            return self
        def __exit__(self, *a):
            return False

    def fake_urlopen(req, timeout=30):
        return FakeResp()

    monkeypatch.setattr(drift_module.urllib.request, "urlopen", fake_urlopen)

    with pytest.raises(drift_module.ApiError):
        drift_module.api("GET", "/repos/owner/repo/issues")


def test_api_allows_raw_when_expect_json_false(drift_module, monkeypatch):
    """api(expect_json=False) returns the `_raw` fallthrough for endpoints
    with known echo-quirks (Gitea create responses). Reserved opt-in."""
    class FakeResp:
        status = 201
        def read(self):
            return b"not-json-but-create-succeeded\n"
        def __enter__(self):
            return self
        def __exit__(self, *a):
            return False

    def fake_urlopen(req, timeout=30):
        return FakeResp()

    monkeypatch.setattr(drift_module.urllib.request, "urlopen", fake_urlopen)
    status, body = drift_module.api(
        "POST", "/repos/owner/repo/issues", expect_json=False
    )
    assert status == 201
    assert "_raw" in body
