"""`scripts/audit_secret_scopes.py` — the CIR-02 durable fix.

The behaviour under test is the one that cost two days of a dead cron: a pipeline
that references a secret on an event the secret is not scoped for. These tests
pin the stdlib pipeline parser against every real fleet shape, the scope
comparison (including the exact mgf-cost-watch 07-13 mismatch shape), and the
paginated repo fetch — all with injected HTTP, no network.
"""

from __future__ import annotations

import importlib.util
import sys
import urllib.error
from pathlib import Path
from typing import Any

import pytest

_SPEC = importlib.util.spec_from_file_location(
    "audit_secret_scopes",
    Path(__file__).resolve().parents[2] / "scripts" / "audit_secret_scopes.py",
)
assert _SPEC and _SPEC.loader
audit = importlib.util.module_from_spec(_SPEC)
sys.modules["audit_secret_scopes"] = audit  # dataclasses resolve annotations via sys.modules
_SPEC.loader.exec_module(audit)


# --------------------------------------------------------------------------- #
# The parser.
# --------------------------------------------------------------------------- #

CLEAN_CRON = """\
when:
  # cadence lives server-side
  - event: cron
  - event: manual

clone:
  - name: clone
    image: woodpeckerci/plugin-git:2.9.2
    settings:
      partial: false
      use-ssh: true
      ssh-key-private:
        from_secret: codeberg_ssh_clone_key
      ssh-host-key:
        from_secret: codeberg_ssh_host_key

steps:
  - name: cost
    image: python:3.12-slim
    environment:
      CLOUDS_YAML:
        from_secret: clouds_yaml
    commands:
      - echo hi
"""

WITH_PUBLISH = """\
when:
  - event: push
    branch: main
  - event: pull_request
  - event: tag
    ref: refs/tags/v*
  - event: manual

clone:
  - name: clone
    settings:
      ssh-key-private:
        from_secret: codeberg_ssh_clone_key

steps:
  - name: lint
    commands:
      - ruff check src
  - name: publish
    when:
      event: tag
      ref: refs/tags/v*
    environment:
      TWINE_PASSWORD:
        from_secret: devpi_password
    commands:
      - twine upload dist/*
"""

INLINE_FLOW = """\
when:
  - event: push
  - event: tag

steps:
  - name: publish
    when: { event: tag, ref: refs/tags/v* }
    environment:
      TWINE_PASSWORD: { from_secret: devpi_password }
    commands:
      - twine upload dist/*
"""

EVENT_FLOW_LIST = """\
when:
  - event: [push, pull_request]
  - event: manual

steps:
  - name: build
    environment:
      TOKEN:
        from_secret: sibling_clone_token
    commands:
      - echo build
"""

SECRETS_LIST_FORM = """\
when:
  - event: push

steps:
  - name: deploy
    secrets: [alpha_key, beta_key]
    commands:
      - echo deploy
  - name: deploy2
    secrets:
      - gamma_key
    commands:
      - echo deploy2
"""

COMMENT_TRAP = """\
when:
  - event: push

steps:
  # No `tag` event: this repo publishes no versioned artifact to any index.
  - name: build
    environment:
      TOKEN:
        from_secret: codeberg_repo_read
    commands:
      - echo build
"""


def _refs(text: str) -> dict[str, set[str]]:
    """secret -> the set of events it is referenced under."""
    _, refs = audit.parse_pipeline(text)
    out: dict[str, set[str]] = {}
    for r in refs:
        out.setdefault(r.secret, set()).update(r.events)
    return out


def test_clone_secrets_inherit_the_pipeline_when_events() -> None:
    pe, _ = audit.parse_pipeline(CLEAN_CRON)
    assert pe == frozenset({"cron", "manual"})
    got = _refs(CLEAN_CRON)
    assert got["codeberg_ssh_clone_key"] == {"cron", "manual"}
    assert got["codeberg_ssh_host_key"] == {"cron", "manual"}
    assert got["clouds_yaml"] == {"cron", "manual"}


