#!/usr/bin/env python3
"""claude-obsidian-sync -- summarize Claude conversations into an Obsidian vault.

Reads Claude Code transcripts (~/.claude/projects/<project>/<session>.jsonl) and claude.ai
data exports, condenses each conversation, redacts secrets, summarizes it with an
OpenAI-compatible LLM endpoint (e.g. LiteLLM), and writes one note per conversation into
Obsidian through the Local REST API plugin. Standard library only.

  claude-obsidian-sync stats                 # sizes and counts, no network
  claude-obsidian-sync sync [--dry-run]      # Claude Code sessions (incremental)
  claude-obsidian-sync import-claude-ai EXPORT.zip|conversations.json

Configuration comes from the environment, falling back to ~/.config/claude-obsidian/config.env
(KEY=VALUE lines). Nothing environment-specific lives in this file. See README.md.
"""
from __future__ import annotations

import argparse
import concurrent.futures as cf
import datetime as dt
import hashlib
import json
import os
import pathlib
import re
import shutil
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import zipfile

HOME = pathlib.Path.home()
CONFIG_DIR = HOME / ".config" / "claude-obsidian"


# --------------------------------------------------------------------------- config
def load_config() -> dict:
    cfg: dict[str, str] = {}
    f = CONFIG_DIR / "config.env"
    if f.exists():
        for line in f.read_text().splitlines():
            line = line.strip()
            if line and not line.startswith("#") and "=" in line:
                k, v = line.split("=", 1)
                cfg[k.strip()] = v.strip().strip('"').strip("'")
    cfg.update({k: v for k, v in os.environ.items() if k in DEFAULTS or k.startswith("LLM_") or k.startswith("OBSIDIAN_")})
    for k, v in DEFAULTS.items():
        cfg.setdefault(k, v)
    return cfg


DEFAULTS = {
    "OBSIDIAN_API_URL": "http://127.0.0.1:27123",
    "OBSIDIAN_API_KEY_FILE": str(CONFIG_DIR / "api-key"),
    "LLM_BACKEND": "claude-cli",           # claude-cli | openai
    "CLAUDE_BIN": "claude",
    "LLM_BASE_URL": "",
    "LLM_API_KEY_FILE": "",
    "LLM_API_KEY_ENV": "LITELLM_KEY",
    "LLM_MODEL": "haiku",
    "VAULT_FOLDER": "Claude",
    "SETTLE_MINUTES": "30",
    "CLAUDE_PROJECTS_DIR": str(HOME / ".claude" / "projects"),
    "STATE_FILE": str(HOME / ".local" / "state" / "claude-obsidian" / "state.json"),
    "JOBS": "1",
    # Cap per run. Summaries draw on the same plan limits as interactive Claude use, so the
    # timer drips a backlog instead of bursting it (a 107-session burst exhausted a session limit).
    "MAX_PER_RUN": "10",
}


def secret(cfg: dict, file_key: str, env_key: str | None = None) -> str:
    path = cfg.get(file_key)
    if path:
        p = pathlib.Path(os.path.expanduser(path))
        if p.exists():
            return p.read_text().strip()
    if env_key and os.environ.get(cfg.get(env_key, "") or ""):
        return os.environ[cfg[env_key]].strip()
    raise SystemExit(f"missing secret: set {file_key} (or {env_key})")


