"""Per-kind liveness probes (P1 — read-only, non-destructive, value-free).

Every registered ``kind`` MUST have a probe handler here — the kind-extension
meta-test (SPEC §7.6) fails the suite otherwise, so the library cannot grow an
unverifiable branch. P1 probes are conservative: they confirm a credential is
present *where declared* at the metadata level (a live-file exists at its mode,
a JWT is unexpired, a Woodpecker/forge surface exists), never reading or emitting
a value. Network-dependent liveness deepens in P2; with no client a probe returns
``skipped`` rather than a false finding.
"""

from __future__ import annotations

import time
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any

from .context import Context
from .fsutil import jwt_exp, octal_mode
from .model import KINDS, Entry

if TYPE_CHECKING:
    from pathlib import Path

_OK = "ok"
_FINDING = "finding"
_SKIPPED = "skipped"
#: The endpoint could not be reached, so the credential's liveness is UNKNOWN.
#: Deliberately distinct from both ``ok`` and ``finding``: a probe that cannot
#: look must never report clean (the false-clean class — F-36), and an
#: unreachable host is not evidence that a credential is bad.
_UNVERIFIABLE = "unverifiable"


@dataclass
class ProbeResult:
    id: str
    kind: str
    status: str  # ok | finding | skipped | unverifiable
    message: str
    evidence: dict[str, Any] = field(default_factory=dict)

    def exit_code(self) -> int:
        return 1 if self.status == _FINDING else 0

    def to_json(self) -> dict[str, Any]:
        return {
            "id": self.id,
            "kind": self.kind,
            "status": self.status,
            "message": self.message,
            "evidence": self.evidence,
        }


Handler = Callable[[Entry, Context], ProbeResult]


# --------------------------------------------------------------------------- #
# Shared building blocks (metadata only).
# --------------------------------------------------------------------------- #


def _local_files(entry: Entry, ctx: Context) -> list[tuple[Path, str]]:
    out: list[tuple[Path, str]] = []
    for s in entry.surfaces_of("local-file"):
        full = ctx.fed_root / str(s["repo"]) / "my_stuff" / str(s["path"])
        out.append((full, str(s["mode"])))
    return out


def _probe_local_presence(entry: Entry, ctx: Context) -> ProbeResult:
    files = _local_files(entry, ctx)
    if not files:
        return ProbeResult(
            entry.id, entry.kind, _SKIPPED, "no local-file surface to check (P1 metadata probe)"
        )
    missing = [str(p) for p, _ in files if not p.exists()]
    if missing:
        return ProbeResult(
            entry.id, entry.kind, _FINDING, "declared local file(s) missing", {"missing": missing}
        )
    return ProbeResult(
        entry.id,
        entry.kind,
        _OK,
        "declared local file(s) present",
        {"files": [str(p) for p, _ in files]},
    )


# --------------------------------------------------------------------------- #
# Per-kind handlers.
# --------------------------------------------------------------------------- #


def probe_ssh_deploy_key(entry: Entry, ctx: Context) -> ProbeResult:
    for s in entry.surfaces_of("forge-deploy-key"):
        title = str(s["title"])
        if ctx.forge is None:
            return ProbeResult(
                entry.id,
                entry.kind,
                _SKIPPED,
                f"forge deploy-key {title!r}: need --surface forge / forge client",
            )
        return ProbeResult(
            entry.id,
            entry.kind,
            _OK,
            f"forge deploy-key {title!r} declared; coverage via doctor C5",
            {"title": title},
        )
    return _probe_local_presence(entry, ctx)


def probe_ssh_keypair(entry: Entry, ctx: Context) -> ProbeResult:
    return _probe_local_presence(entry, ctx)


def probe_api_token(entry: Entry, ctx: Context) -> ProbeResult:
    # No non-destructive value-free liveness call in P1 — presence + hygiene only.
    return _probe_local_presence(entry, ctx)


