#!/usr/bin/env python3
"""session-transcript-converge — one source of truth for Claude Code transcripts.

Sweeps every Claude "projects" home this machine holds (the default
~/.claude/projects plus every other vendor/account home discovered on disk),
and for each worktree-keyed project dir (<base>--worktrees-* or
<base>--claude-worktrees-*, excluding the noisy --claude-worktrees-agent-*
subagent dirs) hardlinks its top-level *.jsonl session transcripts into the
matching <base> dir under the DEFAULT home (~/.claude/projects). Same-account
worktree dirs and cross-account dirs are both covered by the same rule.

A hardlink is not a copy: same inode, same bytes, zero duplication, and it
stays live if the source dir is later deleted (e.g. a worktree cleanup) —
so a Claude Code --resume run FROM the base project's cwd sees every
worktree/account session next to the ones already there.

Never moves, deletes, or rewrites a source transcript, and never requires a
live session to be interrupted: linking is purely additive, and a hardlink
to a file a running process still has open is safe (same inode, further
writes remain visible through either path).

Idempotent: safe to run repeatedly (SessionStart hook, systemd timer, or by
hand). Never requires root. stdlib only.
"""

import json
import os
import sys

HOME = os.path.expanduser("~")
DEFAULT_PROJECTS = os.path.realpath(os.path.join(HOME, ".claude", "projects"))
AGENT_WORKTREE_MARKER = "--claude-worktrees-agent-"
WORKTREE_MARKERS = ("--worktrees-", "--claude-worktrees-")
# Vendor/account homes live directly under $HOME as dotdirs (~/.claude,
# ~/.claude2, ~/.claude-openrouter, ...) or inside the systray account
# registries. Scanning all of $HOME would walk multi-gigabyte trees
# (~/.cache, ~/.local/share, ~/.nvm, ~/.cargo) that hold no transcripts —
# bound the search to where these homes actually live instead.
ACCOUNT_REGISTRY_GLOBS = (
    os.path.join(HOME, ".systray-ai", "claude-accounts", "*"),
    os.path.join(HOME, ".overdeck", "systray", "runtime", "accounts", "*"),
    os.path.join(HOME, ".local", "state", "overdeck", "systray", "runtime", "accounts", "*"),
)
MAX_DEPTH = 4
SKIP_DIR_NAMES = {".git", "node_modules", "Projects"}
# Only descend into a top-level dotdir if its name marks it as a Claude Code
# home — this is what this tool exists to unify (see od-auth SKILL: "Claude
# sessions are account-scoped"). An allowlist, not a denylist: credential
# dirs (.ssh, .gnupg, .secrets, .pki) and multi-gigabyte build/package caches
# are never even opened, let alone walked.
TOP_LEVEL_DOTDIR_ALLOW_SUBSTRING = "claude"


def looks_like_transcript(path):
    try:
        with open(path, "rb") as f:
            line = f.readline(4096)
    except OSError:
        return False
    if not line.strip():
        return False
    try:
        d = json.loads(line)
    except ValueError:
        return False
    return isinstance(d, dict) and ("sessionId" in d or "type" in d)


def search_roots():
    """Bare $HOME dotdirs one level deep, plus every registered account's home."""
    roots = []
    try:
        for e in os.scandir(HOME):
            if e.is_dir(follow_symlinks=False) and e.name.startswith(".") \
                    and TOP_LEVEL_DOTDIR_ALLOW_SUBSTRING in e.name.lower():
                roots.append(e.path)
    except OSError:
        pass
    for pattern_dir in ACCOUNT_REGISTRY_GLOBS:
        base = os.path.dirname(pattern_dir)
        try:
            for e in os.scandir(base):
                if e.is_dir(follow_symlinks=False):
                    roots.append(e.path)
        except OSError:
            pass
    return roots


