"""Off-host durability backends for a vault.

The vault is *always* a local git repo: ``VaultWriter`` serializes every write
under one lock and commits it (history, atomicity, the audit floor — see
``gitops.py``). A durability backend decides only what happens BEYOND that local
commit — whether, and how, the committed state is mirrored **off-host** and
reconciled back:

  - ``git-remote`` (default): ``git push`` / ``pull --rebase`` to a configured
    git remote — ANY host (GitHub, GitLab, Gitea, self-hosted SSH). This is the
    historical behavior; absent config selects it, so existing vaults are
    unchanged.
  - ``volume``: local-only. The vault is a git repo on a durable volume but is
    never mirrored off-host — the volume (and its snapshots) is the durability.
  - ``object-store``: ``git bundle --all`` → S3/R2/Backblaze/MinIO — durable
    with NO git host at all (one bucket, per-tenant prefix, one set of host
    credentials; the multi-tenant path). Single-writer by design: the bundle is
    a backup, not a collaboration point — a vault whose owner ALSO edits it
    directly (laptop/Obsidian) belongs on ``git-remote`` instead.

Selected by ``vault.durability.backend`` in the vault config, mirroring the
``secrets.backend`` pattern (``scripts/secrets_walkthrough.py``). A backend is
pure policy over the writer's git plumbing: its methods take the ``VaultWriter``
and drive ``_git`` / ``_remote`` etc., so the writer keeps a single git-identity
and lock discipline and the backend never re-implements them.
"""
from __future__ import annotations

import os
import re
import sys
from dataclasses import dataclass


@dataclass
class ReconcileResult:
    """What a reconcile did, so the sync pass can react without knowing the
    backend: ``ran`` — an off-host reconcile actually executed (the tree may have
    changed, so invalidate caches); ``conflict`` — it hit a conflict and backed
    out (the pass reports not-ok and the next tick retries)."""
    ran: bool = False
    conflict: bool = False


class DurabilityBackend:
    """Off-host policy. Defaults are no-ops (a purely local vault); subclasses
    override to mirror/reconcile. Methods take the ``VaultWriter`` and use its
    ``_git``/``_remote``/``_remote_name``/``_branch`` helpers."""

    kind = "none"

    def push(self, writer) -> bool:
        """Mirror the freshly-committed local branch off-host. Return True iff
        something actually went off-host (fail-soft: never raise on the writer's
        behalf — a rejected push is retried by the next write / interval pass)."""
        return False

    def reconcile(self, writer) -> ReconcileResult:
        """Reconcile local with the off-host copy before the sync pass commits
        strays. Return a :class:`ReconcileResult`."""
        return ReconcileResult()

    def restore(self, vault) -> bool:
        """Hydrate a fresh vault from the off-host copy (DR / first boot on an
        empty volume). Return True iff a snapshot existed and was restored;
        backends with no off-host copy return False."""
        return False

    def stored_head(self):
        """The commit this backend's off-host copy records as its tip, or None if
        it keeps no such record (and None when there is no copy yet).

        Read-only and deliberately cheap: it exists so a caller can ask "is what
        I have locally actually current?" WITHOUT downloading the copy. The brain
        entrypoint uses it to catch the failure this whole file is haunted by — a
        git-remote leg that has been silently dead while the object-store leg kept
        taking writes, so the repo is stale and the bundle is not."""
        return None


class GitRemoteBackend(DurabilityBackend):
    """Push/pull to a git remote — the historical behavior, any host. The remote
    name comes from ``SYNC_REMOTE`` (via the writer, default ``origin``) and the
    branch from ``SYNC_BRANCH`` / the current branch; a vault with no matching
    remote configured degrades to local-only (push/reconcile become no-ops),
    exactly as before."""

    kind = "git-remote"

    def push(self, writer) -> bool:
        if not writer._remote():
            return False
        return writer._git("push", writer._remote_name(), writer._branch()).returncode == 0

    def reconcile(self, writer) -> ReconcileResult:
        if not writer._remote():
            return ReconcileResult(ran=False)
        r = writer._git("pull", "--rebase", writer._remote_name(), writer._branch())
        if r.returncode != 0:
            writer._git("rebase", "--abort")   # never wedge the tree; next tick retries
            return ReconcileResult(ran=True, conflict=True)
        return ReconcileResult(ran=True)


class VolumeBackend(DurabilityBackend):
    """Local-only: the vault commits to its git repo on a durable volume but is
    never mirrored off-host. Inherits the base no-op push/reconcile."""

    kind = "volume"


_TENANT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")   # one safe path segment