def test_step_level_when_narrows_a_secret_to_its_own_events() -> None:
    got = _refs(WITH_PUBLISH)
    # the clone secret runs on every pipeline event...
    assert got["codeberg_ssh_clone_key"] == {"push", "pull_request", "tag", "manual"}
    # ...but the publish secret is narrowed to the step's own `when: event: tag`.
    assert got["devpi_password"] == {"tag"}


def test_inline_flow_forms_for_when_and_from_secret() -> None:
    got = _refs(INLINE_FLOW)
    assert got == {"devpi_password": {"tag"}}


def test_event_flow_list_expands() -> None:
    got = _refs(EVENT_FLOW_LIST)
    assert got["sibling_clone_token"] == {"push", "pull_request", "manual"}


def test_secrets_list_form_block_and_flow() -> None:
    got = _refs(SECRETS_LIST_FORM)
    assert got["alpha_key"] == {"push"}
    assert got["beta_key"] == {"push"}
    assert got["gamma_key"] == {"push"}


def test_a_comment_mentioning_event_is_not_counted_as_an_event() -> None:
    pe, _ = audit.parse_pipeline(COMMENT_TRAP)
    assert pe == frozenset({"push"})
    got = _refs(COMMENT_TRAP)
    # the commented "No `tag` event:" must NOT leak `tag` into the scope
    assert got["codeberg_repo_read"] == {"push"}


# --------------------------------------------------------------------------- #
# The scope comparison.
# --------------------------------------------------------------------------- #


def test_clean_when_every_event_is_allowed() -> None:
    _, refs = audit.parse_pipeline(CLEAN_CRON)
    allowed = {
        "codeberg_ssh_clone_key": frozenset({"cron", "manual", "push", "pull_request", "tag"}),
        "codeberg_ssh_host_key": frozenset({"cron", "manual", "push", "pull_request", "tag"}),
        "clouds_yaml": frozenset({"cron", "manual"}),
    }
    assert audit.audit_refs(refs, allowed) == []


def test_the_historical_cost_watch_mismatch_is_flagged() -> None:
    """THE ACCEPTANCE. On 07-13 the clone keys were scoped for push-class events
    only, while cost-watch's pipeline fires on cron/manual — the clone got an
    empty key and the cron died. The audit must flag exactly that shape."""
    _, refs = audit.parse_pipeline(CLEAN_CRON)
    # the 07-13 broken scope: clone key NOT allowed for cron/manual
    allowed = {
        "codeberg_ssh_clone_key": frozenset({"push", "pull_request", "tag"}),
        "codeberg_ssh_host_key": frozenset({"push", "pull_request", "tag"}),
        "clouds_yaml": frozenset({"cron", "manual"}),
    }
    findings = audit.audit_refs(refs, allowed)
    by_secret = {f.secret: f for f in findings}
    assert set(by_secret) == {"codeberg_ssh_clone_key", "codeberg_ssh_host_key"}
    f = by_secret["codeberg_ssh_clone_key"]
    assert f.kind == "scope"
    assert f.bad_events == ["cron", "manual"]
    assert "cron" in f.describe() and "manual" in f.describe()


def test_a_secret_defined_nowhere_is_flagged_undefined() -> None:
    _, refs = audit.parse_pipeline(EVENT_FLOW_LIST)
    findings = audit.audit_refs(refs, {})  # nothing defined
    assert len(findings) == 1
    assert findings[0].kind == "undefined"
    assert findings[0].secret == "sibling_clone_token"
    assert "NEITHER" in findings[0].describe()


def test_repo_secret_shadows_org_secret_in_the_caller_merge() -> None:
    # audit_refs takes an already-merged allowed dict; prove the {**org, **repo}
    # merge semantics the orchestrator relies on (repo wins).
    org = {"sibling_clone_token": frozenset({"push"})}
    repo = {"sibling_clone_token": frozenset({"push", "pull_request", "manual"})}
    merged = {**org, **repo}
    _, refs = audit.parse_pipeline(EVENT_FLOW_LIST)
    assert audit.audit_refs(refs, merged) == []  # repo scope covers all three events


# --------------------------------------------------------------------------- #
# The paginated HTTP layer + the end-to-end audit_fleet (injected api, tmp repos).
# --------------------------------------------------------------------------- #


