"""Codeberg forge client (read-only) — the PAPER-110-budgeted surface.

Deploy-key coverage (doctor C5) is the only P1 forge use, and it is opt-in
(``--surface forge``). Codeberg is rate-limited, so this client is **serial,
≤1 req/s, single pass, disk-cached** — never parallel (SPEC §5 C5, §8). The HTTP
layer is injectable so C5's fixtures run offline. Token: ``$CODEBERG_TOKEN`` /
``$FORGE_TOKEN`` (extract per the house recipe — never ``cat`` a doc-form file);
absent → a :class:`CredentialError` that points at the recipe. No value printed.
"""

from __future__ import annotations

import base64
import json
import os
import threading
import time
import urllib.error
import urllib.request
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Protocol

from .errors import CredentialError, SurfaceUnavailableError

if TYPE_CHECKING:
    from pathlib import Path

DEFAULT_FORGE_URL = "https://codeberg.org"
DEFAULT_OWNER = "magogi-admin"

#: HTTP statuses that mean "couldn't look" (rate-limited / transient), not "no keys".
#: A 429 or 5xx is unverifiable (SPEC §4) — never silently an empty key list.
_UNVERIFIABLE_HTTP = frozenset({429, 500, 502, 503, 504})

ApiFn = Callable[..., Any]


@dataclass(frozen=True)
class DeployKey:
    title: str
    fingerprint: str
    read_only: bool
    #: The PUBLIC key material (``ssh-… …``) the forge returns for this key. Public,
    #: safe to reuse — sync sources a missing repo's registration from a repo that
    #: already carries the same titled key (never the private half from the store).
    key: str = ""


class ForgeAPI(Protocol):
    def repo_deploy_keys(self, repo: str) -> list[DeployKey]: ...
    #: Fetch a repo's RAW COMMITTED file (default ``.woodpecker.yml``) — the full
    #: pipeline (all events + ``from_secret`` refs), the no-checkout consumed-check
    #: source (SPEC-P4 §1). ``None`` when the file does not exist (404 → not-consumed).
    def repo_file(self, repo: str, path: str = ...) -> str | None: ...


class ForgeAdmin(ForgeAPI, Protocol):
    """Read + the one write ``sync`` performs: register a (public) deploy key.

    The registered ``key`` is PUBLIC key material (safe to send/commit) — never the
    private half. The CIR-34 coverage sweep uses this to add the fleet clone key to
    a repo missing it.
    """

    def register_deploy_key(self, repo: str, *, title: str, key: str, read_only: bool) -> None: ...


def resolve_forge_token(explicit: str | None = None) -> str:
    if explicit:
        return explicit
    for var in ("CODEBERG_TOKEN", "FORGE_TOKEN"):
        val = os.environ.get(var)
        if val:
            return val
    raise CredentialError(
        "no Codeberg token — set $CODEBERG_TOKEN (extract per the house recipe, "
        "court/reviews/2026-07-16-mgf-uitest-web/ENSHRINE-RUNBOOK.md; never cat a "
        "doc-form token file)."
    )


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
    headers = {"Authorization": f"token {token}"}
    if payload is not None:
        headers["Content-Type"] = "application/json"
    req = urllib.request.Request(url, data=data, method=method, headers=headers)  # noqa: S310
    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 _decode_contents(data: Any) -> str | None:
    """Decode a Codeberg contents-API payload (``{"content": "<b64>", "encoding":
    "base64"}``) into the raw file text. ``None`` when there is no content."""
    if not isinstance(data, dict):
        return None
    content = data.get("content")
    if content is None:
        return None
    if data.get("encoding") == "base64":
        return base64.b64decode(content).decode("utf-8", "replace")
    return str(content)