# The commit the stored bundle was built from, kept as S3 object metadata so the
# next push can ask "does this vault contain that?" for the price of a HEAD
# request, instead of downloading the bundle to find out.
_HEAD_META = "vault-head"

# Object-store codes that mean "there is no such object", as opposed to "we could
# not find out". Only the former may be read as an empty slate; treating the
# latter the same way is how a transient 403 turns into a wiped tenant.
_MISSING_CODES = {"404", "NoSuchKey", "NotFound"}


def _is_missing_object(exc) -> bool:
    """True only when the store positively reported the object absent."""
    resp = getattr(exc, "response", None)
    if isinstance(resp, dict):
        code = str((resp.get("Error") or {}).get("Code") or "")
        status = str((resp.get("ResponseMetadata") or {}).get("HTTPStatusCode") or "")
        return code in _MISSING_CODES or status == "404"
    return type(exc).__name__ in ("NoSuchKey", "404")


class ObjectStoreBackend(DurabilityBackend):
    """``git bundle --all`` → one object per vault in S3-compatible storage.

    The host-free durability path (ROADMAP Now/Next; plan ``durability-backend``):
    each interval pass uploads a full, self-contained snapshot bundle to
    ``s3://<bucket>/<prefix><tenant>/vault.bundle`` — idempotent overwrite; DR =
    clone the latest bundle onto a fresh volume. ``reconcile`` is deliberately a
    no-op: single-writer topology (the serving process owns the vault).

    Config (``vault.durability.object_store``) carries env-var **names**, never
    values — the repo's secrets guardrail: ``bucket_env`` (required at boot),
    ``endpoint_url_env`` (S3-compatibles; omit for AWS), ``access_key_env`` /
    ``secret_key_env`` (omit to use the ambient AWS chain), ``region`` (default
    ``auto``), ``prefix`` (default ``tenants/``), ``tenant_env`` (default
    ``VAULT_TENANT``, falling back to ``vault``). boto3 arrives via the
    ``durability-s3`` extra and is imported lazily; ``client_factory`` is
    injectable for tests."""

    kind = "object-store"

    def __init__(self, spec=None, env=None, client_factory=None):
        spec = spec or {}
        env = os.environ if env is None else env
        self._client_factory = client_factory
        bucket_env = spec.get("bucket_env") or "OBJECT_STORE_BUCKET"
        self.bucket = env.get(bucket_env) or ""
        if not self.bucket:
            raise ValueError(
                f"object-store durability: ${bucket_env} is unset — refusing to "
                "start with nowhere to persist (set it, or switch "
                "vault.durability.backend)")
        self.endpoint = env.get(spec.get("endpoint_url_env")
                                or "OBJECT_STORE_ENDPOINT") or None
        self.region = spec.get("region") or "auto"
        self.access_key = env.get(spec.get("access_key_env")
                                  or "OBJECT_STORE_ACCESS_KEY") or None
        self.secret_key = env.get(spec.get("secret_key_env")
                                  or "OBJECT_STORE_SECRET_KEY") or None
        # R2 (and MinIO) want PATH-style addressing — the bucket in the path,
        # not a TLS subdomain (Orion's external-s3-adapter chose the same). Off
        # by default (AWS uses virtual-hosted). Resolution order, so a pure-env
        # Railway brain needs no config edit: explicit env → config spec → auto
        # true for an R2 endpoint. `OBJECT_STORE_FORCE_PATH_STYLE=false` forces
        # it off even against R2 if ever needed.
        fps_env = env.get("OBJECT_STORE_FORCE_PATH_STYLE")
        if fps_env is not None:
            self.force_path_style = fps_env.strip().lower() in ("1", "true", "yes", "on")
        elif "force_path_style" in spec:
            self.force_path_style = bool(spec["force_path_style"])
        else:
            self.force_path_style = bool(
                self.endpoint and "r2.cloudflarestorage.com" in self.endpoint)
        prefix = spec.get("prefix", "tenants/")
        # The tenant segment is what keeps N brains from overwriting each other
        # in ONE shared bucket, so it is REQUIRED and validated — never defaulted.
        # A silent fallback would make every un-tenanted brain write the same key
        # and clobber another brain's entire history on its first sync.
        tenant_env = spec.get("tenant_env") or "VAULT_TENANT"
        tenant = (env.get(tenant_env) or "").strip()
        if not tenant:
            raise ValueError(
                f"object-store durability: ${tenant_env} is unset — refusing to "
                "start. The tenant segment is what isolates this brain's bundle "
                "from every other brain in the bucket; without it they would "
                "share one key and overwrite each other.")
        if not _TENANT_RE.match(tenant):
            raise ValueError(
                f"object-store durability: ${tenant_env}={tenant!r} must match "
                "[A-Za-z0-9][A-Za-z0-9._-]* — a path separator or '..' would let "
                "one brain write outside its own prefix.")
        self.key = f"{prefix}{tenant}/vault.bundle"

    def _client(self):
        if self._client_factory is not None:
            return self._client_factory()
        try:
            import boto3  # the durability-s3 extra
        except ImportError as e:
            raise RuntimeError(
                "object-store durability needs boto3 — install the durability-s3 "
                "extra (pip install 'alexandria-vault[durability-s3]')") from e
        kw = {"region_name": self.region}
        if self.endpoint:
            kw["endpoint_url"] = self.endpoint
        if self.access_key and self.secret_key:
            kw.update(aws_access_key_id=self.access_key,
                      aws_secret_access_key=self.secret_key)
        if self.force_path_style:
            from botocore.config import Config
            kw["config"] = Config(s3={"addressing_style": "path"})
        return boto3.client("s3", **kw)

    def _head_object(self):
        """The stored bundle's object metadata, or ``None`` if there is no bundle.

        Raises on every error that is NOT "no such object". That distinction is
        the whole point: a 403 after a credential rotation, a throttle, or a
        timeout used to be swallowed and read as "first boot, nothing here yet",
        which sends the caller off to render an empty vault over a real history."""
        try:
            return self._client().head_object(Bucket=self.bucket, Key=self.key)
        except Exception as e:                       # noqa: BLE001 - re-raised below
            if _is_missing_object(e):
                return None
            raise

    def push(self, writer) -> bool:
        """Bundle the whole repo, upload. Fail-soft like every push — a miss is
        retried by the next interval pass, never raised at the writer.

        Guarded by an ancestry check, because this is a single fixed key with no
        versioning: whatever lands here *replaces* the tenant's only off-host
        copy. So a bundle is only overwritten by a repo that actually contains
        what the stored one recorded. A brain that booted empty — for any reason,
        including ones not yet imagined — cannot prove that, and is refused."""
        import tempfile
        try:
            prior = self._head_object()
            if prior is not None:
                stored = (prior.get("Metadata") or {}).get(_HEAD_META) or ""
                # No recorded tip = a bundle written before this guard existed;
                # allow it through and stamp one on the way out. Every later push
                # is checked.
                if stored and writer._git("merge-base", "--is-ancestor",
                                          stored, "HEAD").returncode != 0:
                    print(f"durability: REFUSING to overwrite {self.key} — the stored "
                          f"bundle's tip {stored[:12]} is not an ancestor of this "
                          "vault's HEAD. This vault does not contain the history "
                          "that is already off-host; overwriting would destroy it.",
                          file=sys.stderr)
                    return False
            with tempfile.TemporaryDirectory() as td:
                bundle = os.path.join(td, "vault.bundle")
                if writer._git("bundle", "create", bundle, "--all").returncode != 0:
                    return False
                head = writer._git("rev-parse", "HEAD").stdout.strip()
                self._client().upload_file(
                    bundle, self.bucket, self.key,
                    ExtraArgs={"Metadata": {_HEAD_META: head}} if head else None)
            return True
        except Exception:
            return False

    def reconcile(self, writer) -> ReconcileResult:
        return ReconcileResult()      # single-writer: nothing to pull

    def stored_head(self):
        """The tip `push` stamped on the stored bundle — one HEAD request, no
        download. None when no bundle exists yet, and None for a bundle written
        before the stamp existed (same "cannot tell" answer, so callers treat
        both as "no opinion" rather than as "you are stale")."""
        meta = self._head_object()
        if meta is None:
            return None
        return ((meta.get("Metadata") or {}).get(_HEAD_META) or "").strip() or None

    def restore(self, vault) -> bool:
        """Download the bundle, clone it into ``vault``. The bundle-path origin
        remote is removed so git-remote plumbing never pushes at a temp file."""
        import shutil
        import subprocess
        import tempfile
        from pathlib import Path
        vault = Path(vault)
        # Only a genuinely absent object means "render fresh". Anything else —
        # 403, throttle, DNS, timeout — raises, so the caller fails the boot
        # instead of quietly starting this tenant over from nothing.
        if self._head_object() is None:
            return False
        client = self._client()
        with tempfile.TemporaryDirectory() as td:
            bundle = os.path.join(td, "vault.bundle")
            client.download_file(self.bucket, self.key, bundle)
            vault.mkdir(parents=True, exist_ok=True)
            tmp_clone = os.path.join(td, "clone")
            r = subprocess.run(["git", "clone", bundle, tmp_clone],
                               capture_output=True, text=True)
            if r.returncode != 0:
                raise RuntimeError(f"bundle clone failed: {r.stderr[-300:]}")
            # shutil.move, not Path.rename: the clone is under TMPDIR (the
            # container's overlay filesystem) and the vault is a mounted volume,
            # so a rename across that boundary is EXDEV and the restore — the
            # entire DR path — dies on its first file.
            for item in Path(tmp_clone).iterdir():
                shutil.move(str(item), str(vault / item.name))
            subprocess.run(["git", "-C", str(vault), "remote", "remove", "origin"],
                           capture_output=True)
        return True