def discover_project_homes():
    """realpath("projects" dir) -> True, for every dir holding >=1 real transcript."""
    homes = {}
    seen_dirs = set()
    stack = [(root, 0) for root in search_roots()]
    while stack:
        cur, depth = stack.pop()
        try:
            real = os.path.realpath(cur)
        except OSError:
            continue
        if real in seen_dirs:
            continue
        seen_dirs.add(real)
        try:
            entries = list(os.scandir(cur))
        except OSError:
            continue
        if os.path.basename(cur) == "projects":
            has_transcript = False
            for e in entries:
                if not e.is_dir(follow_symlinks=False):
                    continue
                try:
                    for t in os.scandir(e.path):
                        if t.is_file() and t.name.endswith(".jsonl") and looks_like_transcript(t.path):
                            has_transcript = True
                            break
                except OSError:
                    pass
                if has_transcript:
                    break
            if has_transcript:
                homes[real] = True
                continue
        if depth >= MAX_DEPTH:
            continue
        for e in entries:
            if not e.is_dir(follow_symlinks=False):
                continue
            if e.name.startswith(".worktrees"):
                continue
            if e.name in SKIP_DIR_NAMES:
                continue
            stack.append((e.path, depth + 1))
    return sorted(homes)


def base_project_name(dirname):
    """<base>--worktrees-* / <base>--claude-worktrees-* -> <base>, else None."""
    for marker in WORKTREE_MARKERS:
        idx = dirname.find(marker)
        if idx > 0:
            return dirname[:idx]
    return None


def same_file(a, b):
    try:
        sa, sb = os.stat(a), os.stat(b)
    except OSError:
        return False
    return sa.st_dev == sb.st_dev and sa.st_ino == sb.st_ino


def converge(dry_run=False):
    return converge_homes(discover_project_homes(), dry_run=dry_run)


def converge_homes(homes, dry_run=False):
    linked = 0
    already = 0
    skipped_agent_dirs = 0
    skipped_no_base = 0
    errors = []

    for home in homes:
        try:
            project_dirs = [d for d in os.scandir(home) if d.is_dir(follow_symlinks=False)]
        except OSError as e:
            errors.append(f"{home}: {e}")
            continue

        home_is_default = os.path.realpath(home) == os.path.realpath(DEFAULT_PROJECTS)
        for d in project_dirs:
            if AGENT_WORKTREE_MARKER in d.name:
                skipped_agent_dirs += 1
                continue
            base = base_project_name(d.name)
            if base is None:
                # A plain (non-worktree) project dir. In the default home it already
                # IS the destination; in any other home its sessions are invisible to
                # the picker until adopted under the same project name here.
                if home_is_default:
                    continue
                base = d.name
            dest_dir = os.path.join(DEFAULT_PROJECTS, base)
            if not os.path.isdir(dest_dir):
                if dry_run:
                    skipped_no_base += 1
                    continue
                try:
                    os.makedirs(dest_dir, exist_ok=True)
                except OSError as e:
                    errors.append(f"cannot create {dest_dir}: {e}")
                    continue

            try:
                sessions = [t for t in os.scandir(d.path)
                            if t.is_file(follow_symlinks=False) and t.name.endswith(".jsonl")
                            and ".live." not in t.name]
            except OSError as e:
                errors.append(f"{d.path}: {e}")
                continue

            for s in sessions:
                dest = os.path.join(dest_dir, s.name)
                if os.path.exists(dest):
                    if same_file(dest, s.path):
                        already += 1
                        continue
                    errors.append(f"collision, left alone: {dest} already exists and is not the same file as {s.path}")
                    continue
                if dry_run:
                    linked += 1
                    continue
                try:
                    os.link(s.path, dest)
                    linked += 1
                except OSError as e:
                    errors.append(f"link failed {s.path} -> {dest}: {e}")

    return {
        "homes_scanned": homes,
        "linked": linked,
        "already_linked": already,
        "skipped_agent_dirs": skipped_agent_dirs,
        "skipped_no_base_dir": skipped_no_base,
        "errors": errors,
    }


def main():
    dry_run = "--dry-run" in sys.argv[1:]
    as_json = "--json" in sys.argv[1:]
    result = converge(dry_run=dry_run)
    if as_json:
        print(json.dumps(result, indent=1))
        return 0
    print(f"homes scanned: {len(result['homes_scanned'])}")
    for h in result["homes_scanned"]:
        print(f"  {h}")
    print(f"linked: {result['linked']} (already linked: {result['already_linked']})")
    print(f"skipped agent-subagent worktree dirs: {result['skipped_agent_dirs']}")
    print(f"skipped (no matching base project dir yet): {result['skipped_no_base_dir']}")
    if result["errors"]:
        print(f"errors ({len(result['errors'])}):")
        for e in result["errors"][:50]:
            print(f"  {e}")
    return 0


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