"""The paging-doctor cron core (SPEC-P4 §1) — route a doctor report to the estate.

On a finding OR an ``unverifiable`` the run files/updates the **deduped** Codeberg
issue (the same mechanism ``estate-watch.sh`` uses — no new secret distributed); on
a clean run it closes any standing issue. On every COMPLETED run it heartbeats to
OpenObserve so the dead-man's-switch (`estate-watch-heartbeats.sh` CHECKS) pages if
the doctor cron ever stops (SPEC-P4 §4).

Value-free by construction (AG-18): the summary + heartbeat payload carry NO secret
value — only check counts, entry ids, and the doctor's own value-free finding text.
The HTTP transport + the pager/heartbeat are injectable Protocols, so the routing is
unit-tested with fakes; the real transports are stdlib ``urllib``.
"""

from __future__ import annotations

import json
import socket
import urllib.error
import urllib.parse
import urllib.request
from collections import Counter
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Protocol

if TYPE_CHECKING:
    from .doctor import DoctorReport

#: The stable title substring the deduped estate issue is matched by (the ``q=``
#: search key). Keep it constant — it IS the dedupe identity across cron runs.
DEDUPE_QUERY = "secret-doctor"
ISSUE_TITLE = "[estate] secret-doctor: credential drift detected"
HEARTBEAT_JOB = "secret-doctor"
DEFAULT_ISSUES_REPO = "magogi-admin/mgf-standard"
DEFAULT_FORGE_BASE = "https://codeberg.org"
#: How many top offenders to name in the value-free summary body.
TOP_N = 12

#: (method, url, headers, body) -> (status_code, response_text).
HttpFn = Callable[[str, str, dict[str, str], bytes | None], "tuple[int, str]"]


def _urllib_http(
    method: str, url: str, headers: dict[str, str], body: bytes | None
) -> tuple[int, str]:
    req = urllib.request.Request(url, data=body, method=method, headers=headers)  # noqa: S310
    try:
        with urllib.request.urlopen(req, timeout=20) as resp:  # noqa: S310 (fixed https hosts)
            return int(resp.status), resp.read().decode("utf-8", "replace")
    except urllib.error.HTTPError as exc:
        return int(exc.code), exc.read().decode("utf-8", "replace")


# --------------------------------------------------------------------------- #
# Value-free summary.
# --------------------------------------------------------------------------- #


def summarize(report: DoctorReport) -> tuple[str, str]:
    """(title, body) for the deduped issue — VALUE-FREE (AG-18).

    The body lists the PAGEABLE (un-acknowledged) findings + unverifiable — an
    acknowledged/deferred finding is not paged (only noted in the count). It never
    serialises a finding's ``evidence`` dict verbatim (defence in depth), only the
    doctor's own value-free message text.
    """
    findings = [f for f in report.sorted_findings() if not f.acknowledged]
    acknowledged = len(report.acknowledged_findings())
    unverifiable = report.sorted_unverifiable()
    by_check = Counter(f.check for f in findings)
    lines = [
        f"mgf-secrets doctor: {len(findings)} pageable finding(s), "
        f"{len(unverifiable)} unverifiable, {acknowledged} acknowledged (not paged).",
        f"surfaces: {', '.join(report.surfaces)}",
    ]
    if by_check:
        lines.append("by check: " + ", ".join(f"{c}:{by_check[c]}" for c in sorted(by_check)))
    if report.unverifiable:
        u_checks = Counter(u.check for u in unverifiable)
        lines.append(
            "unverifiable by check: " + ", ".join(f"{c}:{u_checks[c]}" for c in sorted(u_checks))
        )
    lines.append("")
    for f in findings[:TOP_N]:
        lines.append(f"  [{f.severity}] {f.check} {f.id}: {f.finding}")
    for u in unverifiable[:TOP_N]:
        lines.append(f"  [unverifiable] {u.check} {u.target}: {u.reason}")
    extra = (
        len(findings)
        + len(unverifiable)
        - min(len(findings), TOP_N)
        - min(len(unverifiable), TOP_N)
    )
    if extra > 0:
        lines.append(f"  … and {extra} more (run `mgf-secrets doctor --json` for the full set).")
    lines.append("")
    lines.append("Value-free summary (AG-18) — this issue is deduped + auto-updated by the")
    lines.append(
        "agent2 secret-doctor cron; it closes automatically when the doctor next runs clean."
    )
    return ISSUE_TITLE, "\n".join(lines)


