"""SP17 — High-level backup coordinator.

Owns the lifecycle of every backup the operator (or the scheduler)
takes:

* ``create_now`` — runs SQLite's online ``.backup()`` against the
  live engine, encrypts the bytes with :func:`cyclone.backup.encrypt`,
  writes a ``.bin`` + ``.meta.json`` pair into the backup directory,
  and persists a row in ``db_backups``.

* ``list_backups`` — directory listing joined with ``db_backups`` rows.

* ``verify`` — decrypts + recomputes SHA-256, compares to the sidecar.

* ``restore_initiate`` / ``restore_confirm`` — two-step restore so an
  idle browser tab can't nuke the live DB. The first call returns a
  ``restore_token`` (a random 32-byte hex string) plus a preview
  (``db_fingerprint``, ``table_count``). The second call swaps the
  engine only if the token matches.

* ``prune`` — deletes backups older than ``retention_days``.

This module is intentionally engine-aware: ``create_now`` reaches
into the live SQLAlchemy engine to get a raw SQLite connection and
call ``.backup()`` (the only way to take an online consistent
snapshot). ``restore`` reaches into :func:`cyclone.db.dispose_engine`
+ :func:`cyclone.db.reinit_engine` to swap to the restored file.

The encryption key is loaded once at construction time:

* If a backup passphrase is set in the Keychain (``backup.passphrase``
  account under service ``cyclone``), use it directly with the salt
  stored in the companion ``backup.salt`` account. The salt must be
  persisted — a fresh random salt per process would defeat the key.
* Otherwise fall back to deriving from the SQLCipher DB key + a fixed
  salt. Logged at WARNING because this is a degraded-but-still-safe
  posture.
"""
from __future__ import annotations

import json
import logging
import os
import secrets as _secrets
import shutil
import sqlite3
import threading
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Optional, Union

from sqlalchemy.exc import SQLAlchemyError

from cyclone import backup as backup_mod
from cyclone.backup import BackupError
from cyclone import db
from cyclone import secrets as secrets_mod


log = logging.getLogger(__name__)


# Status values for db_backups.status (mirrored in the ORM).
STATUS_PENDING = "pending"
STATUS_OK = "ok"
STATUS_ERROR = "error"
STATUS_PRUNED = "pruned"

# Where the operator's backup passphrase lives in the Keychain.
KEYCHAIN_BACKUP_PASSPHRASE_ACCOUNT = "backup.passphrase"

# Companion account for the salt. Stored as hex. Same value across
# processes so the derived key is reproducible — a fresh random salt
# per BackupService would defeat the key.
KEYCHAIN_BACKUP_SALT_ACCOUNT = "backup.salt"

# Restore token TTL (seconds). The two-step confirm must complete
# within this window or the operator re-runs initiate.
RESTORE_TOKEN_TTL_SECONDS = 300


# ---------------------------------------------------------------------------
# Result dataclasses
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class BackupRecord:
    """Public view of a backup row joined with filesystem state."""

    id: int
    filename: str
    backup_dir: str
    size_bytes: int
    db_fingerprint: str
    table_count: int
    created_at: datetime
    completed_at: Optional[datetime]
    status: str
    error_message: Optional[str]
    key_fingerprint: str


@dataclass(frozen=True)
class CreateResult:
    """Outcome of ``create_now``."""

    backup: BackupRecord
    sidecar: backup_mod.Sidecar


@dataclass(frozen=True)
class VerifyResult:
    """Outcome of ``verify``."""

    backup_id: int
    filename: str
    ok: bool
    expected_fingerprint: str
    actual_fingerprint: str
    table_count: int
    reason: Optional[str] = None


@dataclass(frozen=True)
class RestoreInitiateResult:
    """Returned by the first call of the two-step restore."""

    backup_id: int
    filename: str
    restore_token: str
    expires_at: datetime
    db_fingerprint: str
    table_count: int
    current_db_fingerprint: str
    current_table_count: int
    size_bytes: int