def probe_jwt_minted(entry: Entry, ctx: Context) -> ProbeResult:
    for path, _mode in _local_files(entry, ctx):
        if not path.exists():
            continue
        exp = jwt_exp(path)
        if exp is None:
            return ProbeResult(
                entry.id,
                entry.kind,
                _SKIPPED,
                "local file present but not a decodable JWT (may be a minter)",
            )
        now = time.time()
        if exp < now:
            return ProbeResult(entry.id, entry.kind, _FINDING, "JWT is EXPIRED", {"exp": int(exp)})
        return ProbeResult(
            entry.id,
            entry.kind,
            _OK,
            "JWT valid",
            {"exp": int(exp), "days_left": int((exp - now) // 86400)},
        )
    return ProbeResult(
        entry.id, entry.kind, _SKIPPED, "jwt-minted: minted on demand; no static file to decode"
    )


def probe_password(entry: Entry, ctx: Context) -> ProbeResult:
    return _probe_local_presence(entry, ctx)


def probe_oauth_app(entry: Entry, ctx: Context) -> ProbeResult:
    return _probe_host_or_local(entry, ctx)


def probe_service_account(entry: Entry, ctx: Context) -> ProbeResult:
    return _probe_host_or_local(entry, ctx)


def probe_openstack_appcred(entry: Entry, ctx: Context) -> ProbeResult:
    return _probe_local_presence(entry, ctx)


def _probe_host_or_local(entry: Entry, ctx: Context) -> ProbeResult:
    host_surfaces = entry.surfaces_of("host-file", "server-env")
    if host_surfaces and ctx.hosts is not None:
        missing: list[str] = []
        unverifiable: list[str] = []
        for s in host_surfaces:
            st = ctx.hosts.stat(str(s["host"]), str(s["path"]))
            if st.status == "missing":
                missing.append(f"{s['host']}:{s['path']}")
            elif st.status == "unreachable":
                unverifiable.append(f"{s['host']}:{s['path']}")
        if missing:
            return ProbeResult(
                entry.id,
                entry.kind,
                _FINDING,
                "declared host file(s) MISSING",
                {"missing": missing},
            )
        if unverifiable:
            return ProbeResult(
                entry.id,
                entry.kind,
                _SKIPPED,
                "declared host file(s) present but unverifiable (permission denied)",
                {"unverifiable": unverifiable},
            )
        return ProbeResult(entry.id, entry.kind, _OK, "declared host file(s) present")
    if host_surfaces:
        return ProbeResult(
            entry.id, entry.kind, _SKIPPED, "host-file surface: need --surface hosts / SSH client"
        )
    return _probe_local_presence(entry, ctx)


# --------------------------------------------------------------------------- #
# P2 — LIVE probes. Does the stored value actually authenticate?
#
# The P1 probes above answer "is a credential present where declared". That is a
# metadata question, and it cannot tell a working value from a dead one — which is
# exactly how `devpi_password` sat in the store returning 401 while its probe
# reported OK, discovered only when a release publish failed on 2026-07-30.
#
# These answer the harder question, and only when asked (`probe --live`): they make
# network calls, so they are opt-in and never run in the default path. Three rules,
# each learned the hard way this session:
#
#   1. UNREACHABLE IS `unverifiable`, never `ok`. A probe that could not look must
#      not report clean.
#   2. THE TEST MUST DISCRIMINATE. `/healthz` returns 200 for every token including
#      a wrong one; asserting on it proves nothing. Each probe below hits an
#      endpoint where a bad credential gives a *different* answer than a good one.
#   3. NO VALUE LEAVES. The secret is read into a local, put in a header, and never
#      logged, echoed, or placed in a message — including error paths, which is how
#      F-39 leaked a token through an exception.
# --------------------------------------------------------------------------- #

_LIVE_TIMEOUT = 20


def _http_status(url: str, *, headers: dict[str, str], method: str = "GET") -> int | None:
    """Status code for one request, or None when the host could not be reached.

    ``None`` is the whole point: it is what turns into ``unverifiable`` upstream.
    Errors are swallowed WITHOUT their message because a urllib exception can
    render the request — headers included — into its text (F-39, 2026-07-27).
    """
    import urllib.error
    import urllib.request

    req = urllib.request.Request(url, method=method)  # noqa: S310 - https, fixed callers
    for k, v in headers.items():
        req.add_header(k, v)
    try:
        with urllib.request.urlopen(req, timeout=_LIVE_TIMEOUT) as r:  # noqa: S310
            return int(r.status)
    except urllib.error.HTTPError as exc:
        return int(exc.code)  # an auth rejection IS the answer we want
    except Exception:
        return None


def _http_status_body(url: str, *, body: bytes, headers: dict[str, str]) -> int | None:
    """POST variant of :func:`_http_status`. Same contract: ``None`` = unreachable,
    and the exception is never rendered (it can carry the request body)."""
    import urllib.error
    import urllib.request

    req = urllib.request.Request(url, data=body, method="POST")  # noqa: S310 - https, fixed callers
    for k, v in headers.items():
        req.add_header(k, v)
    try:
        with urllib.request.urlopen(req, timeout=_LIVE_TIMEOUT) as r:  # noqa: S310
            return int(r.status)
    except urllib.error.HTTPError as exc:
        return int(exc.code)
    except Exception:
        return None


def _live_result(
    entry: Entry, code: int | None, *, ok_codes: tuple[int, ...], what: str
) -> ProbeResult:
    if code is None:
        return ProbeResult(
            entry.id,
            entry.kind,
            _UNVERIFIABLE,
            f"{what}: endpoint unreachable — liveness UNKNOWN, not clean",
        )
    if code in ok_codes:
        return ProbeResult(
            entry.id, entry.kind, _OK, f"{what}: authenticated (HTTP {code})", {"http_status": code}
        )
    if code in (401, 403):
        return ProbeResult(
            entry.id,
            entry.kind,
            _FINDING,
            f"{what}: the stored value did NOT authenticate (HTTP {code}) — "
            "present but dead; re-mint and `mgf-secrets set`",
            {"http_status": code},
        )
    return ProbeResult(
        entry.id,
        entry.kind,
        _UNVERIFIABLE,
        f"{what}: unexpected HTTP {code} — cannot conclude",
        {"http_status": code},
    )


def _live_forge_auth(entry: Entry, value: str) -> ProbeResult:
    """Codeberg REST. Discriminates: a repo read is 200 with a valid token, 401 without.

    Deliberately NOT `/user`: this family's tokens are least-privilege
    (write:repository + write:issue) and correctly get 403 there, so `/user` would
    report a healthy token as dead.
    """
    code = _http_status(
        "https://codeberg.org/api/v1/repos/magogi-admin/mgf-standard",
        headers={"Authorization": f"token {value}"},
    )
    return _live_result(entry, code, ok_codes=(200,), what="forge-auth")


def _live_devpi_login(entry: Entry, value: str) -> ProbeResult:
    """devpi ``POST /+login``. Discriminates: 200 for a good password, 401 for bad.

    NOT ``/+api``: that endpoint returns **200 for every caller** — wrong password,
    empty password, no Authorization header at all. Probing it would have reported
    a garbage credential as healthy, which is the same defect as asserting on a
    ``/healthz``. Measured 2026-07-30 before this handler was written, precisely
    because the first draft of this function *did* use ``/+api`` and passed.
    """
    import json as _json

    body = _json.dumps({"user": "magogi", "password": value}).encode()
    code = _http_status_body(
        "https://195.15.203.250.sslip.io/+login",
        body=body,
        headers={"Content-Type": "application/json"},
    )
    return _live_result(entry, code, ok_codes=(200,), what="devpi-login")


def _live_oo_ingest(entry: Entry, value: str) -> ProbeResult:
    """OpenObserve ingest. Discriminates: POST is 204 with the token, 401 without.

    GET would prove nothing — the collector answers 405 for every caller, valid or
    not. Verified 2026-07-30: 204 with the real token, 401 with a wrong one and
    with none.
    """
    code = _http_status(
        "https://195.15.203.114.sslip.io/healthz",
        headers={"Authorization": f"Bearer {value}"},
    )
    if code is None:
        return _live_result(entry, None, ok_codes=(), what="oo-ingest")
    # /healthz proves reachability only; the discriminating call is the POST.
    import json as _json
    import urllib.error
    import urllib.request

    body = _json.dumps(
        [
            {
                "level": "INFO",
                "app": "mgf-secrets",
                "message": "probe --live liveness check",
                "job": "secrets-probe",
            }
        ]
    ).encode()
    req = urllib.request.Request("https://195.15.203.114.sslip.io/ingest", data=body, method="POST")
    req.add_header("Authorization", f"Bearer {value}")
    req.add_header("Content-Type", "application/json")
    try:
        with urllib.request.urlopen(req, timeout=_LIVE_TIMEOUT) as r:  # noqa: S310
            post = int(r.status)
    except urllib.error.HTTPError as exc:
        post = int(exc.code)
    except Exception:
        post = None
    return _live_result(entry, post, ok_codes=(200, 204), what="oo-ingest")


#: Declared ``verify.probe`` name → live handler. A declared probe with no entry
#: here is reported as *not implemented* by name rather than silently passing —
#: a checker must state the scope it actually covered.
LIVE_PROBES: dict[str, Callable[[Entry, str], ProbeResult]] = {
    "forge-auth": _live_forge_auth,
    "devpi-login": _live_devpi_login,
    "oo-ingest": _live_oo_ingest,
}


def run_live_probe(entry: Entry, value: str) -> ProbeResult:
    """Run the entry's DECLARED ``verify.probe`` against the real endpoint.

    The declared name may be compound (``devpi-login+org-scope-match``); the part
    before ``+`` selects the handler and the remainder is reported as not covered,
    so a partial check never reads as a whole one.
    """
    declared = (entry.verify or {}).get("probe") if isinstance(entry.verify, dict) else None
    if not declared:
        return ProbeResult(entry.id, entry.kind, _SKIPPED, "no verify.probe declared")
    head, _, rest = str(declared).partition("+")
    handler = LIVE_PROBES.get(head)
    if handler is None:
        return ProbeResult(
            entry.id,
            entry.kind,
            _SKIPPED,
            f"declared probe {declared!r} has no live implementation — NOT checked",
        )
    result = handler(entry, value)
    if rest:
        result.evidence["not_covered"] = rest
        result.message += f" (declared '+{rest}' not covered by this probe)"
    return result


#: The registry the meta-test checks: every kind → a handler.
PROBES: dict[str, Handler] = {
    "ssh-deploy-key": probe_ssh_deploy_key,
    "ssh-keypair": probe_ssh_keypair,
    "api-token": probe_api_token,
    "jwt-minted": probe_jwt_minted,
    "password": probe_password,
    "oauth-app": probe_oauth_app,
    "service-account": probe_service_account,
    "openstack-appcred": probe_openstack_appcred,
}

assert set(PROBES) == set(KINDS), "every kind must have a probe handler"


def run_probe(entry: Entry, ctx: Context) -> ProbeResult:
    handler = PROBES.get(entry.kind)
    if handler is None:  # pragma: no cover - guarded by the module-level assert
        return ProbeResult(entry.id, entry.kind, _SKIPPED, f"no probe handler for {entry.kind!r}")
    # Optionally sanity-check declared local modes (metadata; no value read).
    result = handler(entry, ctx)
    if result.status == _OK:
        drift = _mode_drift(entry, ctx)
        if drift:
            result.evidence["mode_note"] = drift
    return result


def _mode_drift(entry: Entry, ctx: Context) -> str | None:
    for path, mode in _local_files(entry, ctx):
        actual = octal_mode(path) if path.exists() else None
        if actual and actual.lstrip("0") != mode.lstrip("0"):
            return f"{path.name}: mode {actual} != declared {mode}"
    return None