class CodebergForgeClient:
    """Serial, rate-limited, disk-cached deploy-key reader (PAPER-110).

    The deploy-key sweep (C5) stays serial + 1 req/s throttled. The no-checkout
    config-fetch (:meth:`repo_file`, C2) is safe to call CONCURRENTLY (bounded
    parallelism, SPEC-P4 cron-perf): the per-run budget counter is lock-guarded and
    the request itself is not serial-throttled, so N workers overlap latency. Both
    share the same disk-cache TTL + budget + 429→``unverifiable`` degrade.
    """

    def __init__(
        self,
        *,
        owner: str = DEFAULT_OWNER,
        token: str | None = None,
        base_url: str = DEFAULT_FORGE_URL,
        min_interval: float = 1.0,
        cache_dir: Path | None = None,
        cache_ttl: float | None = None,
        budget: int | None = None,
        api: ApiFn = _default_api,
    ) -> None:
        self.owner = owner
        self.base = base_url.rstrip("/")
        self._token = resolve_forge_token(token)
        self._min_interval = min_interval
        self._api = api
        self._last_call = 0.0
        self._mem: dict[str, list[DeployKey]] = {}
        self._cache_dir = cache_dir
        #: Disk-cache freshness (SPEC-P4 §7b). A cache file older than ``cache_ttl``
        #: seconds is ignored + re-fetched, so a cron reuses a recent sweep (0 live
        #: calls) but never serves indefinitely-stale coverage. ``None`` = never expire.
        self._cache_ttl = cache_ttl
        #: Per-RUN live-fetch budget (SPEC-P4 §7b). Once ``budget`` network reads have
        #: happened, further un-cached reads degrade to :class:`SurfaceUnavailableError`
        #: (``unverifiable``, never silently-absent) so a cron's Codeberg call count is
        #: structurally bounded, not merely "documented". ``None`` = unbounded.
        self._budget = budget
        #: Count of LIVE (network) fetches this run — cache hits do not count. The cron
        #: reads this to report the per-run Codeberg budget actually spent.
        self.live_calls = 0
        #: Guards the budget check + ``live_calls`` update so the parallel config-fetch
        #: (:meth:`repo_file`) is race-free.
        self._lock = threading.Lock()
        #: Per-run in-memory cache of committed-file text (``repo:path`` -> text | None).
        self._file_mem: dict[str, str | None] = {}
        if cache_dir is not None:
            cache_dir.mkdir(parents=True, exist_ok=True)

    def _throttle(self) -> None:
        delta = time.monotonic() - self._last_call
        if delta < self._min_interval:
            time.sleep(self._min_interval - delta)
        self._last_call = time.monotonic()

    def _spend_budget(self, target: str) -> None:
        """Atomically claim one live-fetch from the per-run budget, or degrade to
        ``unverifiable`` (never silently absent) when it is exhausted."""
        with self._lock:
            if self._budget is not None and self.live_calls >= self._budget:
                raise SurfaceUnavailableError(
                    target, f"forge call budget exhausted ({self._budget}/run) — unverifiable"
                )
            self.live_calls += 1

    def _cache_fresh(self, cache_file: Path) -> bool:
        """A disk-cache file is usable iff it exists and (no TTL, or within TTL)."""
        if not cache_file.is_file():
            return False
        if self._cache_ttl is None:
            return True
        return (time.time() - cache_file.stat().st_mtime) <= self._cache_ttl

    def repo_deploy_keys(self, repo: str) -> list[DeployKey]:
        if repo in self._mem:
            return self._mem[repo]
        cache_file = self._cache_dir / f"{repo}.json" if self._cache_dir else None
        if cache_file is not None and self._cache_fresh(cache_file):
            raw = json.loads(cache_file.read_text(encoding="utf-8"))
            keys = [DeployKey(**k) for k in raw]
            self._mem[repo] = keys
            return keys
        # A live read is required — enforce the per-run budget BEFORE spending it, then
        # honour the serial ≤1 req/s throttle (deploy-key coverage stays serial).
        self._spend_budget(repo)
        self._throttle()
        try:
            data = self._api(f"{self.base}/api/v1/repos/{self.owner}/{repo}/keys", self._token)
        except urllib.error.HTTPError as exc:
            if exc.code == 401:
                raise CredentialError(
                    "Codeberg 401 — token invalid/expired (house recipe)."
                ) from exc
            if exc.code == 404:
                return []  # repo has no keys endpoint reachable / no keys
            if exc.code in _UNVERIFIABLE_HTTP:
                # Rate-limited / transient: this repo's coverage could not be READ —
                # unverifiable, never silently an empty (== "no fleet key") list.
                raise SurfaceUnavailableError(repo, f"forge HTTP {exc.code}") from exc
            raise CredentialError(f"Codeberg API HTTP {exc.code} for {repo}") from exc
        except urllib.error.URLError as exc:
            raise SurfaceUnavailableError(repo, f"unreachable ({exc.reason})") from exc
        keys = [
            DeployKey(
                title=str(k.get("title", "")),
                fingerprint=str(k.get("fingerprint", "")),
                read_only=bool(k.get("read_only", True)),
                key=str(k.get("key", "")),
            )
            for k in (data or [])
        ]
        self._mem[repo] = keys
        if cache_file is not None:
            cache_file.write_text(
                json.dumps([k.__dict__ for k in keys], indent=2), encoding="utf-8"
            )
        return keys

    def repo_file(self, repo: str, path: str = ".woodpecker.yml") -> str | None:
        """The repo's RAW COMMITTED file text via the contents API — the full pipeline
        (all events + every ``from_secret``), identical to a local checkout. This is the
        no-checkout consumed-check source (SPEC-P4 §1, ``forge-raw``): Woodpecker's
        per-pipeline config is EVENT-RENDERED (it strips ``from_secret`` structure +
        event-filtered steps), so a rendered config false-cleans the consumed-check.

        Budgeted + disk-cached like the deploy-key sweep (PAPER-110 §7b). A 404 → ``None``
        (no committed file: not-consumed, never a finding); a 429/5xx →
        :class:`SurfaceUnavailableError` (``unverifiable``, never silently absent). Safe
        under bounded parallelism (the budget/counter is lock-guarded; no serial throttle).
        """
        key = f"{repo}:{path}"
        if key in self._file_mem:
            return self._file_mem[key]
        safe = path.replace("/", "_")
        cache_file = self._cache_dir / f"cfg-{repo}-{safe}.txt" if self._cache_dir else None
        if cache_file is not None and self._cache_fresh(cache_file):
            text = cache_file.read_text(encoding="utf-8")
            self._file_mem[key] = text
            return text
        self._spend_budget(repo)
        try:
            data = self._api(
                f"{self.base}/api/v1/repos/{self.owner}/{repo}/contents/{path}", self._token
            )
        except urllib.error.HTTPError as exc:
            if exc.code == 401:
                raise CredentialError(
                    "Codeberg 401 — token invalid/expired (house recipe)."
                ) from exc
            if exc.code == 404:
                self._file_mem[key] = None  # no committed file -> not-consumed (per-run memo)
                return None
            if exc.code in _UNVERIFIABLE_HTTP:
                raise SurfaceUnavailableError(repo, f"forge HTTP {exc.code}") from exc
            raise CredentialError(f"Codeberg API HTTP {exc.code} for {repo}:{path}") from exc
        except urllib.error.URLError as exc:
            raise SurfaceUnavailableError(repo, f"unreachable ({exc.reason})") from exc
        content = _decode_contents(data)
        self._file_mem[key] = content
        if cache_file is not None and content is not None:
            cache_file.write_text(content, encoding="utf-8")
        return content

    def register_deploy_key(self, repo: str, *, title: str, key: str, read_only: bool) -> None:
        """Register a PUBLIC deploy key on ``repo`` (sync --apply; CIR-34 coverage).

        ``key`` is public key material — never the private half. Serial + throttled
        per the PAPER-110 budget. Invalidates this repo's cached key list so a
        follow-up read (idempotence re-plan) sees the new key.
        """
        self._throttle()
        try:
            self._api(
                f"{self.base}/api/v1/repos/{self.owner}/{repo}/keys",
                self._token,
                "POST",
                {"title": title, "key": key, "read_only": read_only},
            )
        except urllib.error.HTTPError as exc:
            if exc.code == 401:
                raise CredentialError(
                    "Codeberg 401 on write — token invalid (house recipe)."
                ) from exc
            raise CredentialError(
                f"Codeberg API HTTP {exc.code} registering key on {repo}"
            ) from exc
        except urllib.error.URLError as exc:
            raise SurfaceUnavailableError(repo, f"unreachable ({exc.reason})") from exc
        self._mem.pop(repo, None)
        if self._cache_dir is not None:
            cache_file = self._cache_dir / f"{repo}.json"
            if cache_file.is_file():
                cache_file.unlink()
