"""The read-only doctor — proves declared == deployed == consumed (P1 checks C1-C8).

**Strictly read-only against the world** (SPEC §1): the only inputs are the
registry, committed pipelines under the federation root, and read-only API/SSH
probes. Every finding carries evidence and NO secret value (AG-18) — C6 decodes a
JWT ``exp`` in-process but emits only the integer, never the token.

Checks are gated by the requested surfaces (default: woodpecker + local):
  woodpecker → C1 C2 C3(secrets) C4 C8 · forge → C5 C3(deploy-keys) ·
  local → C6 · hosts → C7.
"""

from __future__ import annotations

import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any

from .fsutil import jwt_exp as _jwt_exp
from .fsutil import nonblank_lines as _nonblank_lines
from .fsutil import octal_mode as _octal
from .model import ALL_MANAGED, Entry
from .pipeline import audit_refs, parse_pipeline
from .resolve import resolve_all_managed

if TYPE_CHECKING:
    from collections.abc import Sequence

    from .forge import ForgeAPI
    from .hosts import HostAPI
    from .registry import Registry
    from .woodpecker import WoodpeckerAPI

DEFAULT_SURFACES = ("woodpecker", "local")
ALL_SURFACES = ("woodpecker", "forge", "local", "hosts")
_SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2}
_SSH_KINDS = frozenset({"ssh-deploy-key", "ssh-keypair"})
#: Kinds whose local files are legitimately multi-line (not the doc-form leak class):
#: SSH key material, and openstack clouds.yaml / openrc (structured YAML config).
_MULTILINE_KINDS = _SSH_KINDS | frozenset({"openstack-appcred"})
_LEAK_SUFFIXES = (".bak",)
_LEAK_PREFIXES = ("old_", "old-")


@dataclass(frozen=True)
class Finding:
    id: str
    check: str
    severity: str
    finding: str
    evidence: dict[str, Any] = field(default_factory=dict)

    def to_json(self) -> dict[str, Any]:
        return {
            "id": self.id,
            "check": self.check,
            "severity": self.severity,
            "finding": self.finding,
            "evidence": self.evidence,
        }


@dataclass
class DoctorReport:
    surfaces: list[str]
    findings: list[Finding]
    skipped: list[str] = field(default_factory=list)

    def exit_code(self) -> int:
        return 1 if self.findings else 0

    def sorted_findings(self) -> list[Finding]:
        return sorted(
            self.findings, key=lambda f: (_SEVERITY_ORDER.get(f.severity, 9), f.check, f.id)
        )

    def to_json(self) -> dict[str, Any]:
        return {
            "surfaces": self.surfaces,
            "clean": not self.findings,
            "findings": [f.to_json() for f in self.sorted_findings()],
            "skipped": self.skipped,
        }


