"""RBS REGAL — Enterprise Backup & Restore System.

Endpoints:
  • POST /api/backup/create          — snapshot DB to a JSON.gz, return download URL.
  • GET  /api/backup/list            — recent backups (snapshots stored on disk).
  • GET  /api/backup/download/{id}   — download an existing backup.
  • POST /api/backup/restore         — upload + restore from a backup file.
  • POST /api/backup/verify/{id}     — check integrity (SHA-256 + row counts).
  • POST /api/backup/google-drive    — push the latest snapshot to user-supplied folder.
  • GET/PUT /api/backup/schedule     — auto-backup scheduler config.
  • DELETE /api/backup/{id}          — remove a stored backup.

Snapshot format: gzip-compressed UTF-8 JSON with a sha256 + manifest header.
Optional symmetric encryption via Fernet (key derived from JWT_SECRET).
"""
import os
import gzip
import json
import hashlib
import base64
import secrets
from pathlib import Path
from datetime import datetime, timezone, timedelta
from typing import Optional, List

from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File
from fastapi.responses import FileResponse
from pydantic import BaseModel
from cryptography.fernet import Fernet
from bson import json_util

from auth import require_admin


# Backups live under this backend folder by default (relative to this file),
# not a hardcoded container path - override with BACKUP_DIR env var if needed
# (e.g. to point at a different disk/mount on shared hosting).
_ROOT_DIR = Path(__file__).parent
BACKUP_DIR = os.environ.get("BACKUP_DIR", str(_ROOT_DIR / "_backups"))
BACKUP_TRASH_DIR = os.environ.get("BACKUP_TRASH_DIR", str(_ROOT_DIR / "_backups" / "trash"))
DEFAULT_RETENTION_DAYS = 30
os.makedirs(BACKUP_DIR, exist_ok=True)
os.makedirs(BACKUP_TRASH_DIR, exist_ok=True)

# Collections eligible for backup (avoid massive ephemeral logs).
BACKUP_COLLECTIONS = [
    "companies", "users", "parties", "items", "invoices", "payments",
    "expenses", "cash_transactions", "bank_accounts", "bank_transactions",
    "loans", "godowns", "stock_transfers", "ecommerce_settings",
    "shortcuts", "user_shortcuts", "notifications", "announcements",
    "app_settings", "app_features", "roles", "license", "devices",
    "counters", "settings", "user_preferences",
]


router = APIRouter(prefix="/api/backup", tags=["backup"])


def _now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()


def _fernet() -> Fernet:
    """Derive a stable encryption key from JWT_SECRET so backups can roundtrip."""
    secret = os.environ.get("JWT_SECRET", "rm-regal-dev-secret")
    digest = hashlib.sha256(secret.encode()).digest()
    return Fernet(base64.urlsafe_b64encode(digest))


def _safe_id(name: str) -> str:
    """Filesystem-safe filename component."""
    return "".join(c for c in name if c.isalnum() or c in "-_.")


# ============ Snapshot creation ===========================================
class CreateBackupIn(BaseModel):
    label: Optional[str] = ""
    encrypt: bool = True


@router.post("/create")
async def create_backup(payload: CreateBackupIn, request: Request, user=Depends(require_admin)):
    db = request.app.state.db
    ts = datetime.now(timezone.utc)
    bk_id = ts.strftime("%Y%m%d_%H%M%S_") + secrets.token_hex(3)
    label = _safe_id(payload.label or "manual")[:40]
    filename = f"rmregal_{label}_{bk_id}.json.gz" + (".enc" if payload.encrypt else "")
    path = os.path.join(BACKUP_DIR, filename)

    counts = {}
    snapshot = {
        "manifest": {
            "kind": "rm-regal-backup",
            "version": 1,
            "created_at": ts.isoformat(),
            "label": label,
            "encrypted": payload.encrypt,
            "created_by": user["email"],
        },
        "data": {},
    }

    for coll in BACKUP_COLLECTIONS:
        try:
            docs = await db[coll].find({}).to_list(200000)
        except Exception:
            docs = []
        counts[coll] = len(docs)
        snapshot["data"][coll] = json.loads(json_util.dumps(docs))
    snapshot["manifest"]["counts"] = counts

    raw = json.dumps(snapshot, ensure_ascii=False).encode("utf-8")
    sha = hashlib.sha256(raw).hexdigest()
    snapshot["manifest"]["sha256"] = sha
    raw = json.dumps(snapshot, ensure_ascii=False).encode("utf-8")

    payload_bytes = gzip.compress(raw, compresslevel=6)
    if payload.encrypt:
        payload_bytes = _fernet().encrypt(payload_bytes)

    with open(path, "wb") as f:
        f.write(payload_bytes)
    size = os.path.getsize(path)

    # Persist meta in DB so the list endpoint can find it without scanning fs.
    await db.backups.insert_one({
        "_id": bk_id,
        "filename": filename,
        "label": label,
        "encrypted": payload.encrypt,
        "size_bytes": size,
        "counts": counts,
        "sha256": sha,
        "created_at": ts.isoformat(),
        "created_by": user["email"],
    })

    return {
        "id": bk_id,
        "filename": filename,
        "size_bytes": size,
        "size_human": _human_size(size),
        "encrypted": payload.encrypt,
        "counts": counts,
        "sha256": sha,
        "total_rows": sum(counts.values()),
    }