class MultiBackend(DurabilityBackend):
    """Fan one vault's durability out to several backends at once.

    Belt and braces: a bank rack can hold its history in a git remote it can
    browse AND a bundle in object storage it can restore from, without choosing.
    Semantics are deliberately asymmetric, because the failure that matters is
    different in each direction:

    - ``push`` writes to **every** backend and succeeds if **any** did. One
      unreachable destination must not cost you the others; the ones that failed
      retry on the next interval pass.
    - ``reconcile`` runs only the **first** backend that does real reconciliation
      (in practice the git remote). Pulling from two sources would race them
      against each other over one working tree.
    - ``restore`` tries each in order and stops at the first that had a snapshot,
      so ordering in config expresses preference — put the one you'd rather
      rebuild from first.
    """

    kind = "multi"

    def __init__(self, backends):
        self.backends = [b for b in backends if b is not None]
        if not self.backends:
            raise ValueError("vault.durability.backends is empty — name at least one")
        self.kind = "multi(" + "+".join(b.kind for b in self.backends) + ")"

    def push(self, writer) -> bool:
        """Write to every backend; succeed if any did — but never let one leg's
        success HIDE another's failure. A silently dead destination looks exactly
        like a healthy one until the day you need it, so each failure is reported
        even though the overall result stays true."""
        import sys
        ok = False
        for b in self.backends:
            try:
                if b.push(writer):
                    ok = True
                else:
                    print(f"  durability: {b.kind} push did not write "
                          "(no remote configured?)", file=sys.stderr, flush=True)
            except Exception as e:                # noqa: BLE001 — one bad destination
                print(f"  durability: {b.kind} push failed: "
                      f"{e.__class__.__name__}: {e}", file=sys.stderr, flush=True)
        return ok

    def reconcile(self, writer) -> ReconcileResult:
        for b in self.backends:
            res = b.reconcile(writer)
            if res.ran:
                return res                        # exactly one owner of the tree
        return ReconcileResult()

    def restore(self, vault) -> bool:
        for b in self.backends:
            try:
                if b.restore(vault):
                    return True
            except Exception:                     # noqa: BLE001 — try the next source
                continue
        return False

    def stored_head(self):
        """The first leg that records a tip at all. Legs are asked in config
        order, and only the object-store leg answers today, so this reports the
        bundle's tip — which is exactly what the entrypoint wants to weigh the
        git clone against."""
        for b in self.backends:
            try:
                head = b.stored_head()
            except Exception:                     # noqa: BLE001 — one unreachable leg
                continue
            if head:
                return head
        return None


