#!/usr/bin/env python3
"""od-wip — global list/restore CLI for worktree-gc salvaged WIP.

Idle worktrees are salvaged before deletion (see modules/monitor/lib/worktree_gc.py)
into three overlapping sources of truth, most durable first:
  1. origin `wip/<branch-label>-<YYYYMMDD>-<sha7>` refs (visible, git-native copy of
     tracked + modified content, secrets stripped)
  2. local `refs/system-monitor/worktree-archive/<sha>` refs in the ORIGINATING repo
     (keeps the archived commit reachable after the worktree is removed, network-free)
  3. vault entries under `~/.local/state/overdeck/worktree-vault`
     (`SM_WORKTREE_GC_VAULT_ROOT` overrides), one dir per salvage:
       <repo-name>--<branch-label>--<YYYYMMDD-HHMMSS>--<sha7>/
         manifest.json   (0600) — see MANIFEST CONTRACT below
         delta.tar.zst   (0600) — lstat-preserving archive of every path
                                  `git status --porcelain --ignored=matching` reports
                                  (modified/untracked/ignored/secret); omitted when the
                                  delta was empty

MANIFEST CONTRACT — the JSON od-wip reads from each vault entry's manifest.json.
This is the seam between worktree_gc.py's salvager and this CLI:
  repo            str   canonical absolute repo root (git-common-dir parent)
  worktree        str   worktree path at salvage time
  branch          str | null   short branch label (refs/heads/ stripped), null if detached
  detached_sha    str | null   set instead of branch when HEAD was detached
  timestamp       str   ISO 8601 UTC, "%Y-%m-%dT%H:%M:%SZ"
  head_sha        str   full sha of the archived commit; matches the local
                        refs/system-monitor/worktree-archive/<head_sha> ref
  archive_sha256  str | null   sha256 of delta.tar.zst; null when no archive file
  archive_bytes   int | null
  wip_ref         str | null   full ref name pushed in salvage step 2, null if nothing
                                new was pushed
  restore_command str   human-facing exact restore command (informational only —
                        od-wip computes its own regardless)

Read-only `list`; `restore` claims a worktree via od-worktree and, when a vault
entry is available, extracts its delta on top. Never overwrites an existing
worktree for the same salvaged item — see resolve_head_sha()/slug determinism.
"""

import argparse
import hashlib
import json
import os
import re
import subprocess
import sys
import tarfile
import time

HOME = os.path.expanduser("~")
VAULT_ROOT_DEFAULT = os.path.join(HOME, ".local", "state", "overdeck", "worktree-vault")
PROJECT_ROOT_DEFAULT = os.path.join(HOME, "Projects")
SELF_DIR = os.path.dirname(os.path.abspath(__file__))
OD_WORKTREE = os.path.join(SELF_DIR, "od-worktree")

WIP_REF_RE = re.compile(r"^wip/(?P<label>.+)-(?P<date>\d{8})-(?P<sha7>[0-9a-f]{7})$")
VAULT_ENTRY_RE = re.compile(
    r"^(?P<repo>.+)--(?P<label>.+)--(?P<ts>\d{8}-\d{6})--(?P<sha7>[0-9a-f]{7})$"
)

EXIT_OK = 0
EXIT_USAGE = 2
EXIT_EXISTS = 3
EXIT_UNREACHABLE = 4


def die(msg, code=EXIT_USAGE):
    print(f"od-wip: {msg}", file=sys.stderr)
    sys.exit(code)


def run(cmd, cwd=None, check=False, timeout=60):
    try:
        return subprocess.run(
            cmd, cwd=cwd, text=True, capture_output=True, timeout=timeout, check=check
        )
    except subprocess.TimeoutExpired:
        return subprocess.CompletedProcess(cmd, 124, "", "timed out")
    except FileNotFoundError as e:
        return subprocess.CompletedProcess(cmd, 127, "", str(e))


def canonical_repo(path):
    r = run(["git", "-C", path, "rev-parse", "--path-format=absolute", "--git-common-dir"])
    if r.returncode != 0:
        return None
    common = r.stdout.strip()
    return os.path.dirname(common)


def discover_repos(root):
    """Direct-child repos of `root` (worktrees share a .git file, not a dir — skipped)."""
    out = []
    try:
        entries = sorted(os.scandir(root), key=lambda e: e.name)
    except OSError:
        return out
    for e in entries:
        if not e.is_dir(follow_symlinks=False):
            continue
        git_path = os.path.join(e.path, ".git")
        if os.path.isdir(git_path):
            out.append(e.path)
    return out


