"""
Backup of saves + settings — dArkOS's proven recipe, replicated IN-SHELL.

dArkOS ships `/opt/system/Advanced/Backup dArkOS Settings.sh`, but it (like
every dArkOS settings script) is an interactive `dialog` TUI that seizes
/dev/tty1 — we must NEVER shell into it. Instead we lift the command it runs
(`sudo tar -zcf … <config + savestate paths>`) and execute it ourselves, with
output captured so nothing ever reaches a console, and AAhead renders its own
progress. Same split as netops.py:
  • PURE helpers — `fmt_size`, `parse_df_free`, `collect_sources`,
    `build_tar_cmd`, `latest_backup` — fully unit-testable off-device.
  • THIN live layer — `disk_free`, `run_backup` — shell out (tar/df) with
    `sudo -n` (passwordless `ark`, never prompts → never drops to a console),
    tolerant of missing paths via `--ignore-failed-read`.

Backend gates the live layer on `live_enabled()`; on a dev box the Storage
screen runs a simulated flow instead (no /roms to tar).
"""

from __future__ import annotations

import glob
import os
import subprocess
import time
from typing import Optional


# tar can be slow on a big "everything" profile; give it room.
_TIMEOUT = 600
# nmcli/tar need root on device; `ark` has passwordless sudo (see netops/launch).
_SUDO = [] if os.environ.get("AAHEAD_TAR_SUDO") == "0" else ["sudo", "-n"]

# RetroArch keeps saves/states next to the ROM (savestates_in_content_dir),
# so per-ROM saves are found by extension under the roms root.
_SAVE_EXTS = ("srm", "sav", "state", "state.auto", "st0", "st1", "st2",
              "ss0", "ss1", "mcr", "bsv", "rtc")


def roms_root() -> str:
    return os.environ.get("AAHEAD_ROMS_ROOT") or "/roms"


def roms2_root() -> str:
    # dArkOS auto-mounts the 2nd TF card here (its own backup script writes
    # /roms2/backup). Override for testing / non-standard layouts.
    return os.environ.get("AAHEAD_ROMS2_ROOT") or "/roms2"


def home() -> str:
    return os.environ.get("AAHEAD_HOME") or os.path.expanduser("~")


def second_sd_present() -> bool:
    """True iff a real 2nd SD card is mounted at roms2_root(). os.path.ismount
    is the safety gate: a bare /roms2 DIRECTORY on the rootfs (no 2nd card) is
    NOT a mount, so we never write a big backup onto the tiny system partition."""
    r2 = roms2_root()
    try:
        return os.path.ismount(r2)
    except OSError:
        return False


def backup_target(which: str) -> Optional[str]:
    """Resolve a backup destination dir, or None if that target is unavailable.
      which='sd1' → <roms>/backup        (always present)
      which='sd2' → <roms2>/backup       (only when a 2nd card is mounted)"""
    if which == "sd1":
        return os.path.join(roms_root(), "backup")
    if which == "sd2":
        return os.path.join(roms2_root(), "backup") if second_sd_present() else None
    return None


# ── tiers (St2's three profiles) → device paths ───────────────────────
# Layered: saves ⊂ settings ⊂ full. Lifted from the dArkOS backup recipe
# (NetworkManager connections, retroarch cfgs, ES settings, emulator saves).

def _save_dirs(h: str) -> list[str]:
    return [
        f"{h}/.config/ppsspp/PSP/SAVEDATA",
        "/opt/ppsspp/PSP/SAVEDATA",
        "/opt/drastic/backup",
        "/opt/amiberry/savestates",
        "/opt/mupen64plus/InputAutoCfg.ini",
    ]


def _settings_files(h: str) -> list[str]:
    return [
        f"{h}/.config/retroarch/retroarch.cfg",
        f"{h}/.config/retroarch/config",
        f"{h}/.config/retroarch/retroarch-core-options.cfg",
        f"{h}/.config/retroarch32/retroarch.cfg",
        f"{h}/.config/retroarch32/config",
        f"{h}/.config/retroarch32/retroarch-core-options.cfg",
        "/etc/NetworkManager/system-connections",       # saved Wi-Fi networks
        f"{h}/.emulationstation/es_settings.cfg",
        f"{h}/.emulationstation/collections",
        "/opt/drastic/config/drastic.cfg",
    ]


# tier_key → (label, builder). Builder returns STATIC candidate paths (no
# existence check — that's the live layer's job); per-ROM saves are appended
# by run_backup via a content scan.
TIERS = ("saves", "settings", "full")


def collect_sources(tier: str, *, roms: str, home: str) -> list[str]:
    """Static candidate source paths for a tier (de-duped, order-stable).
    Existence is NOT checked here — `tar --ignore-failed-read` skips missing
    ones at backup time, and unit tests can assert the intended set."""
    paths: list[str] = list(_save_dirs(home))
    if tier in ("settings", "full"):
        paths += _settings_files(home)
    if tier == "full":
        paths += [f"{roms}/themes", f"{roms}/screenshots"]
    # de-dupe preserving order
    seen, out = set(), []
    for p in paths:
        if p not in seen:
            seen.add(p); out.append(p)
    return out