def make_backend(config, env=None) -> DurabilityBackend:
    """Build the durability backend named by ``vault.durability.backend``
    (default ``git-remote``, so an absent block is 100% back-compatible)."""
    env = os.environ if env is None else env
    backend = ((config or {}).get("vault") or {}).get("durability") or {}
    # Containers pick their backend per-deployment without editing rendered
    # config: the env override wins when set (the brain kit's knob).
    name = (env.get("VAULT_DURABILITY_BACKEND") or backend.get("backend")
            or "git-remote")
    # A comma-separated name (env or config) means "all of these, together":
    # VAULT_DURABILITY_BACKEND="git-remote,object-store" keeps a browsable git
    # history AND a restorable bundle. Order expresses restore preference.
    names = [n.strip() for n in str(name).split(",") if n.strip()]
    if len(names) > 1:
        return MultiBackend([_one_backend(n, backend, env) for n in names])
    name = names[0] if names else "git-remote"
    return _one_backend(name, backend, env)


def _one_backend(name, spec, env) -> DurabilityBackend:
    """Build one named backend (shared by the single- and multi- paths)."""
    if name == "git-remote":
        return GitRemoteBackend()
    if name == "volume":
        return VolumeBackend()
    if name == "object-store":
        return ObjectStoreBackend(spec.get("object_store") or {}, env=env)
    raise ValueError(
        f"unknown vault.durability.backend {name!r} "
        "(expected 'git-remote', 'volume', 'object-store', or a comma-separated "
        "combination such as 'git-remote,object-store')")
