"""Woodpecker API client (read-only) — the unmetered surface (poll freely).

Ported from ``audit_secret_scopes.py``: paginated ``GET /api/repos?all=true``,
org/repo secret scopes, org lookup. The API is injectable (a plain callable) so
the doctor and its replay fixtures run with zero network. Token resolution is the
house fallback chain: ``$WOODPECKER_TOKEN`` → ``mgf-standard/my_stuff/
woodpecker_token.txt``; a 401 raises a :class:`CredentialError` pointing at the
MGF_INFRA §2 on-host mint. The token value is never logged or printed.
"""

from __future__ import annotations

import json
import os
import urllib.error
import urllib.request
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Protocol

from .errors import CredentialError, SurfaceUnavailableError

DEFAULT_WP_URL = "https://195.15.241.48.sslip.io"
DEFAULT_OWNER = "magogi-admin"
PER_PAGE = 50

#: Pipeline statuses that mean the pipeline itself terminally ERRORED (config parse,
#: compile-time secret injection, system error) — as distinct from ``failure`` (a
#: step ran and failed, which CI already surfaces). The CIR-02 silent-empty shape is
#: exactly one of these with ZERO executed steps (SPEC-P4 §3).
ERROR_STATUSES: frozenset[str] = frozenset({"error"})

#: HTTP statuses that mean "couldn't look" for the (unmetered) Woodpecker surface —
#: transient/5xx. A per-repo soft read turns these into ``unverifiable`` rather than
#: crashing the whole doctor run (SPEC §4).
_UNVERIFIABLE_HTTP = frozenset({429, 500, 502, 503, 504})

ApiFn = Callable[..., Any]


@dataclass(frozen=True)
class RepoInfo:
    id: int
    name: str  # the bare repo name (last path segment)
    full_name: str
    active: bool


@dataclass(frozen=True)
class PipelineInfo:
    """A recent pipeline's terminal shape for the error-status watch (SPEC-P4 §3).

    ``step_count`` is the number of executed steps across the pipeline's workflows;
    ``0`` with a status in :data:`ERROR_STATUSES` is the "nothing gates, nothing
    pages" shape a compile-time empty-secret injection produces.
    """

    number: int
    status: str
    created: int  # unix seconds (0 when the API omits it)
    step_count: int
    finished: int = 0  # unix seconds of terminal time (0 when still running / omitted)


@dataclass(frozen=True)
class SecretState:
    """A live Woodpecker secret's governed shape — its event scope + image
    restriction (D-7). Zero value: a secret's VALUE is write-only on the API and
    never returned by a read, so this metadata is all sync ever compares.
    """

    events: frozenset[str]
    images: tuple[str, ...] = ()


class WoodpeckerAPI(Protocol):
    """The read-only surface the doctor consumes (real client + fakes match it)."""

    def org_id(self) -> int: ...
    def org_secrets(self) -> dict[str, frozenset[str]]: ...
    def repo_secrets(self, repo_id: int) -> dict[str, frozenset[str]]: ...
    def activated_repos(self) -> list[RepoInfo]: ...
    #: Recent pipelines (newest first) for the error-status-0-steps watch (SPEC-P4 §3).
    def recent_pipelines(self, repo_id: int, limit: int = ...) -> list[PipelineInfo]: ...


class WoodpeckerAdmin(WoodpeckerAPI, Protocol):
    """The read+write surface ``sync`` reconciles against (real client + fakes match).

    Extends the read protocol with the image-aware read (``*_secret_states``) and
    the create/update writes. A secret VALUE crosses this boundary exactly once —
    into ``create_*_secret``'s API body — and is never returned or logged (AG-18).
    """

    def org_secret_states(self) -> dict[str, SecretState]: ...
    def repo_secret_states(self, repo_id: int) -> dict[str, SecretState]: ...
    def create_org_secret(
        self, name: str, value: bytes, events: frozenset[str], images: tuple[str, ...]
    ) -> None: ...
    def update_org_secret(
        self, name: str, *, events: frozenset[str], images: tuple[str, ...]
    ) -> None: ...
    def create_repo_secret(
        self,
        repo_id: int,
        name: str,
        value: bytes,
        events: frozenset[str],
        images: tuple[str, ...],
    ) -> None: ...
    def update_repo_secret(
        self, repo_id: int, name: str, *, events: frozenset[str], images: tuple[str, ...]
    ) -> None: ...


