#!/usr/bin/env python3
"""
Automated backup — scheduled DB + config + ledger backups with rotation.

Usage:
  python3 ops/auto_backup.py                 # Run now
  python3 ops/auto_backup.py --list           # List existing backups
  python3 ops/auto_backup.py --prune 30       # Keep last 30 backups, delete rest

Designed to run via cron daily or launchd.
"""
from __future__ import annotations

import argparse
import json
import os
import shutil
import sqlite3
import tarfile
import time
from datetime import datetime, timezone
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
BACKUP_DIR = ROOT / "data" / "backups"
MANIFEST_FILE = BACKUP_DIR / "manifest.json"
MAX_BACKUPS = int(os.environ.get("GAT_BACKUP_KEEP", "30"))

# What to back up
BACKUP_SOURCES = [
    # (path, description)
    ("data/bridge/state/agent_state.db", "sqlite"),
    ("data/auth/accounts.db", "sqlite"),
    ("data/payment/payments.db", "sqlite"),
    ("license_server/data/licenses.db", "sqlite"),
    ("config/", "config"),
    ("logs/decision_ledger.ndjson", "ledger"),
    ("logs/trading_ledger.ndjson", "ledger"),
    ("logs/risk_events.ndjson", "ledger"),
    ("data/onboarding/state.json", "state"),
    ("data/compliance/accepted_disclaimers.json", "state"),
]


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

def _now_compact() -> str:
    return datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")


def _load_manifest() -> dict:
    if MANIFEST_FILE.exists():
        try:
            return json.loads(MANIFEST_FILE.read_text(encoding="utf-8"))
        except (json.JSONDecodeError, OSError):
            pass
    return {"backups": []}

def _save_manifest(m: dict) -> None:
    MANIFEST_FILE.parent.mkdir(parents=True, exist_ok=True)
    MANIFEST_FILE.write_text(json.dumps(m, ensure_ascii=False, indent=2), encoding="utf-8")


def create_backup(label: str = "") -> dict:
    """Create a full backup. Returns manifest entry."""
    ts = _now_compact()
    backup_id = f"backup_{ts}"
    backup_path = BACKUP_DIR / backup_id
    backup_path.mkdir(parents=True, exist_ok=True)

    files_copied = []
    total_size = 0

    for rel_path, category in BACKUP_SOURCES:
        src = ROOT / rel_path
        if not src.exists():
            continue
        dst = backup_path / rel_path
        dst.parent.mkdir(parents=True, exist_ok=True)

        if src.is_dir():
            for f in src.rglob("*"):
                if f.is_file():
                    rel = f.relative_to(src)
                    (dst / rel).parent.mkdir(parents=True, exist_ok=True)
                    size = f.stat().st_size
                    shutil.copy2(f, dst / rel)
                    files_copied.append(str(rel))
                    total_size += size
        else:
            size = src.stat().st_size
            shutil.copy2(src, dst)
            files_copied.append(rel_path)
            total_size += size

    # Create tar.gz archive
    archive_name = f"{backup_id}.tar.gz"
    archive_path = BACKUP_DIR / archive_name
    with tarfile.open(archive_path, "w:gz") as tar:
        tar.add(backup_path, arcname=backup_id)
    archive_size = archive_path.stat().st_size

    # Remove uncompressed directory
    shutil.rmtree(backup_path)

    entry = {
        "id": backup_id,
        "archive": archive_name,
        "created_at": _now(),
        "label": label,
        "file_count": len(files_copied),
        "uncompressed_size": total_size,
        "compressed_size": archive_size,
        "compression_ratio": round(archive_size / max(1, total_size) * 100),
    }

    manifest = _load_manifest()
    manifest["backups"].append(entry)
    _save_manifest(manifest)

    return entry


def prune_backups(keep: int = MAX_BACKUPS) -> list[str]:
    """Delete oldest backups, keeping the N most recent. Returns list of deleted IDs."""
    manifest = _load_manifest()
    backups = manifest.get("backups", [])
    if len(backups) <= keep:
        return []

    # Sort by created_at, oldest first
    backups.sort(key=lambda b: b.get("created_at", ""))
    to_delete = backups[:-keep]
    deleted = []

    for entry in to_delete:
        archive_path = BACKUP_DIR / entry.get("archive", "")
        if archive_path.exists():
            archive_path.unlink()
        deleted.append(entry["id"])

    manifest["backups"] = backups[-keep:]
    _save_manifest(manifest)
    return deleted


def list_backups() -> list[dict]:
    manifest = _load_manifest()
    backups = manifest.get("backups", [])
    backups.sort(key=lambda b: b.get("created_at", ""), reverse=True)
    return backups


def main() -> None:
    parser = argparse.ArgumentParser(description="Gold AutoTrade Backup Manager")
    parser.add_argument("--label", default="manual", help="Backup label")
    parser.add_argument("--list", action="store_true", help="List existing backups")
    parser.add_argument("--prune", type=int, default=0, help="Prune, keeping N most recent")
    args = parser.parse_args()

    if args.list:
        backups = list_backups()
        if not backups:
            print("No backups found.")
            return
        print(f"{'ID':<24} {'Date':<22} {'Files':>6} {'Size':>10} {'Ratio':>6} {'Label'}")
        print("-" * 90)
        for b in backups:
            print(f"{b['id']:<24} {b['created_at'][:19]:<22} {b['file_count']:>6} "
                  f"{_h(b['compressed_size']):>10} {b['compression_ratio']:>5}% {b.get('label', '')}")
        return

    if args.prune:
        deleted = prune_backups(args.prune)
        print(f"Pruned {len(deleted)} old backup(s), kept {args.prune}.")
        for d in deleted:
            print(f"  Deleted: {d}")
        return

    # Create backup
    entry = create_backup(args.label)
    print(f"Backup created: {entry['id']}")
    print(f"  Files: {entry['file_count']}")
    print(f"  Size:  {_h(entry['compressed_size'])} (compressed from {_h(entry['uncompressed_size'])})")
    print(f"  Archive: {entry['archive']}")


def _h(size: int) -> str:
    for unit in ("B", "KB", "MB", "GB"):
        if size < 1024:
            return f"{size:.0f}{unit}"
        size /= 1024
    return f"{size:.0f}TB"


if __name__ == "__main__":
    main()
