#!/usr/bin/env python3
"""NexusOS backup and restore - one entry point for Linux and native Windows.

Same command on both boxes. Everything portable lives here: the git sync with
Gitea, the memory-DB dump / compare / rebuild, and the venv + web-UI rebuild.
The parts that only mean something on Linux - apt packages, the bundled Ollama
binary, the XFCE desktop wiring, the desktop snapshot - stay in bash and get
called from here, skipped outright on Windows.

    python bin/sync.py restore [--check]
    python bin/sync.py backup  [--check] [--full] [--force-db]
    python bin/sync.py compare                 # print the direction verdict only

Standard library only, so it runs before the Promethean venv exists and needs no
sqlite3 binary on PATH - Windows has none, which is why the old
bin/db-compare.sh could never work there.

A fresh machine clones first (git clone <repo> nexus-core), then runs this.
"""
import argparse
import os
import shutil
import sqlite3
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
DB = ROOT / "synapse" / "memory" / "memory.db"
DB_SQL = ROOT / "synapse" / "memory" / "memory.db.sql"
DB_SQL_REL = "synapse/memory/memory.db.sql"   # git paths are always posix-style


def run(*args, capture=False, check=False):
    """Run a command from the repo root. Returns CompletedProcess."""
    exe = shutil.which(args[0])   # resolves npm -> npm.cmd on Windows
    if exe is None:
        raise SystemExit(f"'{args[0]}' not found on PATH")
    return subprocess.run(
        [exe, *args[1:]], cwd=ROOT, check=check,
        capture_output=capture, text=True, encoding="utf-8",
    )


def linux_stage(script: str, *args) -> None:
    """Run one of the Linux-only bash stages. A no-op on Windows, where apt,
    xfconf, plank and the rest have nothing to act on."""
    if os.name == "nt":
        return
    path = ROOT / "bin" / script
    bash = shutil.which("bash")
    if path.exists() and bash:
        # Pin the stage to THIS repo. It defaults to ~/nexus-core otherwise, so a
        # clone in a scratch dir would restore over the real machine instead.
        subprocess.run([bash, str(path), *args], cwd=ROOT, check=False,
                       env={**os.environ, "NEXUS_ROOT": str(ROOT)})


def venv_python() -> Path:
    """Path to the Promethean interpreter, creating the venv if it's missing."""
    venv = ROOT / "Promethean"
    py = venv / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
    if not py.exists():
        subprocess.run([sys.executable, "-m", "venv", str(venv)], check=True)
    return py


def requirements() -> str:
    """Pick the PyTorch overlay for this host."""
    if os.name == "nt":
        return "requirements-windows.txt"        # CPU / pure-Python, right for native Windows
    if shutil.which("nvidia-smi"):
        return "requirements-nvidia.txt"
    lspci = shutil.which("lspci")
    if lspci and "nvidia" in subprocess.run(
            [lspci], capture_output=True, text=True).stdout.lower():
        return "requirements-nvidia.txt"
    return "requirements-amd.txt"


# -- memory DB -----------------------------------------------------------------

def dump_db() -> bool:
    """Dump the (gitignored, binary, WAL) memory DB to a diff-friendly SQL file
    so git backs up the assistant's memory + conversation history. sqlite3 reads
    through the WAL, so the dump has the latest committed data uncheckpointed."""
    if not DB.exists():
        return False
    try:
        with sqlite3.connect(f"file:{DB}?mode=ro", uri=True) as conn:
            DB_SQL.write_text("\n".join(conn.iterdump()) + "\n", encoding="utf-8")
    except sqlite3.Error as exc:
        print(f"Warning: could not dump {DB} ({exc}) - DB not captured")
        return False
    print(f"Dumped memory DB -> {DB_SQL}")
    return True


def _state(conn):
    """What a machine holds: conversations as {id: updated_at} and memory facts
    as a set of ids. NOT messages.id - it's INTEGER AUTOINCREMENT, so both
    machines hand out the same ids independently and comparing them is
    meaningless. A new message bumps its conversation's updated_at, which is
    what actually gets caught here."""
    def query(sql):
        try:
            return conn.execute(sql).fetchall()
        except sqlite3.Error:
            return []   # table absent in an old dump - treat as empty
    return (
        dict(query("select id, updated_at from conversations")),
        {row[0] for row in query("select id from memory")},
    )


def _extra(a, b) -> int:
    """How much `a` holds that `b` lacks: conversations `b` is missing or has an
    older copy of, plus memory facts `b` doesn't have at all. An out-of-date
    copy is NOT extra content on b's side - that asymmetry is what separates
    'one box is simply ahead' from a real divergence."""
    a_conv, a_mem = a
    b_conv, b_mem = b
    newer = sum(1 for cid, ts in a_conv.items() if b_conv.get(cid, "") < ts)
    return newer + len(a_mem - b_mem)