def resolve_token(explicit: str | None = None, *, fed_root: Path | None = None) -> str:
    """Return a Woodpecker token by the house fallback chain, or raise.

    Order: explicit arg → ``$WOODPECKER_TOKEN`` → ``mgf-standard/my_stuff/
    woodpecker_token.txt``. The value is read straight from disk into the request
    header — never returned into a transcript by the caller.
    """
    if explicit:
        return explicit
    env = os.environ.get("WOODPECKER_TOKEN")
    if env:
        return env
    root = fed_root or (Path.home() / "PycharmProjects")
    stored = Path(root) / "mgf-standard" / "my_stuff" / "woodpecker_token.txt"
    if stored.is_file():
        return stored.read_text(encoding="utf-8").strip()
    raise CredentialError(
        "no Woodpecker token — set $WOODPECKER_TOKEN or provide "
        "mgf-standard/my_stuff/woodpecker_token.txt (mint per MGF_INFRA §2)."
    )


def _default_api(
    url: str, token: str, method: str = "GET", payload: dict[str, Any] | None = None
) -> Any:
    data = json.dumps(payload).encode() if payload is not None else None
    req = urllib.request.Request(  # noqa: S310 (fixed https host)
        url,
        data=data,
        method=method,
        headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
    )
    with urllib.request.urlopen(req, timeout=30) as resp:  # noqa: S310 (fixed https host)
        body = resp.read().decode()
        return json.loads(body) if body.strip() else {}


def _step_count(pipeline: Any) -> int | None:
    """Executed-step count across a pipeline's workflows, or ``None`` when the payload
    does not carry the workflow breakdown (a list item vs a detail fetch). Woodpecker
    nests steps under ``workflows[].children`` (older shapes: ``[].steps``)."""
    if not isinstance(pipeline, dict):
        return None
    workflows = pipeline.get("workflows")
    if workflows is None:
        return None
    total = 0
    for wf in workflows:
        if isinstance(wf, dict):
            children = wf.get("children") or wf.get("steps") or []
            total += len(children)
    return total


def _scopes(raw: Any) -> dict[str, frozenset[str]]:
    return {s["name"]: frozenset(s.get("events") or []) for s in (raw or [])}


def _states(raw: Any) -> dict[str, SecretState]:
    return {
        s["name"]: SecretState(
            events=frozenset(s.get("events") or []),
            images=tuple(s.get("images") or ()),
        )
        for s in (raw or [])
    }


def _secret_body(
    name: str, value: bytes | None, events: frozenset[str], images: tuple[str, ...]
) -> dict[str, Any]:
    """Build a Woodpecker secret create/update body. AG-18: this dict carries the
    plaintext value ONLY on create and is handed straight to the API — it is never
    logged, printed, or placed in an exception. ``value=None`` (update) omits it so
    a re-scope/re-image never re-sends the value.
    """
    body: dict[str, Any] = {"name": name, "events": sorted(events), "images": list(images)}
    if value is not None:
        body["value"] = value.decode("utf-8", "surrogateescape")
    return body