def _human_size(n: int) -> str:
    for unit in ["B", "KB", "MB", "GB"]:
        if n < 1024:
            return f"{n:.1f} {unit}"
        n /= 1024
    return f"{n:.1f} TB"


# ============ List / Download / Delete ====================================
@router.get("/list")
async def list_backups(request: Request, user=Depends(require_admin)):
    db = request.app.state.db
    # v12 — exclude soft-deleted (trashed) backups from the active list
    docs = await db.backups.find({"is_deleted": {"$ne": True}}).sort("created_at", -1).limit(50).to_list(50)
    out = []
    for d in docs:
        out.append({
            "id": d["_id"],
            "filename": d.get("filename"),
            "label": d.get("label"),
            "encrypted": d.get("encrypted", False),
            "size_bytes": d.get("size_bytes"),
            "size_human": _human_size(d.get("size_bytes", 0)),
            "counts": d.get("counts", {}),
            "sha256": d.get("sha256"),
            "created_at": d.get("created_at"),
            "created_by": d.get("created_by"),
            "total_rows": sum((d.get("counts", {}) or {}).values()),
        })
    return out


@router.get("/download/{bk_id}")
async def download_backup(bk_id: str, request: Request, user=Depends(require_admin)):
    db = request.app.state.db
    doc = await db.backups.find_one({"_id": bk_id})
    if not doc:
        raise HTTPException(404, "Backup not found")
    # Find file — prefer active path; fall back to trash if soft-deleted
    candidates = [os.path.join(BACKUP_DIR, doc["filename"])]
    if doc.get("is_deleted"):
        candidates.insert(0, os.path.join(BACKUP_TRASH_DIR, doc["filename"]))
    path = next((p for p in candidates if os.path.exists(p)), None)
    if not path:
        raise HTTPException(404, "Backup file missing on disk")
    return FileResponse(path, filename=doc["filename"], media_type="application/octet-stream")


# v12 — Backup Trash / Recycle Bin (soft-delete + restore + retention)
@router.delete("/{bk_id}")
async def delete_backup(
    bk_id: str,
    request: Request,
    permanent: bool = False,
    retention_days: int = DEFAULT_RETENTION_DAYS,
    user=Depends(require_admin),
):
    """Soft-delete a backup (move to trash) by default. Pass ?permanent=true to
    purge the file and DB record completely (irrecoverable). Retention controls
    how long the trashed backup stays before auto-cleanup eligibility."""
    db = request.app.state.db
    doc = await db.backups.find_one({"_id": bk_id})
    if not doc:
        raise HTTPException(404, "Not found")
    src = os.path.join(BACKUP_DIR, doc["filename"])
    trash_path = os.path.join(BACKUP_TRASH_DIR, doc["filename"])

    if permanent:
        # Hard delete — try both active and trash directories
        for p in (src, trash_path):
            try:
                if os.path.exists(p):
                    os.remove(p)
            except OSError:
                pass
        await db.backups.delete_one({"_id": bk_id})
        return {"ok": True, "permanent": True}

    # Soft delete — move file to trash, mark DB record
    if doc.get("is_deleted"):
        raise HTTPException(400, "Backup is already in trash. Use restore or pass permanent=true to purge.")
    if os.path.exists(src):
        try:
            os.replace(src, trash_path)
            restore_path = src
        except OSError as e:
            raise HTTPException(500, f"Could not move backup to trash: {e}")
    else:
        # Orphan record — file already missing, still mark deleted
        restore_path = src
    await db.backups.update_one(
        {"_id": bk_id},
        {"$set": {
            "is_deleted": True,
            "deleted_at": _now_iso(),
            "deleted_by": user.get("email", "system"),
            "restore_path": restore_path,
            "retention_days": int(retention_days or DEFAULT_RETENTION_DAYS),
        }},
    )
    return {"ok": True, "permanent": False, "moved_to_trash": True}