@dataclass(frozen=True)
class RestoreConfirmResult:
    """Returned by the second call of the two-step restore."""

    backup_id: int
    filename: str
    restored_from_fingerprint: str
    restored_at: datetime
    new_db_fingerprint: str


# ---------------------------------------------------------------------------
# BackupService
# ---------------------------------------------------------------------------


class BackupService:
    """Coordinator for encrypted DB backups.

    Construct once at app startup; share across requests. Not
    thread-safe for *creation* (the SQLite ``.backup()`` call uses
    the live engine and is best serialized through the scheduler),
    but ``list_backups`` / ``prune`` / ``status`` are safe to call
    concurrently.
    """

    def __init__(
        self,
        backup_dir: Union[str, Path],
        *,
        passphrase: Optional[str] = None,
        salt: Optional[bytes] = None,
        retention_days: int = 30,
        db_url: Optional[str] = None,
    ) -> None:
        self._backup_dir = Path(backup_dir)
        self._retention_days = max(1, int(retention_days))
        self._db_url = db_url
        # The derived key + its salt. If ``passphrase`` is None we
        # fall back to deriving from the SQLCipher DB key (with a
        # WARNING log).
        #
        # Salt is per-BackupService-instance and MUST be stable across
        # processes — otherwise the same passphrase would derive
        # different keys in different invocations and decrypt would
        # always fail. Two options:
        #
        #   1. Caller passes an explicit ``salt`` (the Keychain flow
        #      reads the persisted salt from backup.salt account).
        #   2. We accept a None salt here; ``_ensure_key`` then either
        #      uses the persisted salt (if available) or generates one
        #      and persists it on first use.
        #
        # Tests typically pass an explicit random salt; production
        # should always pass the persisted one.
        self._passphrase = passphrase
        self._salt = salt
        self._key: Optional[bytes] = None
        self._used_fallback = False
        # Pending restore tokens: token -> (backup_id, expires_at).
        # A simple in-memory dict is sufficient — the token only
        # needs to survive between the two API calls in one process.
        self._pending_restores: dict[str, tuple[int, datetime]] = {}
        self._lock = threading.Lock()

    # ---- Public API -------------------------------------------------------

    @property
    def backup_dir(self) -> Path:
        return self._backup_dir

    @property
    def key_fingerprint(self) -> str:
        """SHA-256 of the current derived key, or "" if not yet derived."""
        if self._key is None:
            return ""
        return backup_mod.fingerprint(self._key)

    def create_now(self) -> CreateResult:
        """Take an encrypted backup of the live DB right now.

        Crash-safe: any failure marks the ``db_backups`` row as
        ``error``, removes any partial files from the backup dir, and
        re-raises the exception.
        """
        # 1. Make sure the backup dir exists.
        self._backup_dir.mkdir(parents=True, exist_ok=True)

        # 2. Allocate a filename + insert a pending row.
        from cyclone.db import DbBackup  # late import — circular otherwise
        from cyclone.store import store as cycl_store

        filename = backup_mod.backup_filename()
        created_at = datetime.now(timezone.utc)

        row = cycl_store.add_backup_pending(
            filename=filename,
            backup_dir=str(self._backup_dir),
        )

        try:
            # 3. Run SQLite's online .backup() to a temp file.
            #    We use a private path *inside* the backup dir so the
            #    operator can see what crashed if it does.
            staging_db = self._backup_dir / f".{filename}.staging.db"
            self._sqlite_backup_to(staging_db)

            # 4. Encrypt.
            plaintext = staging_db.read_bytes()
            db_fp = backup_mod.fingerprint(plaintext)
            key = self._ensure_key()
            blob = backup_mod.encrypt(plaintext, key)

            # 5. Move encrypted blob into place + write sidecar.
            target = self._backup_dir / filename
            target.write_bytes(blob)
            staging_db.unlink()

            table_count = self._count_tables_in_blob(plaintext)
            sidecar = backup_mod.Sidecar(
                format_version=backup_mod.FORMAT_VERSION,
                created_at=created_at.isoformat(),
                db_fingerprint=db_fp,
                table_count=table_count,
                size_bytes=len(blob),
                kdf="PBKDF2-HMAC-SHA256",
                kdf_iterations=backup_mod.KDF_ITERATIONS,
                cipher="AES-256-GCM",
                key_fingerprint=backup_mod.fingerprint(key),
            )
            sidecar_path = self._backup_dir / backup_mod.sidecar_filename(filename)
            sidecar_path.write_text(sidecar.to_json())

            # 6. Mark the row as ok.
            with db.SessionLocal()() as s:
                row = s.get(DbBackup, row.id)
                row.status = STATUS_OK
                row.size_bytes = len(blob)
                row.db_fingerprint = db_fp
                row.table_count = table_count
                row.completed_at = datetime.now(timezone.utc)
                s.commit()
                s.refresh(row)

            record = self._row_to_record(row)
            log.info(
                "Backup created",
                extra={
                    "backup_id": record.id,
                    "backup_filename": record.filename,
                    "size_bytes": record.size_bytes,
                    "db_fingerprint": record.db_fingerprint,
                },
            )
            return CreateResult(backup=record, sidecar=sidecar)

        except Exception as exc:  # noqa: BLE001
            log.exception("Backup create failed")
            try:
                with db.SessionLocal()() as s:
                    row = s.get(DbBackup, row.id)
                    row.status = STATUS_ERROR
                    row.error_message = f"{type(exc).__name__}: {exc}"[:500]
                    row.completed_at = datetime.now(timezone.utc)
                    s.commit()
                # Best-effort cleanup of any partial files.
                for p in [
                    self._backup_dir / filename,
                    self._backup_dir / f".{filename}.staging.db",
                    self._backup_dir / backup_mod.sidecar_filename(filename),
                ]:
                    if p.exists():
                        try:
                            p.unlink()
                        except OSError:
                            pass
            except Exception:  # noqa: BLE001
                log.exception("Failed to mark backup row as error")
            raise

    def list_backups(
        self,
        *,
        limit: int = 100,
        status: Optional[str] = None,
    ) -> list[BackupRecord]:
        """List ``db_backups`` rows newest first.

        Joins the filesystem state (presence of ``.bin`` and
        ``.meta.json``) implicitly via :attr:`BackupRecord.status`:
        a row marked ``pruned`` had its files deleted by the
        retention policy.
        """
        from cyclone.db import DbBackup

        with db.SessionLocal()() as s:
            q = s.query(DbBackup)
            if status is not None:
                q = q.filter(DbBackup.status == status)
            rows = q.order_by(DbBackup.id.desc()).limit(limit).all()
            return [self._row_to_record(r) for r in rows]

    def verify(self, backup_id: int) -> VerifyResult:
        """Decrypt + checksum-verify a backup against its sidecar.

        Does NOT trust the sidecar's ``db_fingerprint`` field alone;
        recomputes the SHA-256 from the decrypted blob and compares.
        """
        from cyclone.db import DbBackup

        with db.SessionLocal()() as s:
            row = s.get(DbBackup, backup_id)
            if row is None:
                raise BackupError(f"backup {backup_id} not found")
            record = self._row_to_record(row)
            sidecar = self._read_sidecar(record.filename)
            if sidecar is None:
                return VerifyResult(
                    backup_id=record.id, filename=record.filename,
                    ok=False, expected_fingerprint="", actual_fingerprint="",
                    table_count=0, reason="sidecar missing",
                )

        try:
            blob = (self._backup_dir / record.filename).read_bytes()
        except FileNotFoundError:
            return VerifyResult(
                backup_id=record.id, filename=record.filename,
                ok=False,
                expected_fingerprint=sidecar.db_fingerprint,
                actual_fingerprint="",
                table_count=sidecar.table_count,
                reason="backup file missing",
            )

        try:
            plaintext = backup_mod.decrypt(blob, self._ensure_key())
        except backup_mod.BackupDecryptError as exc:
            return VerifyResult(
                backup_id=record.id, filename=record.filename,
                ok=False,
                expected_fingerprint=sidecar.db_fingerprint,
                actual_fingerprint="",
                table_count=sidecar.table_count,
                reason=str(exc),
            )

        actual_fp = backup_mod.fingerprint(plaintext)
        return VerifyResult(
            backup_id=record.id,
            filename=record.filename,
            ok=(actual_fp == sidecar.db_fingerprint),
            expected_fingerprint=sidecar.db_fingerprint,
            actual_fingerprint=actual_fp,
            table_count=sidecar.table_count,
            reason=None if actual_fp == sidecar.db_fingerprint else "fingerprint mismatch",
        )

    def restore_initiate(self, backup_id: int) -> RestoreInitiateResult:
        """First half of the two-step restore.

        Decrypts the backup into a temp file and reads its current
        ``db_fingerprint`` + ``table_count``. Returns a one-shot
        ``restore_token`` the operator must echo back to
        :meth:`restore_confirm` within 5 minutes.
        """
        from cyclone.db import DbBackup

        with db.SessionLocal()() as s:
            row = s.get(DbBackup, backup_id)
            if row is None:
                raise BackupError(f"backup {backup_id} not found")
            if row.status != STATUS_OK:
                raise BackupError(
                    f"backup {backup_id} status is {row.status!r}; only 'ok' backups can be restored",
                )
            record = self._row_to_record(row)

        # Decrypt into a staging file so the confirm step is fast.
        staging = self._backup_dir / f".restore-{record.filename}.staging.db"
        try:
            blob = (self._backup_dir / record.filename).read_bytes()
        except FileNotFoundError as exc:
            raise BackupError(f"backup file missing: {record.filename}") from exc
        try:
            plaintext = backup_mod.decrypt(blob, self._ensure_key())
        except backup_mod.BackupDecryptError as exc:
            raise BackupError(f"decrypt failed: {exc}") from exc
        staging.write_bytes(plaintext)

        # Snapshot the live DB's fingerprint for the operator's "are
        # you sure you want to do this?" preview.
        live_fp, live_count = self._live_fingerprint_and_count()

        token = _secrets.token_hex(32)
        expires_at = datetime.now(timezone.utc) + timedelta(
            seconds=RESTORE_TOKEN_TTL_SECONDS,
        )
        with self._lock:
            self._pending_restores[token] = (record.id, expires_at)

        log.info(
            "Restore initiated",
            extra={
                "backup_id": record.id,
                "token_prefix": token[:8],
                "expires_at": expires_at.isoformat(),
            },
        )
        return RestoreInitiateResult(
            backup_id=record.id,
            filename=record.filename,
            restore_token=token,
            expires_at=expires_at,
            db_fingerprint=backup_mod.fingerprint(plaintext),
            table_count=self._count_tables_in_blob(plaintext),
            current_db_fingerprint=live_fp,
            current_table_count=live_count,
            size_bytes=len(plaintext),
        )

    def restore_confirm(
        self,
        backup_id: int,
        restore_token: str,
        *,
        actor: str = "operator",
    ) -> RestoreConfirmResult:
        """Second half of the two-step restore.

        Validates the token, copies the decrypted staging file over
        the live DB path, disposes + reopens the engine. Raises
        ``BackupError`` on any mismatch.
        """
        now = datetime.now(timezone.utc)
        with self._lock:
            entry = self._pending_restores.pop(restore_token, None)
        if entry is None:
            raise BackupError("restore_token not found (already consumed or never issued)")
        token_backup_id, expires_at = entry
        if token_backup_id != backup_id:
            raise BackupError(
                f"restore_token was for backup {token_backup_id}, not {backup_id}",
            )
        if now > expires_at:
            raise BackupError(
                f"restore_token expired at {expires_at.isoformat()}; re-run initiate",
            )

        from cyclone.db import DbBackup
        with db.SessionLocal()() as s:
            row = s.get(DbBackup, backup_id)
            if row is None:
                raise BackupError(f"backup {backup_id} disappeared mid-restore")
            record = self._row_to_record(row)

        staging = self._backup_dir / f".restore-{record.filename}.staging.db"
        if not staging.exists():
            raise BackupError(
                f"staging restore file missing: {staging.name}; re-run initiate",
            )

        target_db_path = self._live_db_path()
        if target_db_path is None:
            raise BackupError(
                "cannot determine live DB file path (non-sqlite URL?)",
            )

        # Pre-restore fingerprint for the audit event.
        restored_from_fp = backup_mod.fingerprint(staging.read_bytes())

        # The swap: dispose engine → copy file → reinit engine.
        # Anything between dispose and reinit raises (queries that
        # are in-flight get a "database is locked" or
        # "no such table" error); we accept that because the
        # operator already confirmed.
        db.dispose_engine()
        try:
            # Atomic copy via temp + rename so a crash mid-copy
            # doesn't leave a half-written DB file.
            tmp_target = target_db_path.with_suffix(
                target_db_path.suffix + f".restoring-{_secrets.token_hex(4)}",
            )
            shutil.copyfile(staging, tmp_target)
            os.replace(tmp_target, target_db_path)
        finally:
            staging.unlink(missing_ok=True)
        db.reinit_engine()

        # Post-restore fingerprint from the now-live engine.
        new_fp, _ = self._live_fingerprint_and_count()

        log.warning(
            "Restore complete: backup_id=%d actor=%s from=%s to=%s",
            backup_id, actor, restored_from_fp, new_fp,
        )
        return RestoreConfirmResult(
            backup_id=record.id,
            filename=record.filename,
            restored_from_fingerprint=restored_from_fp,
            restored_at=datetime.now(timezone.utc),
            new_db_fingerprint=new_fp,
        )

    def prune(self, *, now: Optional[datetime] = None) -> list[str]:
        """Delete backups older than ``retention_days``. Returns deleted paths.

        Marks the ``db_backups`` rows ``pruned`` so the operator can
        still see what was deleted (and when).
        """
        from cyclone.db import DbBackup

        cutoff = (now or datetime.now(timezone.utc)) - timedelta(
            days=self._retention_days,
        )
        deleted: list[str] = []

        with db.SessionLocal()() as s:
            q = s.query(DbBackup).filter(
                DbBackup.status == STATUS_OK,
                DbBackup.created_at < cutoff,
            )
            for row in q.all():
                # Delete the file pair; ignore if already gone.
                bin_path = Path(row.backup_dir) / row.filename
                meta_path = Path(row.backup_dir) / backup_mod.sidecar_filename(row.filename)
                for p in (bin_path, meta_path):
                    try:
                        if p.exists():
                            p.unlink()
                            deleted.append(str(p))
                    except OSError as exc:
                        log.warning("Failed to delete %s: %s", p, exc)
                row.status = STATUS_PRUNED
                s.add(row)
            s.commit()

        log.info(
            "Pruned old backups",
            extra={
                "deleted_count": len(deleted),
                "cutoff": cutoff.isoformat(),
            },
        )
        return deleted

    def status(self) -> dict:
        """Snapshot of the backup subsystem for ``GET /api/admin/backup/status``."""
        from cyclone.db import DbBackup
        from sqlalchemy import func

        with db.SessionLocal()() as s:
            total = s.query(func.count(DbBackup.id)).scalar() or 0
            ok_count = s.query(func.count(DbBackup.id)).filter(
                DbBackup.status == STATUS_OK,
            ).scalar() or 0
            error_count = s.query(func.count(DbBackup.id)).filter(
                DbBackup.status == STATUS_ERROR,
            ).scalar() or 0
            pruned_count = s.query(func.count(DbBackup.id)).filter(
                DbBackup.status == STATUS_PRUNED,
            ).scalar() or 0
            last_row = (
                s.query(DbBackup)
                .filter(DbBackup.status.in_([STATUS_OK, STATUS_ERROR]))
                .order_by(DbBackup.id.desc())
                .first()
            )
            last_ok_row = (
                s.query(DbBackup)
                .filter(DbBackup.status == STATUS_OK)
                .order_by(DbBackup.id.desc())
                .first()
            )
            disk_bytes = 0
            try:
                for p in self._backup_dir.iterdir():
                    if p.is_file() and p.suffix == ".bin":
                        disk_bytes += p.stat().st_size
            except OSError:
                pass

        return {
            "backup_dir": str(self._backup_dir),
            "retention_days": self._retention_days,
            "totals": {
                "all": total,
                "ok": ok_count,
                "error": error_count,
                "pruned": pruned_count,
            },
            "disk_bytes": disk_bytes,
            "last_backup_at": (
                last_row.created_at.isoformat() if last_row and last_row.created_at else None
            ),
            "last_backup_status": last_row.status if last_row else None,
            "last_ok_backup_at": (
                last_ok_row.created_at.isoformat()
                if last_ok_row and last_ok_row.created_at else None
            ),
            "used_fallback_key": self._used_fallback,
        }

    # ---- Internals --------------------------------------------------------

    def _ensure_key(self) -> bytes:
        """Derive (or return cached) AES key. Triggers fallback + WARNING log
        if no passphrase was provided at construction time.

        If a passphrase is set but no salt was passed at construction,
        look one up from the Keychain (``backup.salt`` account). On
        a fresh install, generate + persist a salt on first use so
        subsequent invocations derive the same key.
        """
        if self._key is not None:
            return self._key
        if self._passphrase:
            salt = self._salt
            if salt is None:
                # Try the Keychain.
                stored = secrets_mod.get_secret(KEYCHAIN_BACKUP_SALT_ACCOUNT)
                if stored:
                    salt = bytes.fromhex(stored.strip())
                else:
                    # First run: generate + persist.
                    salt = os.urandom(backup_mod.SALT_LEN)
                    secrets_mod.set_secret(
                        KEYCHAIN_BACKUP_SALT_ACCOUNT,
                        salt.hex(),
                    )
                    log.info(
                        "Generated + persisted backup salt to Keychain "
                        "(account %r)",
                        KEYCHAIN_BACKUP_SALT_ACCOUNT,
                    )
            self._key = backup_mod.derive_key(self._passphrase, salt)
            return self._key

        # Fallback: derive from SQLCipher DB key. This is degraded
        # security (the SQLCipher key is meant to unlock the DB, not
        # the backup), but it's strictly better than plaintext.
        from cyclone import db_crypto
        db_key = db_crypto.get_db_key() if db_crypto.is_encryption_enabled() else None
        if not db_key:
            # No passphrase AND no SQLCipher key — refuse.
            raise BackupError(
                "no backup passphrase set and SQLCipher is not enabled; "
                "either set a backup passphrase in the Keychain or "
                "enable SQLCipher encryption",
            )
        log.warning(
            "Backup using fallback key derived from SQLCipher DB key "
            "(no separate backup passphrase set); set one via "
            "`cyclone backup init-passphrase` for stronger isolation",
            extra={"key_source": "sqlcipher_fallback"},
        )
        self._used_fallback = True
        self._key = backup_mod.derive_key(db_key, backup_mod.FALLBACK_SALT)
        return self._key

    def _sqlite_backup_to(self, target_path: Path) -> None:
        """Run SQLite's online ``.backup()`` against the live engine.

        Works for both plain SQLite and SQLCipher because sqlcipher3
        is API-compatible with sqlite3. The ``.backup()`` API takes
        a *target* connection; we make a fresh sqlite3 connection to
        the target file (which doesn't exist yet) and copy into it.
        """
        url = self._db_url or db._resolve_url()
        if not url.startswith("sqlite"):
            raise BackupError(
                f"only sqlite URLs are supported for online backup; got {url!r}",
            )
        # Drive the backup off the live engine so we capture the
        # current state of all tables atomically (SQLite's .backup
        # holds a read lock on the source for the duration).
        engine = db.engine()  # raises RuntimeError if init_db() wasn't called
        with engine.raw_connection() as raw:
            src_conn = raw.driver_connection  # sqlite3.Connection / sqlcipher3.Connection
            if target_path.exists():
                target_path.unlink()
            dst_conn = sqlite3.connect(str(target_path))
            try:
                src_conn.backup(dst_conn)
            finally:
                dst_conn.close()

    def _count_tables_in_blob(self, plaintext: bytes) -> int:
        """Open the decrypted DB in-memory and count user tables."""
        tmp = self._backup_dir / f".count-tables-{_secrets.token_hex(4)}.db"
        try:
            tmp.write_bytes(plaintext)
            conn = sqlite3.connect(str(tmp))
            try:
                rows = conn.execute(
                    "SELECT count(*) FROM sqlite_master "
                    "WHERE type='table' AND name NOT LIKE 'sqlite_%'",
                ).fetchone()
                return int(rows[0])
            finally:
                conn.close()
        finally:
            tmp.unlink(missing_ok=True)

    def _live_fingerprint_and_count(self) -> tuple[str, int]:
        """Fingerprint + table count of the *current* live DB."""
        try:
            engine = db.engine()
        except RuntimeError:
            return "", 0
        # Use a temp-file .backup so we don't have to worry about
        # online-vs-offline semantics.
        tmp = self._backup_dir / f".live-fp-{_secrets.token_hex(4)}.db"
        try:
            with engine.raw_connection() as raw:
                conn = raw.driver_connection
                if tmp.exists():
                    tmp.unlink()
                dst = sqlite3.connect(str(tmp))
                try:
                    conn.backup(dst)
                finally:
                    dst.close()
            data = tmp.read_bytes()
            return backup_mod.fingerprint(data), self._count_tables_in_blob(data)
        finally:
            tmp.unlink(missing_ok=True)

    def _live_db_path(self) -> Optional[Path]:
        """Resolve the filesystem path of the live DB, or None for non-sqlite."""
        url = self._db_url or db._resolve_url()
        if not url.startswith("sqlite"):
            return None
        # Strip the driver prefix: sqlite:///abs or sqlite:///./rel
        prefix = "sqlite:///"
        if url.startswith(prefix):
            return Path(url[len(prefix):])
        if url.startswith("sqlite://"):
            # sqlite://./relative/path  -> Path("./relative/path")
            return Path(url[len("sqlite://"):])
        return None

    def _read_sidecar(self, filename: str) -> Optional[backup_mod.Sidecar]:
        p = self._backup_dir / backup_mod.sidecar_filename(filename)
        if not p.exists():
            return None
        try:
            return backup_mod.Sidecar.from_json(p.read_text())
        except (json.JSONDecodeError, KeyError, ValueError) as exc:
            log.warning("Sidecar %s is malformed: %s", p, exc)
            return None

    def _row_to_record(self, row) -> BackupRecord:
        """ORM row → BackupRecord. Reads key_fingerprint from the sidecar if present."""
        sidecar = self._read_sidecar(row.filename)
        return BackupRecord(
            id=row.id,
            filename=row.filename,
            backup_dir=row.backup_dir,
            size_bytes=row.size_bytes or 0,
            db_fingerprint=row.db_fingerprint or "",
            table_count=row.table_count or 0,
            created_at=row.created_at,
            completed_at=row.completed_at,
            status=row.status,
            error_message=row.error_message,
            key_fingerprint=sidecar.key_fingerprint if sidecar else "",
        )


# ---------------------------------------------------------------------------
# Module-level singleton
# ---------------------------------------------------------------------------


_service: Optional[BackupService] = None


def configure_backup_service(
    backup_dir: Union[str, Path],
    *,
    passphrase: Optional[str] = None,
    salt: Optional[bytes] = None,
    retention_days: int = 30,
    db_url: Optional[str] = None,
) -> BackupService:
    """Create (or replace) the module-level BackupService singleton."""
    global _service
    if _service is not None:
        return _service
    _service = BackupService(
        backup_dir=backup_dir,
        passphrase=passphrase,
        salt=salt,
        retention_days=retention_days,
        db_url=db_url,
    )
    return _service


def get_backup_service() -> BackupService:
    """Return the configured BackupService. Raises RuntimeError if not set up."""
    if _service is None:
        raise RuntimeError("backup service not configured; call configure_backup_service() first")
    return _service


def reset_backup_service_for_tests() -> None:
    """Clear the module-level singleton. Test-only."""
    global _service
    _service = None