# --------------------------------------------------------------------------- #
# Pager (deduped Codeberg issue) + heartbeat (OpenObserve).
# --------------------------------------------------------------------------- #


class Pager(Protocol):
    def report(self, *, dedupe_query: str, title: str, body: str) -> str: ...
    def resolve(self, *, dedupe_query: str, comment: str) -> str: ...


class Heartbeat(Protocol):
    def beat(self, job: str) -> bool: ...


class CodebergIssuePager:
    """Files/updates + closes ONE standing deduped issue on ``magogi-admin/mgf-standard``.

    Replicates the ``estate-watch.sh`` contract (Forgejo/Gitea ``Authorization: token``
    auth; dedupe by an open-issue title-substring search) and extends it: an existing
    open issue is UPDATED (PATCH body) rather than left stale, and a clean run CLOSES
    it — so a re-scoped secret clears the page on the next run (SPEC-P4 §4).
    """

    def __init__(
        self,
        *,
        token: str,
        repo: str = DEFAULT_ISSUES_REPO,
        base_url: str = DEFAULT_FORGE_BASE,
        http: HttpFn = _urllib_http,
    ) -> None:
        self._token = token
        self.repo = repo
        self.base = base_url.rstrip("/")
        self._http = http

    def _headers(self, *, json_body: bool = False) -> dict[str, str]:
        h = {"Authorization": f"token {self._token}"}
        if json_body:
            h["Content-Type"] = "application/json"
        return h

    def _open_issue_number(self, query: str) -> int | None:
        q = urllib.parse.quote(query)
        url = f"{self.base}/api/v1/repos/{self.repo}/issues?state=open&type=issues&q={q}"
        status, text = self._http("GET", url, self._headers(), None)
        if status != 200 or not text.strip():
            return None
        try:
            issues = json.loads(text)
        except json.JSONDecodeError:
            return None
        for issue in issues if isinstance(issues, list) else []:
            if query in str(issue.get("title", "")):
                return int(issue["number"])
        return None

    def report(self, *, dedupe_query: str, title: str, body: str) -> str:
        num = self._open_issue_number(dedupe_query)
        if num is None:
            payload = json.dumps({"title": title, "body": body}).encode()
            self._http(
                "POST",
                f"{self.base}/api/v1/repos/{self.repo}/issues",
                self._headers(json_body=True),
                payload,
            )
            return "filed"
        payload = json.dumps({"body": body}).encode()
        self._http(
            "PATCH",
            f"{self.base}/api/v1/repos/{self.repo}/issues/{num}",
            self._headers(json_body=True),
            payload,
        )
        return "updated"

    def resolve(self, *, dedupe_query: str, comment: str) -> str:
        num = self._open_issue_number(dedupe_query)
        if num is None:
            return "none"
        self._http(
            "POST",
            f"{self.base}/api/v1/repos/{self.repo}/issues/{num}/comments",
            self._headers(json_body=True),
            json.dumps({"body": comment}).encode(),
        )
        self._http(
            "PATCH",
            f"{self.base}/api/v1/repos/{self.repo}/issues/{num}",
            self._headers(json_body=True),
            json.dumps({"state": "closed"}).encode(),
        )
        return "closed"