@router.get("/trash")
async def list_trash(request: Request, user=Depends(require_admin)):
    """List soft-deleted backups still recoverable from trash."""
    db = request.app.state.db
    docs = await db.backups.find({"is_deleted": True}).sort("deleted_at", -1).limit(200).to_list(200)
    out = []
    now = datetime.now(timezone.utc)
    for d in docs:
        deleted_at_iso = d.get("deleted_at", "")
        retention = int(d.get("retention_days", DEFAULT_RETENTION_DAYS) or DEFAULT_RETENTION_DAYS)
        days_remaining = None
        try:
            if deleted_at_iso:
                deleted_at = datetime.fromisoformat(deleted_at_iso.replace("Z", "+00:00"))
                expires = deleted_at + timedelta(days=retention)
                days_remaining = max(0, (expires - now).days)
        except Exception:
            pass
        out.append({
            "id": d["_id"],
            "filename": d.get("filename"),
            "label": d.get("label"),
            "size_bytes": d.get("size_bytes"),
            "size_human": _human_size(d.get("size_bytes", 0)),
            "encrypted": d.get("encrypted", False),
            "created_at": d.get("created_at"),
            "created_by": d.get("created_by"),
            "deleted_at": deleted_at_iso,
            "deleted_by": d.get("deleted_by"),
            "retention_days": retention,
            "days_remaining": days_remaining,
            "restore_path": d.get("restore_path"),
            "counts": d.get("counts", {}),
            "total_rows": sum((d.get("counts", {}) or {}).values()),
        })
    return out


@router.post("/trash/{bk_id}/restore")
async def restore_from_trash(bk_id: str, request: Request, user=Depends(require_admin)):
    """Restore a soft-deleted backup — moves the file back to the active dir
    and clears the trash flags on the DB record."""
    db = request.app.state.db
    doc = await db.backups.find_one({"_id": bk_id, "is_deleted": True})
    if not doc:
        raise HTTPException(404, "Backup not found in trash")
    src = os.path.join(BACKUP_TRASH_DIR, doc["filename"])
    dst = doc.get("restore_path") or os.path.join(BACKUP_DIR, doc["filename"])
    if os.path.exists(src):
        try:
            os.replace(src, dst)
        except OSError as e:
            raise HTTPException(500, f"Could not restore file: {e}")
    await db.backups.update_one(
        {"_id": bk_id},
        {"$unset": {"is_deleted": "", "deleted_at": "", "deleted_by": "", "restore_path": "", "retention_days": ""}},
    )
    return {"ok": True, "restored": True, "filename": doc["filename"]}


@router.post("/trash/empty")
async def empty_trash(request: Request, user=Depends(require_admin)):
    """Permanently purge ALL items currently in trash. Irrecoverable."""
    db = request.app.state.db
    docs = await db.backups.find({"is_deleted": True}, {"filename": 1}).to_list(1000)
    purged = 0
    for d in docs:
        p = os.path.join(BACKUP_TRASH_DIR, d.get("filename", ""))
        if os.path.exists(p):
            try:
                os.remove(p)
            except OSError:
                pass
        purged += 1
    res = await db.backups.delete_many({"is_deleted": True})
    return {"ok": True, "purged": purged, "db_records_removed": res.deleted_count}


@router.post("/trash/auto-clean")
async def auto_clean_trash(request: Request, user=Depends(require_admin)):
    """Purge any trash item whose retention window has expired. Safe to call
    repeatedly — designed for a daily cron and also exposed as a manual button."""
    db = request.app.state.db
    docs = await db.backups.find({"is_deleted": True}).to_list(1000)
    now = datetime.now(timezone.utc)
    purged = 0
    for d in docs:
        deleted_at_iso = d.get("deleted_at", "")
        retention = int(d.get("retention_days", DEFAULT_RETENTION_DAYS) or DEFAULT_RETENTION_DAYS)
        try:
            deleted_at = datetime.fromisoformat(deleted_at_iso.replace("Z", "+00:00")) if deleted_at_iso else None
        except Exception:
            deleted_at = None
        if deleted_at is None or (now - deleted_at).days < retention:
            continue
        p = os.path.join(BACKUP_TRASH_DIR, d.get("filename", ""))
        if os.path.exists(p):
            try:
                os.remove(p)
            except OSError:
                pass
        await db.backups.delete_one({"_id": d["_id"]})
        purged += 1
    return {"ok": True, "purged": purged, "as_of": _now_iso()}