def now_iso():
    return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())


def parse_iso(ts):
    try:
        return time.mktime(time.strptime(ts, "%Y-%m-%dT%H:%M:%SZ")) - time.timezone
    except (ValueError, TypeError):
        return None


def parse_yyyymmdd(s):
    try:
        return time.mktime(time.strptime(s, "%Y%m%d")) - time.timezone
    except (ValueError, TypeError):
        return None


def human_age(epoch_s):
    if epoch_s is None:
        return "unknown"
    delta = max(0, time.time() - epoch_s)
    days = int(delta // 86400)
    if days > 0:
        return f"{days}d"
    hours = int(delta // 3600)
    if hours > 0:
        return f"{hours}h"
    minutes = int(delta // 60)
    return f"{minutes}m"


def human_bytes(n):
    if n is None:
        return "n/a (git-only)"
    for unit in ("B", "KiB", "MiB", "GiB"):
        if n < 1024 or unit == "GiB":
            return f"{n:.0f}{unit}" if unit == "B" else f"{n / 1024:.1f}{unit}"
        n /= 1024
    return f"{n:.1f}GiB"


# ---------------------------------------------------------------------------
# list
# ---------------------------------------------------------------------------

def vault_entries(vault_root):
    out = []
    try:
        names = sorted(os.listdir(vault_root))
    except OSError:
        return out
    for name in names:
        if name.startswith(".tmp-"):
            continue
        entry_dir = os.path.join(vault_root, name)
        manifest_path = os.path.join(entry_dir, "manifest.json")
        try:
            with open(manifest_path) as f:
                manifest = json.load(f)
        except (OSError, ValueError) as e:
            out.append({"kind": "vault", "id": name, "error": f"unreadable manifest: {e}"})
            continue
        out.append({"kind": "vault", "id": name, "manifest": manifest, "dir": entry_dir})
    return out


def origin_wip_refs(repo):
    r = run(["git", "-C", repo, "ls-remote", "--heads", "origin", "wip/*"], timeout=30)
    if r.returncode != 0:
        return None
    out = []
    for line in r.stdout.splitlines():
        line = line.strip()
        if not line:
            continue
        sha, ref = line.split(None, 1)
        short = ref[len("refs/heads/"):] if ref.startswith("refs/heads/") else ref
        out.append({"kind": "wip-ref", "id": short, "sha": sha, "repo": repo})
    return out


def local_archive_refs(repo):
    r = run(
        ["git", "-C", repo, "for-each-ref", "--format=%(objectname) %(refname) %(creatordate:iso-strict)",
         "refs/system-monitor/worktree-archive/"],
        timeout=15,
    )
    if r.returncode != 0:
        return []
    out = []
    for line in r.stdout.splitlines():
        line = line.strip()
        if not line:
            continue
        parts = line.split(None, 2)
        sha = parts[0]
        created = parts[2] if len(parts) > 2 else None
        out.append({"kind": "archive-ref", "id": sha, "sha": sha, "repo": repo, "created": created})
    return out


def merge_rows(repo, vault_recs, wip_recs, archive_recs):
    groups = {}

    def key_for(sha7, fallback_id):
        return sha7 or f"noid:{fallback_id}"

    for rec in vault_recs:
        m = rec.get("manifest")
        if m is None:
            groups[key_for(None, rec["id"])] = {
                "repo": repo, "branch": "(unreadable)", "epoch": None,
                "archive_bytes": None, "vault_id": rec["id"], "wip_ref": None,
                "sources": ["vault(unreadable)"], "note": rec.get("error", ""),
            }
            continue
        sha7 = (m.get("head_sha") or "")[:7] or None
        g = groups.setdefault(key_for(sha7, rec["id"]), {
            "repo": repo, "branch": None, "epoch": None, "archive_bytes": None,
            "vault_id": None, "wip_ref": None, "sources": [],
        })
        g["branch"] = m.get("branch") or (f"detached:{(m.get('detached_sha') or '?')[:7]}")
        g["epoch"] = parse_iso(m.get("timestamp")) or g["epoch"]
        g["archive_bytes"] = m.get("archive_bytes")
        g["vault_id"] = rec["id"]
        g["wip_ref"] = g["wip_ref"] or m.get("wip_ref")
        g["head_sha"] = m.get("head_sha")
        g["sources"].append("vault")

    for rec in wip_recs:
        m = WIP_REF_RE.match(rec["id"])
        sha7 = m.group("sha7") if m else (rec["sha"] or "")[:7]
        g = groups.setdefault(key_for(sha7, rec["id"]), {
            "repo": repo, "branch": None, "epoch": None, "archive_bytes": None,
            "vault_id": None, "wip_ref": None, "sources": [],
        })
        g["branch"] = g["branch"] or (m.group("label") if m else rec["id"])
        g["epoch"] = g["epoch"] or (parse_yyyymmdd(m.group("date")) if m else None)
        g["wip_ref"] = rec["id"]
        g.setdefault("head_sha", rec["sha"])
        g["sources"].append("wip-ref")

    for rec in archive_recs:
        sha7 = (rec["sha"] or "")[:7]
        g = groups.setdefault(key_for(sha7, rec["id"]), {
            "repo": repo, "branch": None, "epoch": None, "archive_bytes": None,
            "vault_id": None, "wip_ref": None, "sources": [],
        })
        g["branch"] = g["branch"] or "(unknown — see vault/wip)"
        if g["epoch"] is None and rec.get("created"):
            try:
                g["epoch"] = time.mktime(time.strptime(rec["created"][:19], "%Y-%m-%dT%H:%M:%S")) - time.timezone
            except ValueError:
                pass
        g.setdefault("head_sha", rec["sha"])
        g["archive_ref_sha"] = rec["sha"]
        g["sources"].append("archive-ref")

    return list(groups.values())


def restore_command_for(row):
    if row.get("vault_id"):
        return f"od-wip restore {row['vault_id']} --repo {row['repo']}"
    if row.get("wip_ref"):
        return f"od-wip restore {row['wip_ref']} --repo {row['repo']}"
    sha = row.get("archive_ref_sha") or row.get("head_sha")
    if sha:
        return f"od-worktree add wip-{sha[:7]} {sha}   (via {row['repo']}; archive-ref only, no wip/vault entry)"
    return "(no restorable identifier)"


def cmd_list(args):
    vault_root = args.vault_root
    if args.repo:
        repo = canonical_repo(args.repo)
        if repo is None:
            die(f"{args.repo} is not inside a git repository")
        repos = [repo]
    else:
        repos = discover_repos(PROJECT_ROOT_DEFAULT)

    all_vault = vault_entries(vault_root)
    rows = []
    for repo in repos:
        vault_recs = [v for v in all_vault if (v.get("manifest") or {}).get("repo") == repo]
        wip_recs = origin_wip_refs(repo) or []
        archive_recs = local_archive_refs(repo)
        rows.extend(merge_rows(repo, vault_recs, wip_recs, archive_recs))

    # vault entries whose manifest repo does not match any discovered/target repo
    # still belong to the listing (that repo may no longer be under the scan root).
    seen_vault_ids = {r.get("vault_id") for r in rows if r.get("vault_id")}
    orphan_by_repo = {}
    for v in all_vault:
        if v["id"] in seen_vault_ids:
            continue
        m = v.get("manifest")
        vrepo = (m or {}).get("repo")
        if args.repo and vrepo != canonical_repo(args.repo):
            continue
        orphan_by_repo.setdefault(vrepo, []).append(v)
    for vrepo, recs in orphan_by_repo.items():
        rows.extend(merge_rows(vrepo or "(unknown repo)", recs, [], []))

    for row in rows:
        row["age"] = human_age(row.get("epoch"))
        row["size"] = human_bytes(row.get("archive_bytes"))
        row["restore_command"] = restore_command_for(row)

    if args.json:
        print(json.dumps(rows, indent=2, sort_keys=True))
        return EXIT_OK

    if not rows:
        print("od-wip: no salvaged items found" + (f" under {args.repo}" if args.repo else f" under {PROJECT_ROOT_DEFAULT}"))
        return EXIT_OK

    for row in sorted(rows, key=lambda r: (r["repo"], -(r.get("epoch") or 0))):
        print(f"{row['repo']}  {row['branch']}  age={row['age']}  size={row['size']}  sources={','.join(row['sources'])}")
        print(f"  restore: {row['restore_command']}")
    return EXIT_OK


# ---------------------------------------------------------------------------
# restore
# ---------------------------------------------------------------------------

def find_vault_entry(vault_root, entry_id):
    entry_dir = os.path.join(vault_root, entry_id)
    manifest_path = os.path.join(entry_dir, "manifest.json")
    if not os.path.isfile(manifest_path):
        return None
    try:
        with open(manifest_path) as f:
            manifest = json.load(f)
    except (OSError, ValueError) as e:
        die(f"vault entry {entry_id} has an unreadable manifest.json: {e}")
    return {"id": entry_id, "dir": entry_dir, "manifest": manifest}


def find_vault_entry_by_wip_ref(vault_root, wip_ref):
    for rec in vault_entries(vault_root):
        m = rec.get("manifest")
        if m and m.get("wip_ref") == wip_ref:
            return {"id": rec["id"], "dir": rec["dir"], "manifest": m}
    return None


def object_reachable(repo, sha):
    r = run(["git", "-C", repo, "cat-file", "-e", sha + "^{commit}"])
    return r.returncode == 0


def resolve_head_sha(repo, head_sha, wip_ref):
    """Ensure `head_sha` (or the commit pointed to by `wip_ref`) is a locally
    reachable commit in `repo`, fetching from origin if needed. Returns the
    committish to hand to od-worktree, or None if unreachable."""
    if head_sha and object_reachable(repo, head_sha):
        return head_sha
    if wip_ref:
        r = run(["git", "-C", repo, "fetch", "origin", wip_ref], timeout=60)
        if r.returncode != 0:
            return None
        r2 = run(["git", "-C", repo, "rev-parse", "FETCH_HEAD"])
        if r2.returncode != 0:
            return None
        sha = r2.stdout.strip()
        if head_sha and sha != head_sha:
            # origin has moved past the archived sha; still restorable, just not
            # byte-identical to the manifest — the archived sha remains the
            # authoritative id, so refuse rather than silently substituting.
            return None
        return sha
    return None


def sanitize_slug(s):
    s = re.sub(r"[^A-Za-z0-9._-]", "-", s)
    if not s or not re.match(r"^[A-Za-z0-9]", s):
        s = "w" + s
    return s


def extract_delta(archive_path, dest):
    with tarfile.open(archive_path, "r:zst") as tf:
        for member in tf.getmembers():
            try:
                tarfile.data_filter(member, dest)
            except tarfile.FilterError as exc:
                die(f"archive member escapes destination: {exc}", EXIT_UNREACHABLE)
        tf.extractall(dest, filter="data")


def cmd_restore(args):
    vault_root = args.vault_root
    entry_id = args.id

    vault_entry = None
    wip_ref = None

    if VAULT_ENTRY_RE.match(entry_id) or os.path.isdir(os.path.join(vault_root, entry_id)):
        vault_entry = find_vault_entry(vault_root, entry_id)
        if vault_entry is None:
            die(f"no vault entry named {entry_id} under {vault_root} (run `od-wip list` to see valid ids)")
        manifest = vault_entry["manifest"]
        wip_ref = manifest.get("wip_ref")
        repo = args.repo and canonical_repo(args.repo) or manifest.get("repo")
        if repo is None:
            die(f"{args.repo} is not inside a git repository" if args.repo else "vault manifest has no repo and --repo was not given")
        head_sha = manifest.get("head_sha")
        branch_label = manifest.get("branch") or (manifest.get("detached_sha") or "detached")[:7]
    elif entry_id.startswith("wip/"):
        wip_ref = entry_id
        m = WIP_REF_RE.match(entry_id)
        if not m:
            die(f"{entry_id} does not match wip/<label>-<YYYYMMDD>-<sha7>")
        if args.repo:
            repo = canonical_repo(args.repo)
            if repo is None:
                die(f"{args.repo} is not inside a git repository")
        else:
            candidates = []
            for r in discover_repos(PROJECT_ROOT_DEFAULT):
                refs = origin_wip_refs(r) or []
                if any(x["id"] == wip_ref for x in refs):
                    candidates.append(r)
            if len(candidates) == 0:
                die(f"{wip_ref} not found on origin in any repo under {PROJECT_ROOT_DEFAULT} — pass --repo <path>")
            if len(candidates) > 1:
                die(f"{wip_ref} exists in multiple repos ({', '.join(candidates)}) — pass --repo <path> to disambiguate")
            repo = candidates[0]
        head_sha = m.group("sha7")  # short only; resolve_head_sha will fetch the full sha
        branch_label = m.group("label")
        vault_entry = find_vault_entry_by_wip_ref(vault_root, wip_ref)
        if vault_entry:
            head_sha = vault_entry["manifest"].get("head_sha") or head_sha
    else:
        die(f"{entry_id} is not a wip/* ref or a vault entry id — see `od-wip list` for valid ids")

    fetch_ref = wip_ref
    resolved = resolve_head_sha(repo, head_sha if head_sha and len(head_sha) >= 40 else None, fetch_ref)
    if resolved is None:
        # try short sha directly (archive-ref namespace stores full sha; a
        # locally-reachable short sha still resolves via cat-file)
        if head_sha and object_reachable(repo, head_sha):
            resolved = head_sha
        else:
            die(
                f"commit {head_sha or '?'} is not reachable in {repo} and could not be fetched "
                f"from origin{' via ' + fetch_ref if fetch_ref else ''} — check "
                f"refs/system-monitor/worktree-archive/{head_sha or '<sha>'} or origin wip/* by hand",
                EXIT_UNREACHABLE,
            )

    sha7 = resolved[:7] if len(resolved) >= 7 else resolved
    slug = sanitize_slug(f"wip-{sha7}")

    r = run([OD_WORKTREE, "add", slug, resolved], cwd=repo)
    if r.returncode != 0:
        stderr = r.stderr.strip()
        if "already exists" in stderr:
            existing = os.path.join(repo, ".worktrees", slug)
            die(f"a worktree for this item already exists at {existing} — nothing done (never overwrites)", EXIT_EXISTS)
        die(f"od-worktree add failed: {stderr or r.stdout.strip()}")
    dest = r.stdout.strip().splitlines()[-1] if r.stdout.strip() else os.path.join(repo, ".worktrees", slug)

    extracted = False
    if vault_entry:
        archive_path = os.path.join(vault_entry["dir"], "delta.tar.zst")
        if os.path.isfile(archive_path):
            manifest = vault_entry["manifest"]
            expected_sha256 = manifest.get("archive_sha256")
            if expected_sha256:
                h = hashlib.sha256()
                with open(archive_path, "rb") as f:
                    for chunk in iter(lambda: f.read(1 << 20), b""):
                        h.update(chunk)
                if h.hexdigest() != expected_sha256:
                    die(f"archive {archive_path} sha256 mismatch (corrupt vault entry) — worktree {dest} was created but is INCOMPLETE; remove it and investigate", EXIT_UNREACHABLE)
            extract_delta(archive_path, dest)
            extracted = True

    result = {
        "repo": repo, "worktree": dest, "slug": slug, "committish": resolved,
        "vault_entry": vault_entry["id"] if vault_entry else None,
        "wip_ref": wip_ref, "delta_extracted": extracted,
    }
    if args.json:
        print(json.dumps(result, indent=2, sort_keys=True))
    else:
        print(dest)
        if extracted:
            print("delta extracted (untracked/ignored/secret files restored)")
    return EXIT_OK


def main():
    ap = argparse.ArgumentParser(
        prog="od-wip",
        description="List and restore worktree-gc salvaged WIP (origin wip/* refs, "
                     "local archive refs, and the vault).",
        epilog=(
            "examples:\n"
            "  od-wip list\n"
            "  od-wip list --repo ~/Projects/overdeck --json\n"
            "  od-wip restore wip/foo-20260810-abc1234 --repo ~/Projects/overdeck\n"
            "  od-wip restore overdeck--wt-foo--20260810-120000--abc1234\n"
            "exit codes: 0 ok, 2 usage/fail-closed error, 3 worktree already exists, "
            "4 archive/commit unreachable or corrupt"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    ap.add_argument("--vault-root", default=os.environ.get("SM_WORKTREE_GC_VAULT_ROOT", VAULT_ROOT_DEFAULT))
    sub = ap.add_subparsers(dest="cmd", required=True)

    p_list = sub.add_parser("list", help="list every salvaged item (read-only)")
    p_list.add_argument("--repo", help="only this repo (default: scan every repo under ~/Projects)")
    p_list.add_argument("--json", action="store_true")
    p_list.set_defaults(func=cmd_list)

    p_restore = sub.add_parser("restore", help="restore one salvaged item into a fresh worktree")
    p_restore.add_argument("id", help="a wip/<label>-<date>-<sha7> ref, or a vault entry id")
    p_restore.add_argument("--repo", help="repo to restore into (required unless the id resolves unambiguously)")
    p_restore.add_argument("--json", action="store_true")
    p_restore.set_defaults(func=cmd_restore)

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


if __name__ == "__main__":
    sys.exit(main())