def _fake_api(pages: dict[int, list[dict]], secrets: dict[str, Any]) -> Any:
    """A fake Woodpecker API dispatching on URL. ``secrets`` maps a path fragment
    (e.g. "/api/orgs/1/secrets") to the JSON it returns."""

    def api(url: str, token: str, method: str = "GET", payload: dict | None = None) -> Any:
        if "/api/orgs/lookup/" in url:
            return {"id": 1}
        if "/api/repos?all=true" in url:
            page = int(url.split("page=")[1].split("&")[0])
            return pages.get(page, [])
        for frag, data in secrets.items():
            if url.endswith(frag):
                return data
        raise AssertionError(f"unexpected url {url}")

    return api


def test_fetch_activated_repos_pages_until_empty_and_filters() -> None:
    pages = {
        1: [{"id": i, "full_name": f"magogi-admin/r{i}", "active": True} for i in range(50)],
        2: [
            {"id": 51, "full_name": "magogi-admin/r51", "active": True},
            {"id": 52, "full_name": "magogi-admin/inactive", "active": False},
            {"id": 53, "full_name": "other-org/foreign", "active": True},
        ],
        3: [],
    }
    api = _fake_api(pages, {})
    repos = audit.fetch_activated_repos("https://wp", "tok", api, owner="magogi-admin")
    names = {r["full_name"] for r in repos}
    assert "magogi-admin/r51" in names  # page 2 was reached (no single-page truncation)
    assert "magogi-admin/inactive" not in names  # inactive filtered
    assert "other-org/foreign" not in names  # foreign owner filtered
    assert len(repos) == 51


def test_audit_fleet_end_to_end(tmp_path: Path) -> None:
    fed = tmp_path
    (fed / "clean-repo").mkdir()
    (fed / "clean-repo" / ".woodpecker.yml").write_text(CLEAN_CRON)
    (fed / "broken-repo").mkdir()
    (fed / "broken-repo" / ".woodpecker.yml").write_text(CLEAN_CRON)
    (fed / "no-pipeline").mkdir()  # activated but no .woodpecker.yml -> skipped

    pages = {
        1: [
            {"id": 10, "full_name": "magogi-admin/clean-repo", "active": True},
            {"id": 11, "full_name": "magogi-admin/broken-repo", "active": True},
            {"id": 12, "full_name": "magogi-admin/no-pipeline", "active": True},
        ],
        2: [],
    }
    org_ok = ["cron", "manual", "push", "pull_request", "tag"]
    secrets = {
        "/api/orgs/1/secrets": [
            {"name": "codeberg_ssh_clone_key", "events": org_ok},
            {"name": "codeberg_ssh_host_key", "events": org_ok},
        ],
        # clean-repo defines clouds_yaml for cron/manual; broken-repo scopes it push-only
        "/api/repos/10/secrets": [{"name": "clouds_yaml", "events": ["cron", "manual"]}],
        "/api/repos/11/secrets": [{"name": "clouds_yaml", "events": ["push"]}],
        "/api/repos/12/secrets": [],
    }
    api = _fake_api(pages, secrets)
    results = audit.audit_fleet("https://wp", "magogi-admin", "tok", fed, api=api)
    by_name = {r.name: r for r in results}

    assert by_name["clean-repo"].findings == []
    broken = by_name["broken-repo"].findings
    assert [f.secret for f in broken] == ["clouds_yaml"]
    assert broken[0].bad_events == ["cron", "manual"]
    assert by_name["no-pipeline"].skipped == "no local .woodpecker.yml"


def test_poll_unreachable_and_401_surface_cleanly() -> None:
    # audit_fleet's org lookup 401 is caught by main(); here prove fetch helpers
    # propagate a raised HTTPError so main can classify it.
    def boom(url: str, token: str, method: str = "GET", payload: dict | None = None) -> Any:
        raise urllib.error.HTTPError(url, 401, "unauthorized", {}, None)  # type: ignore[arg-type]

    with pytest.raises(urllib.error.HTTPError):
        audit.audit_fleet("https://wp", "magogi-admin", "tok", Path("/nonexistent"), api=boom)