# ============ Verify (integrity check) ====================================
@router.post("/verify/{bk_id}")
async def verify_backup(bk_id: str, request: Request, user=Depends(require_admin)):
    db = request.app.state.db
    doc = await db.backups.find_one({"_id": bk_id})
    if not doc:
        raise HTTPException(404, "Not found")
    path = os.path.join(BACKUP_DIR, doc["filename"])
    if not os.path.exists(path):
        return {"ok": False, "reason": "File missing on disk"}
    with open(path, "rb") as f:
        blob = f.read()
    try:
        if doc.get("encrypted"):
            blob = _fernet().decrypt(blob)
        raw = gzip.decompress(blob)
        parsed = json.loads(raw.decode("utf-8"))
    except Exception as e:
        return {"ok": False, "reason": f"Decode failed: {type(e).__name__}: {str(e)[:120]}"}

    # Recompute hash: server stored sha256 inside manifest, so we recompute with
    # that field removed.
    manifest = parsed.get("manifest") or {}
    expected = manifest.pop("sha256", None)
    raw2 = json.dumps({**parsed, "manifest": manifest}, ensure_ascii=False).encode("utf-8")
    actual = hashlib.sha256(raw2).hexdigest()
    matches = (expected == actual)

    counts_now = {k: len(v) for k, v in (parsed.get("data") or {}).items()}
    return {
        "ok": True,
        "sha256_matches": matches,
        "expected_sha256": expected,
        "counts": counts_now,
        "total_rows": sum(counts_now.values()),
    }


# ============ Restore =====================================================
class RestoreIn(BaseModel):
    bk_id: Optional[str] = None
    mode: str = "merge"  # merge | replace
    collections: Optional[List[str]] = None  # subset only
    dry_run: bool = False                    # v12.4 — preview what would happen without writing


def _read_backup_payload(doc: dict) -> dict:
    """Helper — open + decrypt + decompress a backup record's file (works for
    both active and trashed records). Returns the parsed JSON payload."""
    # Find file — try active first, fall back to trash for soft-deleted ones
    candidates = [os.path.join(BACKUP_DIR, doc["filename"])]
    if doc.get("is_deleted"):
        candidates.insert(0, os.path.join(BACKUP_TRASH_DIR, doc["filename"]))
    path = next((p for p in candidates if os.path.exists(p)), None)
    if not path:
        raise HTTPException(404, "Backup file missing on disk")
    with open(path, "rb") as f:
        blob = f.read()
    if doc.get("encrypted"):
        try:
            blob = _fernet().decrypt(blob)
        except Exception:
            raise HTTPException(400, "Decryption failed — wrong master key")
    try:
        return json.loads(gzip.decompress(blob).decode("utf-8"))
    except Exception:
        raise HTTPException(400, "Corrupt backup file")


@router.get("/{bk_id}/contents")
async def backup_contents(bk_id: str, request: Request, user=Depends(require_admin)):
    """v12.4 — Preview what's inside a backup BEFORE restoring. Returns per-
    collection row counts and the first 3 sample rows so the user can decide
    whether to do a full restore, partial restore, or merge vs replace."""
    db = request.app.state.db
    doc = await db.backups.find_one({"_id": bk_id})
    if not doc:
        raise HTTPException(404, "Backup not found")
    parsed = _read_backup_payload(doc)
    data = parsed.get("data") or {}
    out: list[dict] = []
    for coll, rows in data.items():
        if not isinstance(rows, list):
            continue
        out.append({
            "collection": coll,
            "row_count": len(rows),
            "samples": [{k: v for k, v in r.items() if k in ("_id", "name", "invoice_no", "label", "type", "company_id", "created_at")} for r in rows[:3] if isinstance(r, dict)],
        })
    out.sort(key=lambda x: -x["row_count"])    # heaviest first
    return {
        "bk_id": bk_id,
        "filename": doc.get("filename"),
        "label": doc.get("label"),
        "encrypted": doc.get("encrypted", False),
        "created_at": doc.get("created_at"),
        "is_deleted": bool(doc.get("is_deleted")),
        "version": parsed.get("version"),
        "snapshot_at": parsed.get("snapshot_at"),
        "collections": out,
        "total_collections": len(out),
        "total_rows": sum(c["row_count"] for c in out),
    }


