"""Woodpecker ``.woodpecker.yml`` scope parser — the CIR-02 durable-fix logic.

Ported verbatim (stdlib-only, indentation-aware) from
``mgf-standard/scripts/audit_secret_scopes.py`` so the doctor's C2 check is
**parity-identical** to the original audit on the same fleet (SPEC §5 C2, AC §9.3).
A pipeline that references a secret on an event the secret is not scoped for gets
an EMPTY secret and the step fails silently — this reader extracts, per step, the
referenced secrets and the event classes each is referenced under.

We deliberately do not import a YAML library: mgf-standard is a zero-dependency
pure leaf and this focused reader is covered by the ported test corpus against
every real fleet shape (flow lists, inline maps, comment traps, block secrets).
"""

from __future__ import annotations

import re
from dataclasses import dataclass, field
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from collections.abc import Iterable, Sequence

SECTION_KEYS = ("clone", "steps", "services")

#: ``event: push`` / ``event: [push, pull_request]`` / inline ``{ event: tag }``.
_EVENT_RE = re.compile(r"event:\s*(\[[^\]]*\]|[^,}#\n]+)")
#: ``from_secret: name`` in block or ``{ from_secret: name }`` flow form.
_FROM_SECRET_RE = re.compile(r"from_secret:\s*['\"]?([A-Za-z0-9_.\-]+)")
_NAME_RE = re.compile(r"(?:-\s+)?name:\s*(.+)")
_SECRETS_RE = re.compile(r"secrets:\s*(.*)")
_DASH_ITEM_RE = re.compile(r"-\s+(.+)")


@dataclass(frozen=True)
class SecretRef:
    """One reference to ``secret`` in ``step``, exposed to ``events``."""

    secret: str
    step: str
    events: frozenset[str]


@dataclass(frozen=True)
class _Record:
    indent: int
    stripped: str
    is_comment: bool
    raw: str


def _records(text: str) -> list[_Record]:
    out: list[_Record] = []
    for raw in text.splitlines():
        if not raw.strip():
            continue
        stripped = raw.lstrip(" ")
        out.append(_Record(len(raw) - len(stripped), stripped, stripped.startswith("#"), raw))
    return out


def _events_in(lines: Iterable[str]) -> set[str]:
    """Every event named in ``lines`` (scalar, flow-list, or inline-mapping)."""
    events: set[str] = set()
    for ln in lines:
        for m in _EVENT_RE.finditer(ln):
            val = m.group(1).strip()
            parts = val[1:-1].split(",") if val.startswith("[") else [val]
            for p in parts:
                tok = p.strip().strip("'\"")
                if tok:
                    events.add(tok)
    return events


def _pipeline_events(recs: list[_Record]) -> frozenset[str]:
    """The top-level ``when:`` event set (empty = no top-level constraint)."""
    for i, rec in enumerate(recs):
        if rec.indent == 0 and not rec.is_comment and rec.stripped.startswith("when:"):
            block = [rec.raw]
            for nxt in recs[i + 1 :]:
                if nxt.indent == 0:
                    break
                if not nxt.is_comment:
                    block.append(nxt.raw)
            return frozenset(_events_in(block))
    return frozenset()


def _iter_step_blocks(recs: list[_Record]) -> list[list[_Record]]:
    """Split ``clone:``/``steps:``/``services:`` into per-step record blocks."""
    blocks: list[list[_Record]] = []
    i, n = 0, len(recs)
    while i < n:
        rec = recs[i]
        key = rec.stripped.split(":", 1)[0]
        if rec.indent == 0 and not rec.is_comment and key in SECTION_KEYS:
            body: list[_Record] = []
            j = i + 1
            while j < n and recs[j].indent > 0:
                body.append(recs[j])
                j += 1
            blocks.extend(_split_items(body))
            i = j
        else:
            i += 1
    return blocks


def _split_items(body: list[_Record]) -> list[list[_Record]]:
    dash_indent = min(
        (r.indent for r in body if not r.is_comment and r.stripped.startswith("- ")),
        default=None,
    )
    if dash_indent is None:
        return []
    items: list[list[_Record]] = []
    cur: list[_Record] | None = None
    for rec in body:
        if rec.indent == dash_indent and not rec.is_comment and rec.stripped.startswith("- "):
            if cur is not None:
                items.append(cur)
            cur = [rec]
        elif cur is not None:
            cur.append(rec)
    if cur is not None:
        items.append(cur)
    return items