def compare(db: Path = DB, dump: Path = DB_SQL) -> str:
    """Which way the sync should go. One of: same, local-ahead, local-behind,
    diverged, no-live, no-dump.
    ponytail: detects direction, does not merge. Diverged is reported, not resolved."""
    if not dump.exists() or not dump.stat().st_size:
        return "no-dump"
    if not db.exists():
        return "no-live"
    try:
        with sqlite3.connect(f"file:{db}?mode=ro", uri=True) as live_conn:
            live = _state(live_conn)
        with sqlite3.connect(":memory:") as dump_conn:
            dump_conn.executescript(dump.read_text(encoding="utf-8"))
            backup = _state(dump_conn)
    except (sqlite3.Error, OSError):
        return "diverged"   # can't tell: fail safe, refuse both directions
    ahead, behind = _extra(live, backup), _extra(backup, live)
    if ahead and behind:
        return "diverged"
    if ahead:
        return "local-ahead"
    if behind:
        return "local-behind"
    return "same"


def restore_db() -> None:
    """Rebuild the memory DB from the pulled dump, keeping a rollback copy."""
    print()
    state = compare()
    if state == "same":
        print("Memory DB already matches the backup.")
        return
    if state == "local-ahead":
        print("WARNING: this machine has conversations the backup doesn't.")
        print("         Not applying the dump - run `backup` HERE first.")
        print(f"         To discard local history instead: delete {DB} and restore")
        return
    if state == "diverged":
        print("WARNING: this machine and the backup each have conversations the other lacks.")
        print("         Not applying the dump - nothing is merging these automatically.")
        print(f"         Keep local: run `backup` here. Keep remote: delete {DB} and restore")
        return
    if state == "no-dump":
        print("No memory dump in the backup - skipping DB restore.")
        return

    print("Restoring memory DB from backup...")
    rollback = DB.with_suffix(".db.pre-restore")
    if DB.exists():
        shutil.copyfile(DB, rollback)
    for suffix in ("", "-wal", "-shm"):
        Path(str(DB) + suffix).unlink(missing_ok=True)
    try:
        with sqlite3.connect(DB) as conn:
            conn.executescript(DB_SQL.read_text(encoding="utf-8"))
        print("Memory DB restored (conversations + history + facts).")
    except sqlite3.Error as exc:
        print(f"Warning: memory DB rebuild failed ({exc}).")
        if rollback.exists():
            shutil.move(rollback, DB)
            print("Rolled back to previous DB.")


# -- rebuild -------------------------------------------------------------------

def rebuild_env() -> None:
    print("\nRebuilding Python environment...")
    py, req = venv_python(), requirements()
    if (ROOT / req).exists():
        subprocess.run([str(py), "-m", "pip", "install", "--upgrade", "pip", "-q"], check=False)
        subprocess.run([str(py), "-m", "pip", "install", "-r", req], cwd=ROOT, check=False)
    else:
        print(f"Warning: {req} not found - skipping pip install.")

    print("\nRebuilding frontend dependencies...")
    web = ROOT / "interface" / "web"
    npm = shutil.which("npm")
    if npm is None:
        print("Warning: npm not found - the web UI will not be built.")
        return
    subprocess.run([npm, "install"], cwd=web, check=False)
    # Build the UI so the backend can serve it single-process (it mounts
    # interface/web/dist at :8000). Without this the app has no UI to show.
    print("Building frontend...")
    subprocess.run([npm, "run", "build"], cwd=web, check=False)
    if not (web / "dist" / "index.html").exists():
        print("Warning: interface/web/dist/index.html missing - the app will serve no UI.")


# -- commands ------------------------------------------------------------------

def cmd_restore(args) -> int:
    if args.check:
        print("Fetching to preview restore (no changes)...")
        run("git", "fetch", "origin")
        print("\nCommits a restore would apply:")
        print(run("git", "log", "--oneline", "..origin/main", capture=True).stdout or "(up to date)")
        print(run("git", "diff", "--stat", "..origin/main", capture=True).stdout)
        print(f"Memory DB vs backup: {compare()}")
        print("\n(dry-run only - nothing changed. Apply with: restore)")
        return 0

    # System packages first - the venv and npm build below need them present.
    linux_stage("restore-linux.sh", "prep")

    print("\nPulling latest from Gitea...")
    if run("git", "pull", "--ff-only", "origin", "main").returncode:
        print("Pull failed (diverged? stash/commit local changes).")
        return 1
    restore_db()
    rebuild_env()
    linux_stage("restore-linux.sh", "runtime")
    if not args.no_desktop:
        linux_stage("restore-linux.sh", "desktop")
    print("\nRestore complete. Nexus is ready to start.")
    print("Note: Ollama models are not in the backup. Open the Models tab -> Required")
    print("and pull the two models NexusOS depends on (memory curator + embeddings)")
    print("before anything else, then pick a hardware-fit chat model.")
    return 0