# --------------------------------------------------------------------------- redaction
# Applied to everything sent to the LLM AND to the note written. Over-redaction is the
# accepted failure mode: a summary missing a token is fine, a vault holding one is not.
_REDACTIONS = [
    (re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----", re.S), "[REDACTED PRIVATE KEY]"),
    (re.compile(r"(?i)\b(bearer|token)\s+[A-Za-z0-9._~+/=-]{16,}"), r"\1 [REDACTED]"),
    (re.compile(r"(?i)((?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|NB_API_KEY|PAT)[\"']?\s*[:=]\s*[\"']?)([^\s\"'`,;]{6,})"), r"\1[REDACTED]"),
    (re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}"), "[REDACTED JWT]"),
    (re.compile(r"\b(?:sk|pk|rk)-[A-Za-z0-9_-]{20,}|\bgh[pousr]_[A-Za-z0-9]{30,}|\bglpat-[A-Za-z0-9_-]{20,}|\bxox[abpr]-[A-Za-z0-9-]{10,}"), "[REDACTED TOKEN]"),
    # >=48 hex: API keys / sha256 tokens. Deliberately leaves 40-hex git SHAs and 32-hex md5s.
    (re.compile(r"\b[a-fA-F0-9]{48,}\b"), "[REDACTED HEX]"),
    # Random mixed-case+digit strings (generated passwords). No '/' so paths survive.
    (re.compile(r"(?<![A-Za-z0-9_./-])(?=[A-Za-z0-9+]*[0-9])(?=[A-Za-z0-9+]*[a-z])(?=[A-Za-z0-9+]*[A-Z])[A-Za-z0-9+]{20,}={0,2}(?![A-Za-z0-9_./-])"), "[REDACTED]"),
]


def redact(text: str) -> str:
    for pat, repl in _REDACTIONS:
        text = pat.sub(repl, text)
    return text


# --------------------------------------------------------------------------- parsing
_NOISE_BLOCKS = re.compile(
    r"<(system-reminder|local-command-stdout|local-command-stderr|local-command-caveat|task-notification)>.*?</\1>",
    re.S,
)
_CMD = re.compile(r"<command-name>(.*?)</command-name>.*?(?:<command-args>(.*?)</command-args>)?", re.S)
MAX_MSG = 6000


def _clip(text: str) -> str:
    return text if len(text) <= MAX_MSG else text[:4000] + "\n[...clipped...]\n" + text[-1500:]


def _clean_user(text: str) -> str:
    text = _NOISE_BLOCKS.sub("", text)
    m = _CMD.search(text)
    if m:
        args = (m.group(2) or "").strip()
        text = f"{m.group(1).strip()} {args}".strip()
    text = re.sub(r"</?command-(message|args|name)>", "", text)
    return text.strip()


def _tool_line(block: dict) -> str:
    name = block.get("name", "tool")
    inp = block.get("input") or {}
    desc = inp.get("description") or inp.get("command") or inp.get("file_path") or inp.get("query") \
        or inp.get("url") or inp.get("prompt") or inp.get("pattern") or ""
    desc = " ".join(str(desc).split())[:160]
    return f"[{name}: {desc}]" if desc else f"[{name}]"


def parse_claude_code(path: pathlib.Path) -> dict:
    meta = {"session_id": path.stem, "cwd": None, "branch": None, "first": None, "last": None,
            "title": None, "version": None, "prompts": 0}
    turns: list[list[str]] = []
    for line in path.open(encoding="utf-8", errors="replace"):
        try:
            r = json.loads(line)
        except json.JSONDecodeError:
            continue
        t = r.get("type")
        if t == "ai-title" and r.get("aiTitle"):
            meta["title"] = r["aiTitle"]
        if t == "custom-title" and r.get("customTitle"):
            meta["title"] = r["customTitle"]
        if t not in ("user", "assistant") or r.get("isSidechain") or r.get("isMeta"):
            continue
        ts = r.get("timestamp")
        if ts:
            meta["first"] = meta["first"] or ts
            meta["last"] = ts
        meta["cwd"] = meta["cwd"] or r.get("cwd")
        meta["branch"] = r.get("gitBranch") or meta["branch"]
        meta["version"] = r.get("version") or meta["version"]
        content = (r.get("message") or {}).get("content")
        if t == "user":
            if isinstance(content, str):
                text = content
            elif isinstance(content, list):
                text = "\n".join(b.get("text", "") for b in content if b.get("type") == "text")
            else:
                continue
            text = _clean_user(text)
            if text:
                role = "SUMMARY-OF-EARLIER-CONTEXT" if r.get("isCompactSummary") else "USER"
                turns.append([role, _clip(text)])
                meta["prompts"] += role == "USER"
        else:
            parts = []
            for b in content if isinstance(content, list) else []:
                if b.get("type") == "text" and b.get("text", "").strip():
                    parts.append(_clip(b["text"].strip()))
                elif b.get("type") == "tool_use":
                    parts.append(_tool_line(b))
            if not parts:
                continue
            if turns and turns[-1][0] == "ASSISTANT":       # a tool loop is many records
                turns[-1][1] += "\n" + "\n".join(parts)
            else:
                turns.append(["ASSISTANT", "\n".join(parts)])
    meta["transcript"] = "\n\n".join(f"{role}: {text}" for role, text in turns)
    return meta


def parse_claude_ai_export(src: pathlib.Path) -> list[dict]:
    """claude.ai 'Export data' -> conversations.json. Tolerant of field-name drift."""
    if src.suffix == ".zip":
        with zipfile.ZipFile(src) as z:
            name = next((n for n in z.namelist() if n.endswith("conversations.json")), None)
            if not name:
                raise SystemExit(f"no conversations.json inside {src}")
            data = json.loads(z.read(name))
    else:
        data = json.loads(src.read_text())
    out = []
    for c in data if isinstance(data, list) else data.get("conversations", []):
        turns, count = [], 0
        for m in c.get("chat_messages") or c.get("messages") or []:
            text = m.get("text") or ""
            if not text and isinstance(m.get("content"), list):
                text = "\n".join(b.get("text", "") for b in m["content"] if b.get("type") == "text")
            for a in m.get("attachments") or []:
                if a.get("extracted_content"):
                    text += f"\n[attachment {a.get('file_name', '')}]: {a['extracted_content'][:1500]}"
            if not text.strip():
                continue
            role = "USER" if (m.get("sender") or m.get("role")) in ("human", "user") else "ASSISTANT"
            turns.append(f"{role}: {_clip(text.strip())}")
            count += role == "USER"
        if not turns:
            continue
        out.append({
            "session_id": c.get("uuid") or hashlib.sha1(json.dumps(c, sort_keys=True).encode()).hexdigest(),
            "title": c.get("name") or None, "first": c.get("created_at"), "last": c.get("updated_at"),
            "cwd": None, "branch": None, "version": None, "prompts": count,
            "transcript": "\n\n".join(turns),
        })
    return out


# --------------------------------------------------------------------------- LLM
SYSTEM = """You write concise knowledge-base notes summarizing a conversation between a user and Claude.
The reader is the same user, months later, searching their notes. Capture what happened and
what was learned, not a play-by-play. Be specific: name the systems, files, commands, errors
and decisions. Never include passwords, API keys, tokens or other secrets, even if present;
refer to them generically ("the API key"). Text marked [REDACTED] must stay redacted.

Reply with ONLY a JSON object, no prose, with these keys:
  "title": short specific title (<= 70 chars)
  "summary": 3-6 sentences
  "outcomes": list of what was actually built, fixed or answered
  "decisions": list of choices made and why
  "facts": list of reusable facts, gotchas, root causes worth remembering
  "open_threads": list of unfinished work or follow-ups
  "tags": 3-8 lowercase kebab-case topic tags
Use empty lists where nothing applies."""

CHUNK_SYSTEM = """You are condensing one part of a long conversation between a user and Claude so that a
later step can summarize the whole. Output dense bullet notes: goals, actions taken, results,
errors and their causes, decisions, open questions. Be specific. Never reproduce secrets."""

CHUNK_CHARS = 300_000


class QuotaExhausted(RuntimeError):
    """The plan/API usage limit is hit. Every further call would fail the same way -- stop the run."""


_QUOTA = re.compile(r"(?i)hit your (?:session |usage |weekly )?limit|usage limit|quota|credit balance|resets? (?:at )?\d")


class LLM:
    """Two backends.

    claude-cli (default): headless Claude Code on the user's own plan -- no API key to manage.
      --no-session-persistence is load-bearing: without it every summary writes a new transcript
      into ~/.claude/projects and the next sync would summarize its own summarizing.
    openai: any OpenAI-compatible /chat/completions endpoint (LiteLLM, local models).
    """

    def __init__(self, cfg: dict):
        self.backend = cfg["LLM_BACKEND"]
        self.model = cfg["LLM_MODEL"]
        self.resolved = cfg["LLM_MODEL"]
        if self.backend == "claude-cli":
            self.bin = shutil.which(os.path.expanduser(cfg["CLAUDE_BIN"]))
            if not self.bin:
                raise SystemExit(f"claude CLI not found ({cfg['CLAUDE_BIN']})")
            self.workdir = pathlib.Path(os.path.expanduser(cfg["STATE_FILE"])).parent  # no project CLAUDE.md here
            self.workdir.mkdir(parents=True, exist_ok=True)
        elif self.backend == "openai":
            if not cfg["LLM_BASE_URL"]:
                raise SystemExit("LLM_BASE_URL is not set (see config.env.example)")
            self.url = cfg["LLM_BASE_URL"].rstrip("/") + "/chat/completions"
            self.key = secret(cfg, "LLM_API_KEY_FILE", "LLM_API_KEY_ENV")
        else:
            raise SystemExit(f"unknown LLM_BACKEND {self.backend!r}")

    def chat(self, system: str, user: str, max_tokens: int = 2500) -> str:
        return self._chat_cli(system, user) if self.backend == "claude-cli" else self._chat_openai(system, user, max_tokens)

    def _chat_cli(self, system: str, user: str) -> str:
        cmd = [self.bin, "-p", "--model", self.model, "--no-session-persistence", "--tools", "",
               "--strict-mcp-config", "--output-format", "json", "--system-prompt", system]
        last = ""
        for attempt in range(5):
            try:
                proc = subprocess.run(cmd, input=user, capture_output=True, text=True, timeout=900, cwd=self.workdir)
            except subprocess.TimeoutExpired:
                last = "timeout"
            else:
                try:
                    d = json.loads(proc.stdout)
                except json.JSONDecodeError:
                    d = {"is_error": True, "result": (proc.stdout or proc.stderr)[-300:]}
                if not d.get("is_error") and d.get("result"):
                    used = list((d.get("modelUsage") or {}).keys())
                    if used:
                        self.resolved = used[0]
                    return d["result"]
                last = str(d.get("result") or proc.stderr)[-300:]
                if _QUOTA.search(last):
                    raise QuotaExhausted(last.strip())
                if not re.search(r"(?i)rate.?limit|overloaded|429|529|timeout|temporar", last):
                    break                                              # not transient -- do not hammer
            time.sleep(2 ** attempt * 10)
        raise RuntimeError(f"claude -p failed: {last}")

    def _chat_openai(self, system: str, user: str, max_tokens: int) -> str:
        body = json.dumps({"model": self.model, "temperature": 0.2, "max_tokens": max_tokens,
                           "messages": [{"role": "system", "content": system},
                                        {"role": "user", "content": user}]}).encode()
        for attempt in range(5):
            req = urllib.request.Request(self.url, data=body, method="POST", headers={
                "Authorization": f"Bearer {self.key}", "Content-Type": "application/json"})
            try:
                with urllib.request.urlopen(req, timeout=600) as r:
                    return json.load(r)["choices"][0]["message"]["content"]
            except urllib.error.HTTPError as e:
                detail = e.read()[:300].decode(errors="replace")
                if _QUOTA.search(detail):
                    raise QuotaExhausted(detail) from None
                if e.code in (429, 500, 502, 503, 504, 529) and attempt < 4:
                    time.sleep(2 ** attempt * 5)
                    continue
                raise RuntimeError(f"LLM HTTP {e.code}: {detail}") from None
            except (urllib.error.URLError, TimeoutError) as e:
                if attempt < 4:
                    time.sleep(2 ** attempt * 5)
                    continue
                raise RuntimeError(f"LLM unreachable: {e}") from None
        raise RuntimeError("LLM retries exhausted")

    def summarize(self, meta: dict) -> dict:
        text = redact(meta["transcript"])
        if len(text) > CHUNK_CHARS:                       # map-reduce for long sessions
            chunks, cur = [], []
            size = 0
            for part in text.split("\n\n"):
                if size + len(part) > CHUNK_CHARS and cur:
                    chunks.append("\n\n".join(cur)); cur, size = [], 0
                cur.append(part); size += len(part) + 2
            chunks.append("\n\n".join(cur))
            notes = [self.chat(CHUNK_SYSTEM, f"Part {i + 1} of {len(chunks)}:\n\n{c}", 3000)
                     for i, c in enumerate(chunks)]
            text = "Condensed notes from each part of a long conversation, in order:\n\n" + \
                   "\n\n---\n\n".join(notes)
        header = f"Working directory: {meta.get('cwd')}\nGit branch: {meta.get('branch')}\n" \
                 f"Existing title: {meta.get('title')}\n\n"
        raw = self.chat(SYSTEM, header + text)
        m = re.search(r"\{.*\}", raw, re.S)
        if not m:
            raise RuntimeError(f"LLM did not return JSON: {raw[:200]!r}")
        out = json.loads(m.group(0))
        for k in ("outcomes", "decisions", "facts", "open_threads", "tags"):
            out[k] = [redact(str(x)) for x in (out.get(k) or []) if str(x).strip()]
        out["summary"] = redact(str(out.get("summary", "")))
        out["title"] = redact(str(out.get("title") or meta.get("title") or meta["session_id"]))
        return out


# --------------------------------------------------------------------------- vault
class Vault:
    def __init__(self, cfg: dict):
        self.base = cfg["OBSIDIAN_API_URL"].rstrip("/")
        self.key = secret(cfg, "OBSIDIAN_API_KEY_FILE")

    def put(self, path: str, content: str) -> None:
        req = urllib.request.Request(f"{self.base}/vault/{urllib.parse.quote(path)}",
                                     data=content.encode(), method="PUT", headers={
                                         "Authorization": f"Bearer {self.key}",
                                         "Content-Type": "text/markdown"})
        with urllib.request.urlopen(req, timeout=60) as r:
            if r.status not in (200, 201, 204):
                raise RuntimeError(f"vault PUT {path}: HTTP {r.status}")

    def ping(self) -> None:
        """Checked BEFORE any summarizing, so an unreachable vault never spends usage limits."""
        req = urllib.request.Request(f"{self.base}/", headers={"Authorization": f"Bearer {self.key}"})
        try:
            with urllib.request.urlopen(req, timeout=15) as r:
                info = json.load(r)
        except (urllib.error.URLError, TimeoutError, OSError) as e:
            raise SystemExit(f"Obsidian REST API unreachable at {self.base}: {getattr(e, 'reason', e)}") from None
        if not info.get("authenticated"):
            raise SystemExit("Obsidian REST API reachable but the API key was rejected")


_BAD = re.compile(r'[\\/:*?"<>|#^\[\]]+')


def note_path(cfg: dict, kind: str, meta: dict, title: str) -> str:
    when = (meta.get("first") or dt.datetime.now(dt.timezone.utc).isoformat())[:10]
    slug = " ".join(_BAD.sub(" ", title).split())[:60].strip() or "untitled"
    return f"{cfg['VAULT_FOLDER']}/{kind}/{when[:4]}/{when} {slug} ({meta['session_id'][:8]}).md"


def render(meta: dict, s: dict, source: str, model: str, src_file: str | None) -> str:
    project = pathlib.Path(meta["cwd"]).name if meta.get("cwd") else None
    tags = ["claude", source] + [t for t in s["tags"] if t not in ("claude", source)]
    fm = {
        "type": "claude-conversation", "source": source, "session_id": meta["session_id"],
        "title": s["title"], "project": project, "cwd": meta.get("cwd"), "branch": meta.get("branch"),
        "started": meta.get("first"), "ended": meta.get("last"), "prompts": meta.get("prompts"),
        "summarized_by": model, "synced": dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
        "tags": tags,
    }
    lines = ["---"] + [f"{k}: {json.dumps(v)}" for k, v in fm.items() if v not in (None, "")] + ["---", "",
             f"# {s['title']}", "", "> [!summary]"] + [f"> {l}" for l in s["summary"].splitlines() or [""]]
    for heading, key in (("Outcomes", "outcomes"), ("Decisions", "decisions"),
                         ("Facts & gotchas", "facts"), ("Open threads", "open_threads")):
        if s[key]:
            lines += ["", f"## {heading}", ""] + [f"- {x}" for x in s[key]]
    lines += ["", "---"]
    if source == "claude-code":
        lines.append(f"Resume: `claude --resume {meta['session_id']}`" + (f" · transcript `{src_file}`" if src_file else ""))
    else:
        lines.append(f"claude.ai conversation `{meta['session_id']}`")
    return "\n".join(lines) + "\n"


# --------------------------------------------------------------------------- state
class State:
    def __init__(self, path: str):
        self.path = pathlib.Path(os.path.expanduser(path))
        self.lock = threading.Lock()
        self.data = json.loads(self.path.read_text()) if self.path.exists() else {}

    def save(self) -> None:
        self.path.parent.mkdir(parents=True, exist_ok=True)
        tmp = self.path.with_suffix(".tmp")
        tmp.write_text(json.dumps(self.data, indent=1, sort_keys=True))
        tmp.replace(self.path)

    def record(self, key: str, value: dict) -> None:
        with self.lock:
            self.data[key] = value
            self.save()


def write_index(cfg: dict, vault: Vault, state: State) -> None:
    rows = sorted((v for v in state.data.values() if v.get("note")), key=lambda v: v.get("started") or "", reverse=True)
    out = ["---", 'type: "claude-index"', "---", "", "# Claude conversations", "",
           f"{len(rows)} conversations. Regenerated by claude-obsidian-sync.", "",
           "| Date | Title | Project | Source |", "|---|---|---|---|"]
    for v in rows:
        link = v["note"][:-3]
        title = v.get("title", "").replace("|", "-")
        out.append(f"| {(v.get('started') or '')[:10]} | [[{link}\\|{title}]] | {v.get('project') or ''} | {v.get('source')} |")
    vault.put(f"{cfg['VAULT_FOLDER']}/Index.md", "\n".join(out) + "\n")


# --------------------------------------------------------------------------- commands
def top_level_sessions(cfg: dict) -> list[pathlib.Path]:
    root = pathlib.Path(os.path.expanduser(cfg["CLAUDE_PROJECTS_DIR"]))
    return sorted(root.glob("*/*.jsonl"), key=lambda p: p.stat().st_mtime)   # nested = subagents


def process(cfg, llm, vault, state, key, meta, source, kind, fingerprint, src_file, dry_run) -> str:
    s = llm.summarize(meta)
    prev = state.data.get(key, {})
    path = prev.get("note") or note_path(cfg, kind, meta, s["title"])        # stable across re-syncs
    body = render(meta, s, source, llm.resolved, src_file)
    if dry_run:
        print(f"\n===== {path} =====\n{body}")
        return path
    vault.put(path, body)
    state.record(key, {"note": path, "title": s["title"], "fingerprint": fingerprint, "source": source,
                       "started": meta.get("first"),
                       "project": pathlib.Path(meta["cwd"]).name if meta.get("cwd") else None})
    return path


SKIPPED = "(empty, skipped)"
EX_TEMPFAIL = 75                    # "try again later" -- systemd logs it, the next timer run retries


def run_jobs(cfg, work, fn) -> tuple[int, int, str | None]:
    ok = failed = 0
    quota = None
    stop = threading.Event()        # Future.cancel() cannot stop a job a worker already picked up

    def guarded(*w):
        if stop.is_set():
            raise cf.CancelledError()
        try:
            return fn(*w)
        except QuotaExhausted:
            stop.set()
            raise

    with cf.ThreadPoolExecutor(max_workers=int(cfg["JOBS"])) as ex:
        futs = {ex.submit(guarded, *w): w for w in work}
        for fut in cf.as_completed(futs):
            label = futs[fut][0]
            try:
                result = fut.result()
                if result == SKIPPED:
                    print(f"skip {label} (no conversation content)", flush=True)
                else:
                    print(f"ok   {label} -> {result}", flush=True); ok += 1
            except QuotaExhausted as e:
                if quota is None:
                    quota = str(e)
                    for f in futs:                                   # don't launch the rest
                        f.cancel()
            except cf.CancelledError:
                pass
            except Exception as e:                                   # keep going; next run retries
                print(f"FAIL {label}: {e}", file=sys.stderr, flush=True); failed += 1
    return ok, failed, quota


def finish(ok: int, failed: int, quota: str | None, dry_run: bool, remaining: int) -> None:
    verb = "rendered (dry run, nothing written)" if dry_run else "written"
    print(f"done: {ok} {verb}, {failed} failed, {remaining} still queued")
    if quota:
        print(f"stopped: usage limit reached -- {quota}", file=sys.stderr)
        sys.exit(EX_TEMPFAIL)
    sys.exit(1 if failed else 0)


def cmd_stats(cfg, args) -> None:
    total = 0
    rows = []
    for p in top_level_sessions(cfg):
        m = parse_claude_code(p)
        n = len(redact(m["transcript"]))
        total += n
        rows.append((n, p.stat().st_size, m["prompts"], m["title"] or p.stem))
    rows.sort(reverse=True)
    for n, raw, msgs, title in rows[: args.top]:
        print(f"{raw / 1e6:7.2f} MB raw -> {n / 1e3:7.1f} KB condensed  {msgs:4d} prompts  {title[:60]}")
    print(f"\n{len(rows)} sessions, {total / 1e6:.1f} MB condensed (~{total / 4 / 1e6:.2f}M tokens in), "
          f"{sum(1 for r in rows if r[0] > CHUNK_CHARS)} need map-reduce")


def cmd_sync(cfg, args) -> None:
    state = State(cfg["STATE_FILE"])
    settle = dt.timedelta(minutes=int(cfg["SETTLE_MINUTES"]))
    now = time.time()
    work = []
    for p in top_level_sessions(cfg):
        if args.session and p.stem != args.session:
            continue
        st = p.stat()
        fp = f"{st.st_size}:{int(st.st_mtime)}"
        key = f"claude-code:{p.stem}"
        if not args.force and state.data.get(key, {}).get("fingerprint") == fp:
            continue
        if not (args.force or args.session) and now - st.st_mtime < settle.total_seconds():
            continue                                                    # still being written
        work.append((p, fp, key))
    total = len(work)
    cap = args.limit or int(cfg["MAX_PER_RUN"])
    work = work[-cap:] if not args.oldest else work[:cap]            # newest first by default
    print(f"{total} session(s) pending, summarizing {len(work)} this run", flush=True)
    if not work:
        return
    llm, vault = LLM(cfg), (None if args.dry_run else Vault(cfg))
    if vault:
        vault.ping()

    def one(p, fp, key):
        meta = parse_claude_code(p)
        if not meta["transcript"].strip():
            if not args.dry_run:
                state.record(key, {"note": None, "fingerprint": fp, "source": "claude-code"})
            return SKIPPED
        src = str(p).replace(str(HOME), "~")
        return process(cfg, llm, vault, state, key, meta, "claude-code", "Code", fp, src, args.dry_run)

    ok, failed, quota = run_jobs(cfg, [(w[0].name, *w) for w in work], lambda _l, p, fp, key: one(p, fp, key))
    if vault and ok:
        write_index(cfg, vault, state)
    finish(ok, failed, quota, args.dry_run, total - ok)


def cmd_import(cfg, args) -> None:
    state = State(cfg["STATE_FILE"])
    convs = parse_claude_ai_export(pathlib.Path(args.export))
    work = []
    for m in convs:
        key = f"claude-ai:{m['session_id']}"
        fp = m.get("last") or str(len(m["transcript"]))
        if args.force or state.data.get(key, {}).get("fingerprint") != fp:
            work.append((m, fp, key))
    total = len(work)
    work = work[: args.limit or int(cfg["MAX_PER_RUN"])]
    print(f"{len(convs)} conversations in export, {total} new or changed, summarizing {len(work)} this run", flush=True)
    if not work:
        return
    llm, vault = LLM(cfg), (None if args.dry_run else Vault(cfg))
    if vault:
        vault.ping()
    ok, failed, quota = run_jobs(cfg, [((m.get("title") or m["session_id"])[:50], m, fp, key) for m, fp, key in work],
                          lambda _l, m, fp, key: process(cfg, llm, vault, state, key, m, "claude-ai", "Web",
                                                         fp, None, args.dry_run))
    if vault and ok:
        write_index(cfg, vault, state)
    finish(ok, failed, quota, args.dry_run, total - ok)


def main() -> None:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    sub = ap.add_subparsers(dest="cmd", required=True)
    s = sub.add_parser("stats", help="condensed sizes, no network")
    s.add_argument("--top", type=int, default=15)
    s = sub.add_parser("sync", help="summarize Claude Code sessions")
    s.add_argument("--dry-run", action="store_true", help="print notes instead of writing them")
    s.add_argument("--force", action="store_true", help="ignore state and the settle window")
    s.add_argument("--session", help="only this session id")
    s.add_argument("--limit", type=int, help="max sessions this run (default MAX_PER_RUN)")
    s.add_argument("--oldest", action="store_true", help="take the oldest pending sessions instead of the newest")
    s = sub.add_parser("import-claude-ai", help="import a claude.ai data export")
    s.add_argument("export", help="the export .zip or its conversations.json")
    s.add_argument("--dry-run", action="store_true")
    s.add_argument("--force", action="store_true")
    s.add_argument("--limit", type=int, help="max conversations this run (default MAX_PER_RUN)")
    args = ap.parse_args()
    cfg = load_config()
    {"stats": cmd_stats, "sync": cmd_sync, "import-claude-ai": cmd_import}[args.cmd](cfg, args)


if __name__ == "__main__":
    main()