def _step_name(block: list[_Record]) -> str:
    for rec in block:
        if rec.is_comment:
            continue
        m = _NAME_RE.match(rec.stripped)
        if m:
            return m.group(1).strip().strip("'\"")
    return "?"


def _step_events(block: list[_Record]) -> set[str]:
    """The step's own ``when:`` event set, or empty if it doesn't constrain events."""
    dash_indent = block[0].indent
    when_indent = dash_indent + 2
    for k, rec in enumerate(block):
        if rec.is_comment or rec.indent != when_indent or not rec.stripped.startswith("when:"):
            continue
        inline = _events_in([rec.raw])
        if inline:
            return inline
        sub: list[str] = []
        for r in block[k + 1 :]:
            if r.indent <= when_indent:
                break
            if not r.is_comment:
                sub.append(r.raw)
        return _events_in(sub)
    return set()


def _step_secrets(block: list[_Record]) -> list[str]:
    secrets: list[str] = []
    for k, rec in enumerate(block):
        if rec.is_comment:
            continue
        secrets.extend(_FROM_SECRET_RE.findall(rec.raw))
        sm = _SECRETS_RE.match(rec.stripped)
        if not sm:
            continue
        rest = sm.group(1).strip()
        if rest.startswith("["):
            secrets.extend(t.strip().strip("'\"") for t in rest[1:-1].split(",") if t.strip())
        elif rest and not rest.startswith("#"):
            secrets.append(rest.strip("'\""))
        else:  # block list: following ``- name`` items deeper than ``secrets:``
            for nxt in block[k + 1 :]:
                if nxt.indent <= rec.indent:
                    break
                if nxt.is_comment:
                    continue
                dm = _DASH_ITEM_RE.match(nxt.stripped)
                if dm:
                    secrets.append(dm.group(1).strip().strip("'\""))
    return secrets


def parse_pipeline(text: str) -> tuple[frozenset[str], list[SecretRef]]:
    """Parse a ``.woodpecker.yml`` into (pipeline events, secret references).

    A secret's effective event scope is the step's own ``when:`` events if it
    constrains events, else the pipeline-level ``when:`` events.
    """
    recs = _records(text)
    pevents = _pipeline_events(recs)
    refs: list[SecretRef] = []
    for block in _iter_step_blocks(recs):
        name = _step_name(block)
        sevents = _step_events(block)
        eff = frozenset(sevents) if sevents else pevents
        for secret in _step_secrets(block):
            refs.append(SecretRef(secret=secret, step=name, events=eff))
    return pevents, refs


# --------------------------------------------------------------------------- #
# The scope comparison (pure) — reused by doctor C2.
# --------------------------------------------------------------------------- #


@dataclass
class ScopeFinding:
    secret: str
    referenced: list[str]
    kind: str  # "scope" | "undefined"
    allowed: list[str] | None = None
    bad_events: list[str] = field(default_factory=list)

    def describe(self) -> str:
        if self.kind == "undefined":
            return (
                f"secret {self.secret!r} referenced on {self.referenced} but defined at "
                "NEITHER org nor repo level (injects empty on every event)"
            )
        return (
            f"secret {self.secret!r} referenced on {sorted(self.bad_events)} but scoped only for "
            f"{self.allowed} (used on {self.referenced})"
        )


def audit_refs(refs: Sequence[SecretRef], allowed: dict[str, frozenset[str]]) -> list[ScopeFinding]:
    """Compare referenced (secret, events) against allowed event scopes.

    ``allowed`` maps a defined secret name to its allowed event set (repo secrets
    already merged over org secrets by the caller). A secret referenced with an
    UNCONSTRAINED event set (no ``when``) is not a finding — its exposure cannot
    be proven.
    """
    aggregated: dict[str, set[str]] = {}
    for ref in refs:
        if ref.events:
            aggregated.setdefault(ref.secret, set()).update(ref.events)
        else:
            aggregated.setdefault(ref.secret, set())
    findings: list[ScopeFinding] = []
    for secret in sorted(aggregated):
        events = aggregated[secret]
        if secret not in allowed:
            findings.append(
                ScopeFinding(secret=secret, referenced=sorted(events), kind="undefined")
            )
            continue
        allow = allowed[secret]
        bad = sorted(e for e in events if e not in allow)
        if bad:
            findings.append(
                ScopeFinding(
                    secret=secret,
                    referenced=sorted(events),
                    kind="scope",
                    allowed=sorted(allow),
                    bad_events=bad,
                )
            )
    return findings