class WoodpeckerClient:
    """Live read-only client. All calls go through the injectable ``api``."""

    def __init__(
        self,
        *,
        base_url: str = DEFAULT_WP_URL,
        owner: str = DEFAULT_OWNER,
        token: str | None = None,
        fed_root: Path | None = None,
        api: ApiFn = _default_api,
    ) -> None:
        self.base = base_url.rstrip("/")
        self.owner = owner
        self._token = resolve_token(token, fed_root=fed_root)
        self._api = api
        self._org_id: int | None = None

    def _get(self, path: str) -> Any:
        try:
            return self._api(f"{self.base}{path}", self._token)
        except urllib.error.HTTPError as exc:
            if exc.code == 401:
                raise CredentialError(
                    "Woodpecker 401 — token expired; re-mint per MGF_INFRA §2 "
                    "(on-host DB-hash JWT). This is owned infra, not a wall."
                ) from exc
            raise CredentialError(f"Woodpecker API HTTP {exc.code} on {path}") from exc
        except urllib.error.URLError as exc:
            raise CredentialError(f"Woodpecker unreachable ({exc.reason})") from exc

    def org_id(self) -> int:
        if self._org_id is None:
            org = self._get(f"/api/orgs/lookup/{self.owner}")
            self._org_id = int(org["id"])
        return self._org_id

    def org_secrets(self) -> dict[str, frozenset[str]]:
        return _scopes(self._get(f"/api/orgs/{self.org_id()}/secrets"))

    def repo_secrets(self, repo_id: int) -> dict[str, frozenset[str]]:
        return _scopes(self._get(f"/api/repos/{repo_id}/secrets"))

    # -- image-aware reads (sync compares events + D-7 image restrictions) ---- #

    def org_secret_states(self) -> dict[str, SecretState]:
        return _states(self._get(f"/api/orgs/{self.org_id()}/secrets"))

    def repo_secret_states(self, repo_id: int) -> dict[str, SecretState]:
        return _states(self._get(f"/api/repos/{repo_id}/secrets"))

    # -- writes (sync --apply only; the VALUE never leaves the API body) ------ #

    def _write(self, path: str, method: str, payload: dict[str, Any]) -> None:
        try:
            self._api(f"{self.base}{path}", self._token, method, payload)
        except urllib.error.HTTPError as exc:
            if exc.code == 401:
                raise CredentialError(
                    "Woodpecker 401 on write — token expired; re-mint per MGF_INFRA §2."
                ) from exc
            raise CredentialError(f"Woodpecker API HTTP {exc.code} on {method} {path}") from exc
        except urllib.error.URLError as exc:
            raise CredentialError(f"Woodpecker unreachable ({exc.reason})") from exc

    def create_org_secret(
        self, name: str, value: bytes, events: frozenset[str], images: tuple[str, ...]
    ) -> None:
        self._write(
            f"/api/orgs/{self.org_id()}/secrets",
            "POST",
            _secret_body(name, value, events, images),
        )

    def update_org_secret(
        self, name: str, *, events: frozenset[str], images: tuple[str, ...]
    ) -> None:
        self._write(
            f"/api/orgs/{self.org_id()}/secrets/{name}",
            "PATCH",
            _secret_body(name, None, events, images),
        )

    def create_repo_secret(
        self, repo_id: int, name: str, value: bytes, events: frozenset[str], images: tuple[str, ...]
    ) -> None:
        self._write(
            f"/api/repos/{repo_id}/secrets",
            "POST",
            _secret_body(name, value, events, images),
        )

    def update_repo_secret(
        self, repo_id: int, name: str, *, events: frozenset[str], images: tuple[str, ...]
    ) -> None:
        self._write(
            f"/api/repos/{repo_id}/secrets/{name}",
            "PATCH",
            _secret_body(name, None, events, images),
        )

    def activated_repos(self) -> list[RepoInfo]:
        collected: list[dict[str, Any]] = []
        page = 1
        while True:
            batch = self._get(f"/api/repos?all=true&page={page}&perPage={PER_PAGE}")
            if not batch:
                break
            collected.extend(batch)
            page += 1
        out: list[RepoInfo] = []
        for r in collected:
            full = str(r.get("full_name", ""))
            if self.owner and full.split("/")[0] != self.owner:
                continue
            if not r.get("active"):
                continue
            out.append(
                RepoInfo(id=int(r["id"]), name=full.split("/")[-1], full_name=full, active=True)
            )
        return sorted(out, key=lambda r: r.full_name)

    # -- soft GET (unverifiable-aware) ---------------------------------------- #

    def _get_soft(self, path: str) -> Any:
        """A GET that turns a MISSING resource into ``None`` and a transient/5xx into
        :class:`SurfaceUnavailableError` (``unverifiable``), instead of the hard
        :class:`CredentialError` :meth:`_get` raises. A 401 still hard-fails (re-mint).
        The target for the unverifiable record is the request ``path``.
        """
        try:
            return self._api(f"{self.base}{path}", self._token)
        except urllib.error.HTTPError as exc:
            if exc.code == 401:
                raise CredentialError(
                    "Woodpecker 401 — token expired; re-mint per MGF_INFRA §2."
                ) from exc
            if exc.code == 404:
                return None
            if exc.code in _UNVERIFIABLE_HTTP:
                raise SurfaceUnavailableError(path, f"woodpecker HTTP {exc.code}") from exc
            raise CredentialError(f"Woodpecker API HTTP {exc.code} on {path}") from exc
        except urllib.error.URLError as exc:
            raise SurfaceUnavailableError(path, f"unreachable ({exc.reason})") from exc

    # -- recent-pipeline scan (error-status-0-steps watch, SPEC-P4 §3) --------- #

    def recent_pipelines(self, repo_id: int, limit: int = 10) -> list[PipelineInfo]:
        data = self._get_soft(f"/api/repos/{repo_id}/pipelines?perPage={limit}&page=1")
        out: list[PipelineInfo] = []
        for p in data or []:
            status = str(p.get("status", ""))
            number = int(p.get("number") or 0)
            created = int(p.get("created") or p.get("created_at") or 0)
            finished = int(p.get("finished") or p.get("finished_at") or 0)
            steps = _step_count(p)
            # Only the rare error-status pipelines need a detail fetch to count steps;
            # a success/failure pipeline's step count is never classified (§3).
            if steps is None and status in ERROR_STATUSES:
                detail = self._get_soft(f"/api/repos/{repo_id}/pipelines/{number}")
                steps = _step_count(detail or {})
            out.append(
                PipelineInfo(
                    number=number,
                    status=status,
                    created=created,
                    step_count=steps or 0,
                    finished=finished,
                )
            )
        return out