def _remote_dump(tmp: Path) -> bool:
    """Write origin/main's dump to tmp. False if it can't be fetched."""
    if run("git", "fetch", "-q", "origin").returncode:
        print("Note: could not reach Gitea - skipping backup safety check.")
        return False
    shown = run("git", "show", f"origin/main:{DB_SQL_REL}", capture=True)
    if shown.returncode:
        return False
    tmp.write_text(shown.stdout, encoding="utf-8")
    return True


def check_db_direction(tmp_dir: Path) -> bool:
    """Mirror of restore's guard: refuse to dump a stale live DB over a backup
    that already holds newer conversations from the other machine. Compares
    against the REMOTE dump, not the working-tree copy - that copy is from this
    box's last backup and is exactly what goes stale when the other box pushes."""
    if not DB.exists():
        return True
    tmp = tmp_dir / "remote.sql"
    if not _remote_dump(tmp):
        return True
    state = compare(DB, tmp)
    if state == "local-behind":
        print("REFUSING TO BACK UP: the backup has conversations this machine doesn't.")
        print("  Backing up now would overwrite them with this box's older history.")
        print("  Run `restore` here first, then back up.")
        return False
    if state == "diverged":
        print("REFUSING TO BACK UP: this machine and the backup each have conversations")
        print("  the other lacks. Nothing merges these automatically.")
        print("  Force this box's history to win: python bin/sync.py backup --force-db")
        return False
    return True


def cmd_backup(args) -> int:
    import tempfile
    if args.full:
        linux_stage("backup-linux.sh")
    dump_db()
    with tempfile.TemporaryDirectory() as tmp_dir:
        safe = check_db_direction(Path(tmp_dir))

    if args.check:
        print(f"\nMemory DB vs backup: {'safe to back up' if safe else 'STALE - restore first'}")
        print("\nFiles a backup would commit:")
        print(run("git", "status", "--short", capture=True).stdout)
        print("Local commits not yet pushed:")
        print(run("git", "log", "--oneline", "@{u}..", capture=True).stdout or "(none / no upstream)")
        print("(dry-run only - nothing committed or pushed. Apply with: backup)")
        return 0
    if not safe and not args.force_db:
        return 1

    run("git", "add", "-A")
    if run("git", "diff", "--cached", "--quiet").returncode:
        stamp = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
        if run("git", "commit", "-q", "-m", f"backup: {stamp}").returncode:
            print("Commit failed - see error above (e.g. git identity not set).")
            return 1
        print("Committed backup snapshot.")
    else:
        print("No changes to commit.")

    print("Pushing to Gitea...")
    if run("git", "push", "origin", "main").returncode:
        print("Push failed. Set up credentials once with:")
        print("  git config credential.helper store   # then push once and enter your Gitea token")
        return 1
    print("Backup complete.")
    return 0


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    sub = parser.add_subparsers(dest="cmd", required=True)

    restore = sub.add_parser("restore", help="pull from Gitea, rebuild DB + venv + web UI")
    restore.add_argument("-c", "--check", action="store_true", help="dry run, change nothing")
    restore.add_argument("--no-desktop", action="store_true",
                         help="skip the XFCE desktop wiring (panel, theme, os-release). "
                              "Use for a test clone - that stage writes to $HOME, not the repo.")
    restore.set_defaults(func=cmd_restore)

    backup = sub.add_parser("backup", help="dump DB, commit and push to Gitea")
    backup.add_argument("-c", "--check", action="store_true", help="dry run, change nothing")
    backup.add_argument("-f", "--full", action="store_true",
                        help="also snapshot the live desktop wiring + Claude notes (Linux)")
    backup.add_argument("--force-db", action="store_true", help="skip the staleness guard")
    backup.set_defaults(func=cmd_backup)

    compare_cmd = sub.add_parser("compare", help="print the sync direction verdict")
    compare_cmd.set_defaults(func=lambda a: (print(compare()), 0)[1])

    args = parser.parse_args()
    return args.func(args)


if __name__ == "__main__":
    raise SystemExit(main())
