"""``sync`` — the reconciler: make declared (registry) == deployed, per surface.

Terraform-shaped and **plan-first** (``--plan`` is the default; ``--apply`` is
explicit). Per surface it reconciles the registry's declared truth onto the live
world:

* **woodpecker** — create a missing org/repo secret; set the exact declared event
  scope; set the declared **image restrictions** (D-7). A create injects the
  store value via the API body (:func:`mgf.secrets.store.store_get`) — the value
  is never printed, logged, or placed in an exception (AG-18).
* **forge** — ensure every repo the entry's ``repos`` glob covers (incl.
  ``ALL_MANAGED``) carries the declared deploy key (the CIR-34 coverage sweep).
  Serial + throttled by the underlying client (PAPER-110). The registered key is
  PUBLIC material.
* **hosts** — enforce a declared file's mode + owner over SSH (never contents in
  P3 — perms/owner only).
* **local** — delegate to the existing :mod:`mgf.secrets.materialize`.

Properties (all tested, SPEC §2):

* **Never deletes silently** — the plan is forward-only (create / update / register
  / chmod / chown / materialize). An undeclared live secret is a doctor C3 finding,
  never a sync removal. Removing a declared credential is the ``retire`` verb's job
  (``state = "retiring"`` + ``--allow-retire``), never a side effect of sync.
* **Held is inviolable** — a ``state = "held"`` entry is never armed (absorbs the A2
  hold). It is reported as a held note, never planned.
* **Idempotent** — ``plan → apply → plan`` yields an empty plan on unchanged inputs.
* **Contract-surface playbook** — an ``--apply`` that touches a cross-family contract
  surface (an org secret) runs the secret-change playbook itself: a re-plan scope
  audit → the "fire one pipeline per event class" reminder → the sooth canary.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any

from . import materialize as mtz
from .errors import CredentialError, SurfaceUnavailableError
from .managed import normalize_repos, resolve_all_managed_detail

if TYPE_CHECKING:
    from collections.abc import Callable, Sequence
    from pathlib import Path

    from .forge import ForgeAdmin
    from .hosts import HostAdmin
    from .model import Entry, Surface
    from .registry import Registry
    from .woodpecker import WoodpeckerAdmin

ALL_SYNC_SURFACES = ("woodpecker", "forge", "hosts", "local")
DEFAULT_SYNC_SURFACES = ("woodpecker", "forge", "hosts", "local")


# --------------------------------------------------------------------------- #
# Plan / apply data model — value-free throughout (AG-18).
# --------------------------------------------------------------------------- #


@dataclass(frozen=True)
class SyncAction:
    """One reconciling operation. ``detail`` is value-free: names, scopes,
    fingerprints, modes, owners — never a secret value."""

    surface: str  # woodpecker | forge | hosts | local
    op: str  # create-secret | update-secret | register-deploy-key | chmod | chown | materialize
    id: str  # registry entry id
    target: str  # human target, e.g. "org:devpi_password" / "mgf-uitest-web/ci-clone-…"
    detail: dict[str, Any] = field(default_factory=dict)

    def to_json(self) -> dict[str, Any]:
        return {
            "surface": self.surface,
            "op": self.op,
            "id": self.id,
            "target": self.target,
            "detail": self.detail,
        }

    def line(self) -> str:
        return f"{self.op:<20} {self.surface:<11} {self.target}"


@dataclass(frozen=True)
class SyncNote:
    """A non-action observation: a held entry (never armed), a retiring/skipped
    entry, or a refusal pointer. Informational — never affects idempotence."""

    kind: str  # held | retiring | skip | refuse | unverifiable
    id: str
    message: str

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


@dataclass
class SyncPlan:
    surfaces: list[str]
    actions: list[SyncAction] = field(default_factory=list)
    notes: list[SyncNote] = field(default_factory=list)

    @property
    def is_empty(self) -> bool:
        return not self.actions

    def sorted_actions(self) -> list[SyncAction]:
        return sorted(self.actions, key=lambda a: (a.surface, a.op, a.target))

    def to_json(self) -> dict[str, Any]:
        return {
            "surfaces": self.surfaces,
            "empty": self.is_empty,
            "actions": [a.to_json() for a in self.sorted_actions()],
            "notes": [n.to_json() for n in self.notes],
        }


@dataclass
class CanaryResult:
    ok: bool
    detail: str

    def to_json(self) -> dict[str, Any]:
        return {"ok": self.ok, "detail": self.detail}


@dataclass
class PlaybookResult:
    """The secret-change playbook run after a contract-surface apply (SPEC §2.4)."""

    triggered: bool
    audit_clean: bool
    audit_residual: list[dict[str, Any]] = field(default_factory=list)
    reminder: str = "fire one pipeline per event class for each changed secret"
    canary_ran: bool = False
    canary_ok: bool | None = None
    canary_detail: str = ""

    def to_json(self) -> dict[str, Any]:
        return {
            "triggered": self.triggered,
            "audit_clean": self.audit_clean,
            "audit_residual": self.audit_residual,
            "reminder": self.reminder,
            "canary_ran": self.canary_ran,
            "canary_ok": self.canary_ok,
            "canary_detail": self.canary_detail,
        }


@dataclass
class ApplyReport:
    surfaces: list[str]
    applied: list[SyncAction] = field(default_factory=list)
    failed: list[dict[str, str]] = field(default_factory=list)
    notes: list[SyncNote] = field(default_factory=list)
    playbook: PlaybookResult | None = None

    def ok(self) -> bool:
        return not self.failed

    def to_json(self) -> dict[str, Any]:
        return {
            "surfaces": self.surfaces,
            "applied": [a.to_json() for a in self.applied],
            "failed": self.failed,
            "notes": [n.to_json() for n in self.notes],
            "playbook": self.playbook.to_json() if self.playbook else None,
        }


# --------------------------------------------------------------------------- #
# The reconciler.
# --------------------------------------------------------------------------- #


class Reconciler:
    """Plan + apply reconciliation against injected admin clients. A missing client
    means its surface is not reconciled (its actions are simply absent)."""

    def __init__(
        self,
        registry: Registry,
        *,
        fed_root: Path,
        store_root: Path | None = None,
        identity_path: str | Path | None = None,
        woodpecker: WoodpeckerAdmin | None = None,
        forge: ForgeAdmin | None = None,
        hosts: HostAdmin | None = None,
        value_provider: Callable[[str], bytes] | None = None,
        key_provider: Callable[[str], str] | None = None,
        canary: Callable[[], CanaryResult] | None = None,
    ) -> None:
        self.reg = registry
        self.fed_root = fed_root
        self.store_root = store_root
        self.identity_path = identity_path
        self.wp = woodpecker
        self.forge = forge
        self.hosts = hosts
        self._value_provider = value_provider
        self._key_provider = key_provider
        self.canary = canary
        self._repo_ids: dict[str, int] | None = None
        self._repo_states_cache: dict[int, dict[str, Any]] = {}
        self._managed: set[str] | None = None
        #: title -> PUBLIC key material, captured from any repo that already carries
        #: the titled key during the coverage sweep (the register pubkey source).
        self._pubkey_by_title: dict[str, str] = {}

    # -- providers (value/key resolved lazily, never at plan time) ----------- #

    def _value(self, entry_id: str) -> bytes:
        if self._value_provider is not None:
            return self._value_provider(entry_id)
        from .store import store_get, store_root

        root = (
            self.store_root if self.store_root is not None else store_root(fed_root=self.fed_root)
        )
        return store_get(entry_id, root=root, identity_path=self.identity_path)

    def _deploy_pubkey(self, entry_id: str, title: str) -> str:
        """Source the PUBLIC key to register a deploy key (never the private half).

        Order (SPEC-P3 FIX-1): an explicit injected provider (tests) → **the same
        titled key already carried by another repo** (captured during the coverage
        sweep; the fleet clone key's private half lives in a Woodpecker secret, NOT
        the store) → a declared ``.pub`` local-file surface on the entry. A deploy-key
        registration never requires a store entry for the key.
        """
        if self._key_provider is not None:
            return self._key_provider(entry_id)
        reused = self._pubkey_by_title.get(title)
        if reused:
            return reused
        from_file = self._pubkey_from_local_file(entry_id)
        if from_file:
            return from_file
        raise CredentialError(
            f"cannot source the public key for deploy key {title!r}: no repo carries it yet "
            "(nothing to reuse), no --key provider, and no declared .pub local-file surface. "
            "Register it once by hand, or declare a *.pub local-file surface."
        )

    def _pubkey_from_local_file(self, entry_id: str) -> str | None:
        """A declared ``.pub`` local-file surface on the entry, read from disk (the
        public key is not a secret). Returns the stripped key text, or None."""
        entry = self.reg.get(entry_id)
        if entry is None:
            return None
        for s in entry.surfaces_of("local-file"):
            rel = str(s.get("path", ""))
            if rel.endswith(".pub"):
                f = self.fed_root / str(s["repo"]) / "my_stuff" / rel
                if f.is_file():
                    return f.read_text(encoding="utf-8").strip()
        return None

    # -- plan ---------------------------------------------------------------- #

    def plan(self, surfaces: Sequence[str] | None = None) -> SyncPlan:
        want = list(surfaces or DEFAULT_SYNC_SURFACES)
        plan = SyncPlan(surfaces=sorted(set(want)))
        if "woodpecker" in want and self.wp is not None:
            self._plan_woodpecker(plan)
        if "forge" in want and self.forge is not None:
            self._plan_forge(plan)
        if "hosts" in want and self.hosts is not None:
            self._plan_hosts(plan)
        if "local" in want:
            self._plan_local(plan)
        return plan

    # -- woodpecker ---------------------------------------------------------- #

    def _activated_ids(self) -> dict[str, int]:
        if self._repo_ids is None:
            self._repo_ids = {r.name: r.id for r in self.wp.activated_repos()} if self.wp else {}
        return self._repo_ids

    def _repo_states(self, repo_id: int) -> dict[str, Any]:
        if repo_id not in self._repo_states_cache:
            self._repo_states_cache[repo_id] = (
                self.wp.repo_secret_states(repo_id) if self.wp else {}
            )
        return self._repo_states_cache[repo_id]

    def _plan_woodpecker(self, plan: SyncPlan) -> None:
        assert self.wp is not None
        org_live = self.wp.org_secret_states()
        repo_ids = self._activated_ids()
        for entry in self.reg.with_surface("woodpecker-org-secret", "woodpecker-repo-secret"):
            if entry.state == "held":
                plan.notes.append(
                    SyncNote("held", entry.id, "held — sync never arms it (the founder's hold)")
                )
                continue
            if entry.state != "active":
                plan.notes.append(
                    SyncNote(
                        entry.state, entry.id, f"{entry.state} — not reconciled (see `retire`)"
                    )
                )
                continue
            for s in entry.surfaces_of("woodpecker-org-secret"):
                self._plan_wp_secret(plan, entry, s, scope="org", live=org_live, repo_id=None)
            for s in entry.surfaces_of("woodpecker-repo-secret"):
                repo = str(s["repo"])
                if repo not in repo_ids:
                    plan.notes.append(
                        SyncNote("skip", entry.id, f"repo {repo!r} not activated on Woodpecker")
                    )
                    continue
                rid = repo_ids[repo]
                self._plan_wp_secret(
                    plan,
                    entry,
                    s,
                    scope="repo",
                    live=self._repo_states(rid),
                    repo_id=rid,
                    repo=repo,
                )

    def _plan_wp_secret(
        self,
        plan: SyncPlan,
        entry: Entry,
        surface: Surface,
        *,
        scope: str,
        live: dict[str, Any],
        repo_id: int | None,
        repo: str | None = None,
    ) -> None:
        name = str(surface.get("name", entry.id))
        declared_events = frozenset(surface.events)
        target = f"{scope}:{name}" if scope == "org" else f"repo:{name}@{repo}"
        cur = live.get(name)
        base: dict[str, Any] = {"scope": scope, "name": name}
        if repo_id is not None:
            base["repo"], base["repo_id"] = repo, repo_id
        if cur is None:
            plan.actions.append(
                SyncAction(
                    "woodpecker",
                    "create-secret",
                    entry.id,
                    target,
                    # A new secret carries no image restriction (SPEC-P3 FIX-2: sync no
                    # longer manages images — Woodpecker rejects dotted tags, and a
                    # fleet-wide `python` restriction isolates nothing).
                    {**base, "events": sorted(declared_events), "images": []},
                )
            )
            return
        # Sync reconciles EVENT SCOPES only. It never plans an image change; on a
        # scope update it echoes back the LIVE images so an existing (out-of-band)
        # restriction is preserved, never cleared.
        if cur.events != declared_events:
            plan.actions.append(
                SyncAction(
                    "woodpecker",
                    "update-secret",
                    entry.id,
                    target,
                    {
                        **base,
                        "events": sorted(declared_events),
                        "images": list(cur.images),
                        "was_events": sorted(cur.events),
                    },
                )
            )

    # -- forge --------------------------------------------------------------- #

    def _managed_repos(self) -> set[str]:
        """The ``ALL_MANAGED`` fleet, resolved once per run (shared by every
        coverage entry so the sweep is stable within a plan)."""
        if self._managed is None:
            activated = list(self._activated_ids())
            self._managed = set(
                resolve_all_managed_detail(fed_root=self.fed_root, activated_repos=activated).repos
            )
        return self._managed

    def _target_repos(self, repos_decl: Any) -> set[str]:
        # ONE shared parser with the doctor's C5 (mgf.secrets.managed.normalize_repos):
        # handles ALL_MANAGED, a comma-separated string (the live steward-key shape),
        # and a list — never mistaking "a,b,c" for one literal repo.
        return normalize_repos(repos_decl, managed=self._managed_repos())

    def _plan_forge(self, plan: SyncPlan) -> None:
        assert self.forge is not None
        for entry in self.reg.with_surface("forge-deploy-key"):
            if entry.state != "active":
                kind = "held" if entry.state == "held" else entry.state
                plan.notes.append(
                    SyncNote(kind, entry.id, f"{entry.state} — deploy key not reconciled")
                )
                continue
            for s in entry.surfaces_of("forge-deploy-key"):
                title = str(s["title"])
                readonly = bool(s["readonly"])
                # Fetch each target repo's keys once. A repo we cannot read is
                # 'unverifiable' — noted, never assumed absent (the determinism rule).
                repo_keys: dict[str, list[Any]] = {}
                for repo in sorted(self._target_repos(s.get("repos"))):
                    try:
                        repo_keys[repo] = self.forge.repo_deploy_keys(repo)
                    except SurfaceUnavailableError as exc:
                        plan.notes.append(
                            SyncNote(
                                "unverifiable",
                                entry.id,
                                f"{repo}: coverage unverifiable ({exc.reason}) — not assumed absent",
                            )
                        )
                # A deploy key's identity is its PUBLIC-KEY FINGERPRINT, not its
                # title: a repo may legitimately carry the same key under a different
                # title (mgf-uitest-web's 'woodpecker-ci-clone' IS the fleet key).
                # Matching by title alone both false-flags such a repo AND 422s on
                # apply (re-adding an already-present key). Capture the reference
                # fingerprint + pubkey from any repo that carries this titled key.
                ref_fp = ""
                for keys in repo_keys.values():
                    for k in keys:
                        if k.title == title:
                            if k.fingerprint and not ref_fp:
                                ref_fp = k.fingerprint
                            if k.key and title not in self._pubkey_by_title:
                                self._pubkey_by_title[title] = k.key
                # Covered = carries the key by fingerprint (content) OR exact title.
                for repo, keys in repo_keys.items():
                    covered = any(
                        (ref_fp and k.fingerprint == ref_fp) or k.title == title for k in keys
                    )
                    if not covered:
                        plan.actions.append(
                            SyncAction(
                                "forge",
                                "register-deploy-key",
                                entry.id,
                                f"{repo}/{title}",
                                {"repo": repo, "title": title, "readonly": readonly},
                            )
                        )

    # -- hosts --------------------------------------------------------------- #

    def _plan_hosts(self, plan: SyncPlan) -> None:
        assert self.hosts is not None
        for entry in self.reg.with_surface("host-file"):
            if entry.state != "active":
                plan.notes.append(
                    SyncNote(entry.state, entry.id, f"{entry.state} — host-file not reconciled")
                )
                continue
            for s in entry.surfaces_of("host-file"):
                host, path = str(s["host"]), str(s["path"])
                want_mode, want_owner = str(s["mode"]), str(s["owner"])
                st = self.hosts.stat(host, path)
                if st.status == "missing":
                    plan.notes.append(
                        SyncNote(
                            "skip",
                            entry.id,
                            f"{host}:{path} MISSING — sync does not push host contents in P3 "
                            "(perms/owner only); create it deliberately",
                        )
                    )
                    continue
                if st.status == "unreachable":
                    plan.notes.append(
                        SyncNote(
                            "unverifiable", entry.id, f"{host}:{path} unverifiable ({st.detail})"
                        )
                    )
                    continue
                if st.mode and st.mode.lstrip("0") != want_mode.lstrip("0"):
                    plan.actions.append(
                        SyncAction(
                            "hosts",
                            "chmod",
                            entry.id,
                            f"{host}:{path}",
                            {"host": host, "path": path, "mode": want_mode, "was_mode": st.mode},
                        )
                    )
                if st.owner and st.owner != want_owner:
                    plan.actions.append(
                        SyncAction(
                            "hosts",
                            "chown",
                            entry.id,
                            f"{host}:{path}",
                            {
                                "host": host,
                                "path": path,
                                "owner": want_owner,
                                "was_owner": st.owner,
                            },
                        )
                    )

    # -- local (delegate to materialize) ------------------------------------- #

    def _plan_local(self, plan: SyncPlan) -> None:
        from .store import store_root as _sr

        root = self.store_root if self.store_root is not None else _sr(fed_root=self.fed_root)
        items = mtz.plan(self.reg, fed_root=self.fed_root, root=root)
        for pi in items:
            if pi.status == mtz.STATUS_HELD:
                plan.notes.append(
                    SyncNote("held", pi.id, f"{pi.target}: held — never materialized")
                )
            elif pi.status == mtz.STATUS_NO_STORE:
                plan.notes.append(SyncNote("skip", pi.id, f"{pi.target}: no stored value yet"))
            elif pi.pending:
                plan.actions.append(
                    SyncAction(
                        "local",
                        "materialize",
                        pi.id,
                        str(pi.target),
                        {"mode": pi.declared_mode, "status": pi.status},
                    )
                )

    # -- apply --------------------------------------------------------------- #

    def apply(
        self,
        surfaces: Sequence[str] | None = None,
        *,
        allow_retire: bool = False,
        skip_canary: bool = False,
    ) -> ApplyReport:
        # allow_retire is accepted for interface stability: P3 sync is forward-only
        # (never deletes), so removing a credential stays the `retire` verb's job.
        _ = allow_retire
        plan = self.plan(surfaces)
        report = ApplyReport(surfaces=plan.surfaces, notes=plan.notes)
        touched_contract = False
        local_ids: list[str] = []
        for action in plan.sorted_actions():
            if action.surface == "local":
                local_ids.append(action.id)
                continue
            try:
                self._apply_action(action)
            except (CredentialError, SurfaceUnavailableError) as exc:
                report.failed.append({"target": action.target, "op": action.op, "error": str(exc)})
                continue
            report.applied.append(action)
            if action.surface == "woodpecker" and action.detail.get("scope") == "org":
                touched_contract = True
        if local_ids:
            self._apply_local(local_ids, report)
        if touched_contract:
            report.playbook = self._run_playbook(skip_canary=skip_canary)
        return report

    def _apply_action(self, action: SyncAction) -> None:
        if action.surface == "woodpecker":
            self._apply_woodpecker(action)
        elif action.surface == "forge":
            self._apply_forge(action)
        elif action.surface == "hosts":
            self._apply_hosts(action)

    def _apply_woodpecker(self, action: SyncAction) -> None:
        assert self.wp is not None
        d = action.detail
        name = str(d["name"])
        events = frozenset(d["events"])
        images = tuple(str(i) for i in d["images"])
        if d["scope"] == "org":
            if action.op == "create-secret":
                self.wp.create_org_secret(name, self._value(action.id), events, images)
            else:
                self.wp.update_org_secret(name, events=events, images=images)
        else:
            rid = int(d["repo_id"])
            if action.op == "create-secret":
                self.wp.create_repo_secret(rid, name, self._value(action.id), events, images)
            else:
                self.wp.update_repo_secret(rid, name, events=events, images=images)

    def _apply_forge(self, action: SyncAction) -> None:
        assert self.forge is not None
        d = action.detail
        title = str(d["title"])
        self.forge.register_deploy_key(
            str(d["repo"]),
            title=title,
            key=self._deploy_pubkey(action.id, title),
            read_only=bool(d["readonly"]),
        )

    def _apply_hosts(self, action: SyncAction) -> None:
        assert self.hosts is not None
        d = action.detail
        ok = (
            self.hosts.chmod(str(d["host"]), str(d["path"]), str(d["mode"]))
            if action.op == "chmod"
            else self.hosts.chown(str(d["host"]), str(d["path"]), str(d["owner"]))
        )
        if not ok:
            raise CredentialError(f"{action.op} failed on {action.target} (sudo -n unavailable?)")

    def _apply_local(self, ids: list[str], report: ApplyReport) -> None:
        from .store import store_root as _sr

        root = self.store_root if self.store_root is not None else _sr(fed_root=self.fed_root)
        items = [
            pi
            for pi in mtz.plan(self.reg, fed_root=self.fed_root, root=root, only=ids)
            if pi.pending
        ]
        result = mtz.apply(
            items, fed_root=self.fed_root, root=root, identity_path=self.identity_path
        )
        for wid in result.written:
            report.applied.append(
                SyncAction("local", "materialize", wid, wid, {"status": "written"})
            )

    def _run_playbook(self, *, skip_canary: bool) -> PlaybookResult:
        """The secret-change playbook (SPEC §2.4): re-plan scope audit → the
        event-class reminder → the sooth canary."""
        residual = self.plan(["woodpecker"])
        pb = PlaybookResult(
            triggered=True,
            audit_clean=residual.is_empty,
            audit_residual=[a.to_json() for a in residual.sorted_actions()],
        )
        if skip_canary:
            pb.canary_detail = "skipped (--skip-canary; a real surface-1 apply must run it)"
            return pb
        if self.canary is not None:
            result = self.canary()
            pb.canary_ran = True
            pb.canary_ok = result.ok
            pb.canary_detail = result.detail
        else:
            pb.canary_detail = "no canary configured"
        return pb