class OOHeartbeat:
    """POST a success heartbeat to OpenObserve (the estate ``backup_heartbeats`` stream).

    Payload shape matches ``self-heal.sh`` ``post_heartbeat`` exactly:
    ``[{"job":..,"status":"ok","host":..,"ts":..}]``. The auth header value is the
    token file's content verbatim (it already carries its scheme). FAIL-OPEN: a failed
    POST returns ``False`` and never raises — the dead-man's-switch pages on the missed
    heartbeat, so the job must not die trying to report liveness.
    """

    def __init__(self, *, url: str, token: str, http: HttpFn = _urllib_http) -> None:
        self.url = url
        self._token = token
        self._http = http

    def beat(self, job: str) -> bool:
        ts = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
        payload = json.dumps(
            [{"job": job, "status": "ok", "host": socket.gethostname(), "ts": ts}]
        ).encode()
        headers = {"Authorization": self._token, "Content-Type": "application/json"}
        try:
            status, _ = self._http("POST", self.url, headers, payload)
        except OSError:
            return False
        return 200 <= status < 300


# --------------------------------------------------------------------------- #
# Routing.
# --------------------------------------------------------------------------- #


@dataclass
class WatchOutcome:
    clean: bool  # clean-FOR-PAGING: no pageable finding + no unverifiable
    paged: bool
    pager_action: str  # filed | updated | closed | none | suppressed
    heartbeat_sent: bool
    acknowledged: int  # findings suppressed by an active ack (reported, not paged)
    title: str
    body: str

    def to_json(self) -> dict[str, object]:
        return {
            "clean": self.clean,
            "paged": self.paged,
            "pager_action": self.pager_action,
            "heartbeat_sent": self.heartbeat_sent,
            "acknowledged": self.acknowledged,
            "title": self.title,
        }


def run_watch(
    report: DoctorReport,
    *,
    pager: Pager | None,
    heartbeat: Heartbeat | None,
    job: str = HEARTBEAT_JOB,
    page: bool = True,
    heartbeat_on: str = "always",
) -> WatchOutcome:
    """Route a doctor report (SPEC-P4 §1 + the ack governor).

    * ANY UN-acknowledged finding OR ``unverifiable`` → file/update the deduped issue.
    * clean-FOR-PAGING (every finding acknowledged + 0 unverifiable) → close any
      standing issue; the run summary notes "N acknowledged (not paged)". A deferred
      finding is reported by the doctor but never pages (SPEC-P4 ack governor) — so a
      known-deferred state does not train the operator to ignore the pager.
    * heartbeat: ``heartbeat_on="always"`` (default) beats on every completed run so
      the dead-man's-switch means exactly "the cron stopped" — a drift-finding run
      stays distinguishable from a dead cron (see the module note / DEVIATION-1).
      ``heartbeat_on="clean"`` restores the SPEC's literal "heartbeat only on clean".

    ``page=False`` is the PAGE=0 escape hatch: compute + return the summary but issue
    no pager call (fire-testing the routing without paging the founder).
    """
    pageable = report.pageable()  # un-acked finding or unverifiable
    clean = not pageable
    acknowledged = len(report.acknowledged_findings())
    title, body = summarize(report)
    pager_action = "suppressed"
    paged = False
    if page and pager is not None:
        if clean:
            note = (
                f"secret-doctor: clean-for-paging ({acknowledged} acknowledged); closing."
                if acknowledged
                else "secret-doctor: clean run — declared == deployed == consumed; closing."
            )
            pager_action = pager.resolve(dedupe_query=DEDUPE_QUERY, comment=note)
        else:
            pager_action = pager.report(dedupe_query=DEDUPE_QUERY, title=title, body=body)
            paged = True
    heartbeat_sent = False
    if heartbeat is not None and (heartbeat_on == "always" or clean):
        heartbeat_sent = heartbeat.beat(job)
    return WatchOutcome(
        clean=clean,
        paged=paged,
        pager_action=pager_action,
        heartbeat_sent=heartbeat_sent,
        acknowledged=acknowledged,
        title=title,
        body=body,
    )