def find_rom_saves(roms: str) -> list[str]:
    """Per-ROM save/state files under the roms root (RetroArch keeps them next
    to content). Pure-ish: just a filesystem glob, returns [] if roms absent."""
    out: list[str] = []
    if not os.path.isdir(roms):
        return out
    for ext in _SAVE_EXTS:
        out.extend(glob.glob(os.path.join(roms, "**", f"*.{ext}"),
                             recursive=True))
    return sorted(set(out))


# ── formatting / parsing (pure) ───────────────────────────────────────

def fmt_size(num_bytes) -> str:
    """Human size: 0 B / 42 MB / 1.3 GB (consumer-facing, never raw bytes)."""
    try:
        n = float(num_bytes)
    except (TypeError, ValueError):
        return "—"
    for unit in ("B", "KB", "MB", "GB", "TB"):
        if n < 1024 or unit == "TB":
            if unit == "B":
                return f"{int(n)} {unit}"
            return (f"{n:.1f} {unit}" if n < 10 else f"{int(round(n))} {unit}")
        n /= 1024
    return f"{int(n)} TB"


def parse_df_free(output: str) -> Optional[int]:
    """Free bytes from `df -kP <path>` output (POSIX). 4th column = available
    1K-blocks → ×1024. None if unparseable."""
    lines = [ln for ln in output.splitlines() if ln.strip()]
    if len(lines) < 2:
        return None
    cols = lines[1].split()
    if len(cols) < 4:
        return None
    try:
        return int(cols[3]) * 1024
    except ValueError:
        return None


def latest_backup(backup_dir: str) -> Optional[dict]:
    """Newest *.tar.gz in backup_dir → {path, name, size, mtime} or None."""
    if not os.path.isdir(backup_dir):
        return None
    arcs = glob.glob(os.path.join(backup_dir, "*.tar.gz"))
    if not arcs:
        return None
    newest = max(arcs, key=lambda p: os.path.getmtime(p))
    st = os.stat(newest)
    return {"path": newest, "name": os.path.basename(newest),
            "size": st.st_size, "mtime": st.st_mtime}


def build_tar_cmd(archive: str, filelist: str) -> list[str]:
    """argv for the backup tar: gzip, read members from `filelist`, never fail
    on an absent member. Prefixed with sudo so root-only paths (NM connections)
    are readable."""
    return [*_SUDO, "tar", "-zcf", archive, "--ignore-failed-read",
            "-T", filelist]


# ── live layer (device; tolerant) ─────────────────────────────────────

def _run(cmd: list[str], timeout: int = _TIMEOUT) -> Optional[subprocess.CompletedProcess]:
    try:
        return subprocess.run(cmd, capture_output=True, text=True,
                              timeout=timeout)
    except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
        return None


def disk_free(path: str) -> Optional[int]:
    """Free bytes on the filesystem holding `path`, or None."""
    r = _run(["df", "-kP", path], timeout=10)
    if r is None or r.returncode != 0:
        return None
    return parse_df_free(r.stdout)


def run_backup(tier: str, target_dir: str, *, stamp: Optional[str] = None) -> Optional[dict]:
    """Create a backup archive for `tier` under `target_dir`. Returns
    {ok, archive, size} (ok False on tar failure), or None if we couldn't even
    start (e.g. target unwritable). Output is captured — nothing hits a console.

    `stamp` lets callers/tests pin the timestamp; defaults to now."""
    # Safety: refuse SD2 destinations when no 2nd card is mounted — writing to a
    # bare /roms2 rootfs dir could fill the system partition.
    r2 = os.path.abspath(roms2_root())
    if os.path.abspath(target_dir).startswith(r2) and not second_sd_present():
        return {"ok": False, "archive": None, "size": 0,
                "error": "second SD card not detected"}

    roms, h = roms_root(), home()
    sources = [p for p in collect_sources(tier, roms=roms, home=h)
               if os.path.exists(p)]
    sources += find_rom_saves(roms)
    if not sources:
        return {"ok": False, "archive": None, "size": 0,
                "error": "nothing to back up"}

    # ensure the target dir exists (sudo: /roms/backup may be root-owned)
    mk = _run([*_SUDO, "mkdir", "-p", target_dir], timeout=10)
    if mk is None or mk.returncode != 0:
        return None

    stamp = stamp or time.strftime("%Y%m%d-%H%M")
    archive = os.path.join(target_dir, f"aahead-{tier}-{stamp}.tar.gz")

    # write the member list to a temp file, then tar -T it
    listfile = os.path.join("/tmp", f"aahead-backup-{stamp}.list")
    try:
        with open(listfile, "w", encoding="utf-8") as fh:
            fh.write("\n".join(sources) + "\n")
    except OSError:
        return None

    r = _run(build_tar_cmd(archive, listfile))
    try:
        os.remove(listfile)
    except OSError:
        pass
    if r is None:
        return None
    ok = r.returncode == 0 and os.path.exists(archive)
    size = os.path.getsize(archive) if os.path.exists(archive) else 0
    return {"ok": ok, "archive": archive if ok else None, "size": size,
            "error": None if ok else (r.stderr or "tar failed")[-200:]}