class Doctor:
    """Runs the P1 checks against injected clients. No client → its checks skip."""

    def __init__(
        self,
        registry: Registry,
        *,
        fed_root: Path,
        woodpecker: WoodpeckerAPI | None = None,
        forge: ForgeAPI | None = None,
        hosts: HostAPI | None = None,
    ) -> None:
        self.reg = registry
        self.fed_root = Path(fed_root)
        self.wp = woodpecker
        self.forge = forge
        self.hosts = hosts
        self._org_secrets: dict[str, frozenset[str]] | None = None
        self._repos: list[Any] | None = None
        self._repo_secrets: dict[int, dict[str, frozenset[str]]] = {}
        self._pipelines: dict[str, str] | None = None
        self.skipped: list[str] = []

    # -- cached live data ---------------------------------------------------- #

    def _org_secret_scopes(self) -> dict[str, frozenset[str]]:
        if self._org_secrets is None:
            self._org_secrets = self.wp.org_secrets() if self.wp else {}
        return self._org_secrets

    def _activated(self) -> list[Any]:
        if self._repos is None:
            self._repos = self.wp.activated_repos() if self.wp else []
        return self._repos

    def _repo_secret_scopes(self, repo_id: int) -> dict[str, frozenset[str]]:
        if repo_id not in self._repo_secrets:
            self._repo_secrets[repo_id] = self.wp.repo_secrets(repo_id) if self.wp else {}
        return self._repo_secrets[repo_id]

    def _pipeline_texts(self) -> dict[str, str]:
        """repo name -> committed ``.woodpecker.yml`` text (drift-check pattern)."""
        if self._pipelines is None:
            out: dict[str, str] = {}
            for repo in self._activated():
                wp = self.fed_root / repo.name / ".woodpecker.yml"
                if wp.is_file():
                    out[repo.name] = wp.read_text(encoding="utf-8")
                else:
                    self.skipped.append(f"{repo.name}: no local .woodpecker.yml")
            self._pipelines = out
        return self._pipelines

    # -- declared indexes ---------------------------------------------------- #

    def _wp_name(self, entry: Entry, surface: Any) -> str:
        return str(surface.get("name", entry.id))

    def _declared_org(self) -> dict[str, tuple[Entry, frozenset[str]]]:
        out: dict[str, tuple[Entry, frozenset[str]]] = {}
        for e in self.reg.with_surface("woodpecker-org-secret"):
            for s in e.surfaces_of("woodpecker-org-secret"):
                out[self._wp_name(e, s)] = (e, s.events)
        return out

    def _declared_repo(self) -> dict[tuple[str, str], tuple[Entry, frozenset[str]]]:
        out: dict[tuple[str, str], tuple[Entry, frozenset[str]]] = {}
        for e in self.reg.with_surface("woodpecker-repo-secret"):
            for s in e.surfaces_of("woodpecker-repo-secret"):
                out[(str(s["repo"]), self._wp_name(e, s))] = (e, s.events)
        return out

    def _held_ids(self) -> set[str]:
        return {e.id for e in self.reg if e.state == "held"}

    # -- runner -------------------------------------------------------------- #

    def run(self, surfaces: Sequence[str] | None = None) -> DoctorReport:
        want = set(surfaces or DEFAULT_SURFACES)
        findings: list[Finding] = []
        if "woodpecker" in want and self.wp is not None:
            findings += self.c1_declared_actual()
            findings += self.c2_consumed_declared()
            findings += self.c3_unregistered_secrets()
            findings += self.c4_held_discipline()
            findings += self.c8_policy()
        if "forge" in want and self.forge is not None:
            findings += self.c5_forge_coverage()
            findings += self.c3_unregistered_deploy_keys()
        if "local" in want:
            findings += self.c6_local_hygiene()
        if "hosts" in want and self.hosts is not None:
            findings += self.c7_hosts()
        return DoctorReport(surfaces=sorted(want), findings=findings, skipped=self.skipped)

    # -- C1 ------------------------------------------------------------------ #

    def c1_declared_actual(self) -> list[Finding]:
        out: list[Finding] = []
        org_live = self._org_secret_scopes()
        for name, (entry, declared) in self._declared_org().items():
            if entry.state != "active":
                continue
            if name not in org_live:
                out.append(
                    Finding(
                        entry.id,
                        "C1",
                        "high",
                        f"declared org secret {name!r} is MISSING from Woodpecker",
                        {"name": name, "declared_events": sorted(declared)},
                    )
                )
            elif org_live[name] != declared:
                out.append(
                    Finding(
                        entry.id,
                        "C1",
                        "high",
                        f"org secret {name!r} live scope != declared",
                        {
                            "name": name,
                            "declared_events": sorted(declared),
                            "live_events": sorted(org_live[name]),
                        },
                    )
                )
        repo_ids = {r.name: r.id for r in self._activated()}
        for (repo, name), (entry, declared) in self._declared_repo().items():
            if entry.state != "active" or repo not in repo_ids:
                continue
            live = self._repo_secret_scopes(repo_ids[repo])
            if name not in live:
                out.append(
                    Finding(
                        entry.id,
                        "C1",
                        "high",
                        f"declared repo secret {name!r} MISSING on {repo}",
                        {"name": name, "repo": repo, "declared_events": sorted(declared)},
                    )
                )
            elif live[name] != declared:
                out.append(
                    Finding(
                        entry.id,
                        "C1",
                        "high",
                        f"repo secret {name!r} on {repo} live scope != declared",
                        {
                            "name": name,
                            "repo": repo,
                            "declared_events": sorted(declared),
                            "live_events": sorted(live[name]),
                        },
                    )
                )
        return out

    # -- C2 ------------------------------------------------------------------ #

    def c2_consumed_declared(self) -> list[Finding]:
        out: list[Finding] = []
        org_live = self._org_secret_scopes()
        repo_ids = {r.name: r.id for r in self._activated()}
        held = self._held_ids()
        for repo, text in self._pipeline_texts().items():
            _, refs = parse_pipeline(text)
            # Held-entry references are the C4 domain (the held inversion); C2
            # reproduces the live-scope audit for everything else (CIR-02 parity).
            refs = [r for r in refs if r.secret not in held]
            allowed = dict(org_live)
            if repo in repo_ids:
                allowed.update(self._repo_secret_scopes(repo_ids[repo]))
            for sf in audit_refs(refs, allowed):
                sev = "high"
                fid = sf.secret
                ev: dict[str, Any]
                if sf.kind == "undefined":
                    msg = (
                        f"pipeline in {repo} references {sf.secret!r} on {sf.referenced} but it "
                        "is defined at NEITHER org nor repo level (injects empty)"
                    )
                    ev = {"repo": repo, "referenced": sf.referenced, "kind": "undefined"}
                else:
                    msg = (
                        f"pipeline in {repo} references {sf.secret!r} on {sorted(sf.bad_events)} "
                        f"but its live scope is only {sf.allowed}"
                    )
                    ev = {
                        "repo": repo,
                        "referenced": sf.referenced,
                        "kind": "scope",
                        "live_events": sf.allowed,
                        "bad_events": sorted(sf.bad_events),
                    }
                out.append(Finding(fid, "C2", sev, msg, ev))
        return out

    # -- C3 ------------------------------------------------------------------ #

    def c3_unregistered_secrets(self) -> list[Finding]:
        out: list[Finding] = []
        declared_org = set(self._declared_org())
        for name in sorted(self._org_secret_scopes()):
            if name not in declared_org:
                out.append(
                    Finding(
                        f"unregistered:{name}",
                        "C3",
                        "high",
                        f"live ORG secret {name!r} is absent from the registry (ungoverned)",
                        {"name": name, "scope": "org"},
                    )
                )
        declared_repo = self._declared_repo()
        for repo in self._activated():
            live = self._repo_secret_scopes(repo.id)
            for name in sorted(live):
                if (repo.name, name) not in declared_repo:
                    out.append(
                        Finding(
                            f"unregistered:{repo.name}:{name}",
                            "C3",
                            "high",
                            f"live REPO secret {name!r} on {repo.name} is absent from the "
                            "registry (ungoverned)",
                            {"name": name, "repo": repo.name, "scope": "repo"},
                        )
                    )
        return out

    def c3_unregistered_deploy_keys(self) -> list[Finding]:
        out: list[Finding] = []
        if self.forge is None:
            return out
        declared_titles = {
            str(s["title"])
            for e in self.reg.with_surface("forge-deploy-key")
            for s in e.surfaces_of("forge-deploy-key")
        }
        for repo in self._sampled_forge_repos():
            for key in self.forge.repo_deploy_keys(repo):
                if key.title not in declared_titles:
                    out.append(
                        Finding(
                            f"unregistered-key:{repo}:{key.title}",
                            "C3",
                            "high",
                            f"undeclared deploy key {key.title!r} on {repo} (steward-class)",
                            {
                                "repo": repo,
                                "title": key.title,
                                "fingerprint": key.fingerprint,
                                "read_only": key.read_only,
                            },
                        )
                    )
        return out

    def _sampled_forge_repos(self) -> list[str]:
        managed = resolve_all_managed(
            fed_root=self.fed_root, activated_repos=[r.name for r in self._activated()]
        )
        return sorted(managed)

    # -- C4 ------------------------------------------------------------------ #

    def c4_held_discipline(self) -> list[Finding]:
        out: list[Finding] = []
        org_live = self._org_secret_scopes()
        repo_ids = {r.name: r.id for r in self._activated()}
        # (a) a held entry must not exist as a live Woodpecker secret.
        for name, (entry, _) in self._declared_org().items():
            if entry.state == "held" and name in org_live:
                out.append(
                    Finding(
                        entry.id,
                        "C4",
                        "high",
                        f"HELD entry {name!r} is ARMED as a live org secret — the founder's "
                        "hold is in force; un-hold deliberately, never by arming",
                        {"name": name, "scope": "org", "live_events": sorted(org_live[name])},
                    )
                )
        for (repo, name), (entry, _) in self._declared_repo().items():
            if entry.state == "held" and repo in repo_ids:
                live = self._repo_secret_scopes(repo_ids[repo])
                if name in live:
                    out.append(
                        Finding(
                            entry.id,
                            "C4",
                            "high",
                            f"HELD entry {name!r} is ARMED as a live repo secret on {repo}",
                            {"name": name, "repo": repo, "live_events": sorted(live[name])},
                        )
                    )
        # (b) pipelines referencing held (or undefined) secrets — the CIR-33 shape.
        held = self._held_ids()
        for repo, text in self._pipeline_texts().items():
            _, refs = parse_pipeline(text)
            seen: set[str] = set()
            for r in refs:
                if r.secret in held and r.secret not in seen:
                    seen.add(r.secret)
                    out.append(
                        Finding(
                            r.secret,
                            "C4",
                            "medium",
                            f"pipeline in {repo} references HELD secret {r.secret!r} "
                            "(deploy path held — reported, not fixed)",
                            {"repo": repo, "referenced": sorted(r.events), "state": "held"},
                        )
                    )
        return out

    # -- C5 ------------------------------------------------------------------ #

    def c5_forge_coverage(self) -> list[Finding]:
        out: list[Finding] = []
        if self.forge is None:
            return out
        fleet_keys = [
            (e, s)
            for e in self.reg.with_surface("forge-deploy-key")
            for s in e.surfaces_of("forge-deploy-key")
            if s.get("repos") == ALL_MANAGED and e.state == "active"
        ]
        if not fleet_keys:
            return out
        managed = self._sampled_forge_repos()
        for repo in managed:
            keys = self.forge.repo_deploy_keys(repo)
            titles = {k.title for k in keys}
            fprints = {k.fingerprint for k in keys if k.fingerprint}
            for entry, surface in fleet_keys:
                title = str(surface["title"])
                if title not in titles:
                    out.append(
                        Finding(
                            entry.id,
                            "C5",
                            "high",
                            f"repo {repo} is MISSING fleet deploy key {title!r} (CIR-34 "
                            "coverage gap)",
                            {"repo": repo, "title": title},
                        )
                    )
                elif surface.get("fingerprint") and str(surface["fingerprint"]) not in fprints:
                    out.append(
                        Finding(
                            entry.id,
                            "C5",
                            "medium",
                            f"repo {repo} carries {title!r} with a mismatched fingerprint",
                            {
                                "repo": repo,
                                "title": title,
                                "declared_fingerprint": str(surface["fingerprint"]),
                            },
                        )
                    )
        return out

    # -- C6 ------------------------------------------------------------------ #

    def c6_local_hygiene(self) -> list[Finding]:
        out: list[Finding] = []
        repos_with_local: set[str] = set()
        for entry in self.reg.with_surface("local-file"):
            for s in entry.surfaces_of("local-file"):
                repo = str(s["repo"])
                repos_with_local.add(repo)
                rel = str(s["path"])
                declared_mode = str(s["mode"])
                full = self.fed_root / repo / "my_stuff" / rel
                if not full.exists():
                    out.append(
                        Finding(
                            entry.id,
                            "C6",
                            "medium",
                            f"declared local-file {repo}/my_stuff/{rel} is MISSING",
                            {"repo": repo, "path": rel},
                        )
                    )
                    continue
                actual = _octal(full)
                if actual and actual.lstrip("0") != declared_mode.lstrip("0"):
                    out.append(
                        Finding(
                            entry.id,
                            "C6",
                            "medium",
                            f"{repo}/my_stuff/{rel} mode {actual} != declared {declared_mode}",
                            {
                                "repo": repo,
                                "path": rel,
                                "actual_mode": actual,
                                "declared_mode": declared_mode,
                            },
                        )
                    )
                # doc-form: a bare-value file that is actually multi-line.
                if entry.kind not in _MULTILINE_KINDS and not s.get("multiline_ok"):
                    nlines = _nonblank_lines(full)
                    if nlines > 1:
                        out.append(
                            Finding(
                                entry.id,
                                "C6",
                                "medium",
                                f"{repo}/my_stuff/{rel} looks like a DOC-FORM file "
                                f"({nlines} non-blank lines; bare-value expected) — the "
                                "cat-a-doc leak class",
                                {"repo": repo, "path": rel, "nonblank_lines": nlines},
                            )
                        )
                # JWT exp decode (in-process; only the integer leaves).
                if entry.kind == "jwt-minted":
                    out += self._jwt_expiry(entry, full, f"{repo}/my_stuff/{rel}")
        # my_stuff dir hygiene + gitignore + leftover scan, per referenced repo.
        for repo in sorted(repos_with_local):
            out += self._my_stuff_hygiene(repo)
        return out

    def _jwt_expiry(self, entry: Entry, path: Path, label: str) -> list[Finding]:
        exp = _jwt_exp(path)
        if exp is None:
            return []
        now = time.time()
        if exp < now:
            return [
                Finding(
                    entry.id,
                    "C6",
                    "high",
                    f"{label}: JWT is EXPIRED",
                    {"path": label, "exp": int(exp), "expired": True},
                )
            ]
        if exp - now <= 7 * 86400:
            return [
                Finding(
                    entry.id,
                    "C6",
                    "medium",
                    f"{label}: JWT expires in ≤7 days",
                    {"path": label, "exp": int(exp), "days_left": int((exp - now) // 86400)},
                )
            ]
        return []

    def _my_stuff_hygiene(self, repo: str) -> list[Finding]:
        out: list[Finding] = []
        base = self.fed_root / repo / "my_stuff"
        if not base.is_dir():
            return out
        top_mode = _octal(base)
        if top_mode and top_mode.lstrip("0") != "700":
            out.append(
                Finding(
                    f"hygiene:{repo}",
                    "C6",
                    "medium",
                    f"{repo}/my_stuff is mode {top_mode}, want 0700",
                    {"repo": repo, "actual_mode": top_mode},
                )
            )
        gi = self.fed_root / repo / ".gitignore"
        if not gi.is_file() or "my_stuff" not in gi.read_text(encoding="utf-8"):
            out.append(
                Finding(
                    f"hygiene:{repo}",
                    "C6",
                    "high",
                    f"{repo}/.gitignore does NOT cover my_stuff/",
                    {"repo": repo},
                )
            )
        for p in sorted(base.rglob("*")):
            if not p.is_file():
                continue
            n = p.name
            if n.endswith(_LEAK_SUFFIXES) or n.startswith(_LEAK_PREFIXES):
                rel = p.relative_to(self.fed_root / repo)
                out.append(
                    Finding(
                        f"leftover:{repo}:{rel}",
                        "C6",
                        "low",
                        f"credential leftover {rel} in {repo}/my_stuff (.bak/old_* — prune)",
                        {"repo": repo, "path": str(rel)},
                    )
                )
        return out

    # -- C7 ------------------------------------------------------------------ #

    def c7_hosts(self) -> list[Finding]:
        out: list[Finding] = []
        if self.hosts is None:
            return out
        for entry in self.reg.with_surface("host-file"):
            for s in entry.surfaces_of("host-file"):
                host, path = str(s["host"]), str(s["path"])
                st = self.hosts.stat(host, path)
                if st.status == "missing":
                    out.append(
                        Finding(
                            entry.id,
                            "C7",
                            "high",
                            f"declared host-file {host}:{path} is MISSING",
                            {"host": host, "path": path},
                        )
                    )
                    continue
                if st.status == "unreachable":
                    # Present-but-unverifiable — NEVER page as missing (the
                    # false-signal class this program kills). Recorded, not a finding.
                    self.skipped.append(
                        f"{entry.id} {host}:{path} unverifiable ({st.detail or 'unreachable'})"
                    )
                    continue
                want_mode, want_owner = str(s["mode"]), str(s["owner"])
                if st.mode and st.mode.lstrip("0") != want_mode.lstrip("0"):
                    out.append(
                        Finding(
                            entry.id,
                            "C7",
                            "medium",
                            f"{host}:{path} mode {st.mode} != declared {want_mode}",
                            {
                                "host": host,
                                "path": path,
                                "actual_mode": st.mode,
                                "declared_mode": want_mode,
                            },
                        )
                    )
                if st.owner != want_owner:
                    out.append(
                        Finding(
                            entry.id,
                            "C7",
                            "medium",
                            f"{host}:{path} owner {st.owner} != declared {want_owner}",
                            {
                                "host": host,
                                "path": path,
                                "actual_owner": st.owner,
                                "declared_owner": want_owner,
                            },
                        )
                    )
        return out

    # -- C8 ------------------------------------------------------------------ #

    def c8_policy(self) -> list[Finding]:
        out: list[Finding] = []
        for repo, text in self._pipeline_texts().items():
            _, refs = parse_pipeline(text)
            for r in {ref.secret for ref in refs}:
                entry = self.reg.get(r)
                if entry is None:
                    continue
                allowed = entry.policy.allowed_repos
                if allowed == "*" or allowed is None:
                    continue
                allow_list = allowed if isinstance(allowed, list) else [allowed]
                if repo not in allow_list:
                    out.append(
                        Finding(
                            entry.id,
                            "C8",
                            "medium",
                            f"secret {r!r} referenced in {repo}, outside allowed_repos "
                            f"{allow_list}",
                            {"repo": repo, "allowed_repos": allow_list},
                        )
                    )
        return out