@router.post("/restore")
async def restore_backup(payload: RestoreIn, request: Request, user=Depends(require_admin)):
    db = request.app.state.db
    if not payload.bk_id:
        raise HTTPException(400, "bk_id required")
    doc = await db.backups.find_one({"_id": payload.bk_id})
    if not doc:
        raise HTTPException(404, "Backup not found")
    parsed = _read_backup_payload(doc)

    data = parsed.get("data") or {}
    collections = payload.collections or list(data.keys())
    summary = {}

    # v12.4 — Dry-run: simulate without writing. Reports what WOULD be touched.
    if payload.dry_run:
        for coll in collections:
            rows = data.get(coll) or []
            if not isinstance(rows, list):
                continue
            # Count how many would insert vs update by checking _id presence
            ids = [r.get("_id") for r in rows if isinstance(r, dict) and r.get("_id")]
            existing_count = 0
            if ids:
                try:
                    # Sample-check: count up to 500 ids that already exist
                    cur = db[coll].find({"_id": {"$in": ids[:500]}}, {"_id": 1})
                    existing_count = len([_ async for _ in cur])
                except Exception:
                    existing_count = 0
            current_total = await db[coll].count_documents({})
            summary[coll] = {
                "rows_in_backup": len(rows),
                "would_update": existing_count if payload.mode == "merge" else 0,
                "would_insert": len(rows) - existing_count if payload.mode == "merge" else len(rows),
                "current_total": current_total,
                "would_delete_first": current_total if payload.mode == "replace" else 0,
            }
        return {
            "ok": True,
            "dry_run": True,
            "mode": payload.mode,
            "summary": summary,
            "warning": "No changes made. Re-run without dry_run=true to actually restore.",
        }

    for coll in collections:
        rows = data.get(coll) or []
        if not isinstance(rows, list):
            continue
        actual = json.loads(json_util.dumps(rows), object_hook=json_util.object_hook)

        # If replace mode, wipe first
        if payload.mode == "replace":
            try:
                await db[coll].delete_many({})
            except Exception:
                pass

        # Bulk upsert by _id
        inserted = 0
        updated = 0
        for d in actual:
            if isinstance(d, dict) and "_id" in d:
                r = await db[coll].update_one({"_id": d["_id"]}, {"$set": d}, upsert=True)
                if r.matched_count:
                    updated += 1
                else:
                    inserted += 1
        summary[coll] = {"inserted": inserted, "updated": updated, "total": len(actual)}

    return {
        "ok": True,
        "mode": payload.mode,
        "summary": summary,
        "total_inserted": sum(s["inserted"] for s in summary.values()),
        "total_updated": sum(s["updated"] for s in summary.values()),
    }


@router.post("/upload")
async def upload_backup(request: Request, file: UploadFile = File(...), user=Depends(require_admin)):
    """Allow re-upload of an externally-stored backup so users can restore from
    files saved on their computer or Google Drive."""
    db = request.app.state.db
    blob = await file.read()
    if not blob:
        raise HTTPException(400, "Empty file")
    ts = datetime.now(timezone.utc)
    bk_id = ts.strftime("%Y%m%d_%H%M%S_") + secrets.token_hex(3)
    fname = file.filename or f"upload_{bk_id}.bin"
    safe = _safe_id(fname)[:60]
    path = os.path.join(BACKUP_DIR, safe)
    with open(path, "wb") as f:
        f.write(blob)

    # Try to decode for metadata
    encrypted = safe.endswith(".enc")
    counts = {}
    sha = ""
    try:
        b = blob
        if encrypted:
            b = _fernet().decrypt(b)
        parsed = json.loads(gzip.decompress(b).decode("utf-8"))
        counts = (parsed.get("manifest") or {}).get("counts", {}) or {}
        sha = (parsed.get("manifest") or {}).get("sha256", "")
    except Exception:
        pass

    await db.backups.insert_one({
        "_id": bk_id,
        "filename": safe,
        "label": "uploaded",
        "encrypted": encrypted,
        "size_bytes": len(blob),
        "counts": counts,
        "sha256": sha,
        "created_at": ts.isoformat(),
        "created_by": user["email"],
        "source": "upload",
    })
    return {"id": bk_id, "filename": safe, "size_bytes": len(blob)}


# ============ Auto-Backup Scheduler =======================================
class ScheduleIn(BaseModel):
    enabled: bool = True
    interval_hours: int = 24
    keep_last_n: int = 7
    encrypt: bool = True


@router.get("/schedule")
async def get_schedule(request: Request, user=Depends(require_admin)):
    db = request.app.state.db
    doc = await db.app_settings.find_one({"_id": "backup_schedule"}) or {}
    return {
        "enabled": bool(doc.get("enabled", False)),
        "interval_hours": int(doc.get("interval_hours", 24)),
        "keep_last_n": int(doc.get("keep_last_n", 7)),
        "encrypt": bool(doc.get("encrypt", True)),
        "next_run_at": doc.get("next_run_at"),
        "last_run_at": doc.get("last_run_at"),
        "last_run_id": doc.get("last_run_id"),
    }


@router.put("/schedule")
async def set_schedule(payload: ScheduleIn, request: Request, user=Depends(require_admin)):
    db = request.app.state.db
    interval = max(1, min(720, int(payload.interval_hours)))
    keep = max(1, min(60, int(payload.keep_last_n)))
    next_run = (datetime.now(timezone.utc) + timedelta(hours=interval)).isoformat()
    update = {
        "enabled": bool(payload.enabled),
        "interval_hours": interval,
        "keep_last_n": keep,
        "encrypt": bool(payload.encrypt),
        "next_run_at": next_run if payload.enabled else None,
        "updated_by": user["email"],
        "updated_at": _now_iso(),
    }
    await db.app_settings.update_one({"_id": "backup_schedule"}, {"$set": update}, upsert=True)
    return {"ok": True, **update}


# Internal helper called by the apscheduler job (registered in server.py)
async def run_scheduled_backup(db):
    sched = await db.app_settings.find_one({"_id": "backup_schedule"}) or {}
    if not sched.get("enabled"):
        return
    ts = datetime.now(timezone.utc)
    bk_id = ts.strftime("%Y%m%d_%H%M%S_") + secrets.token_hex(3)
    filename = f"rmregal_auto_{bk_id}.json.gz.enc"
    path = os.path.join(BACKUP_DIR, filename)

    counts = {}
    snapshot = {"manifest": {"kind": "rm-regal-backup", "version": 1, "created_at": ts.isoformat(),
                              "label": "auto", "encrypted": True, "created_by": "scheduler"}, "data": {}}
    for coll in BACKUP_COLLECTIONS:
        try:
            docs = await db[coll].find({}).to_list(200000)
        except Exception:
            docs = []
        counts[coll] = len(docs)
        snapshot["data"][coll] = json.loads(json_util.dumps(docs))
    snapshot["manifest"]["counts"] = counts
    raw = json.dumps(snapshot, ensure_ascii=False).encode("utf-8")
    sha = hashlib.sha256(raw).hexdigest()
    snapshot["manifest"]["sha256"] = sha
    raw = json.dumps(snapshot, ensure_ascii=False).encode("utf-8")
    blob = _fernet().encrypt(gzip.compress(raw, compresslevel=6))
    with open(path, "wb") as f:
        f.write(blob)
    size = os.path.getsize(path)

    await db.backups.insert_one({
        "_id": bk_id, "filename": filename, "label": "auto", "encrypted": True,
        "size_bytes": size, "counts": counts, "sha256": sha,
        "created_at": ts.isoformat(), "created_by": "scheduler",
    })

    # Retention — keep newest N, soft-delete (trash) older ones so they're recoverable
    keep_n = int(sched.get("keep_last_n", 7))
    older = await db.backups.find({"label": "auto", "is_deleted": {"$ne": True}}).sort("created_at", -1).skip(keep_n).to_list(100)
    for d in older:
        try:
            src = os.path.join(BACKUP_DIR, d["filename"])
            dst = os.path.join(BACKUP_TRASH_DIR, d["filename"])
            if os.path.exists(src):
                os.replace(src, dst)
        except Exception:
            pass
        await db.backups.update_one(
            {"_id": d["_id"]},
            {"$set": {
                "is_deleted": True,
                "deleted_at": ts.isoformat(),
                "deleted_by": "scheduler",
                "restore_path": os.path.join(BACKUP_DIR, d["filename"]),
                "retention_days": DEFAULT_RETENTION_DAYS,
            }},
        )

    # Update schedule meta
    next_run = (ts + timedelta(hours=int(sched.get("interval_hours", 24)))).isoformat()
    await db.app_settings.update_one(
        {"_id": "backup_schedule"},
        {"$set": {"last_run_at": ts.isoformat(), "last_run_id": bk_id, "next_run_at": next_run}},
    )
    return {"ok": True, "id": bk_id, "size": size}
