"""linear-canal db — Full-screen TUI database & context browser.

  linear-canal db             Auto-detect: open local project or global picker
  linear-canal db --global    Browse ALL indexed projects

Navigation
──────────
  ← →  or  1-7   Switch section        r   Refresh
  ↑ ↓          Scroll / select         q   Quit / back
  n / p        Next / prev page (Files section)

DB management keys
──────────────────
  Vectors section:  D   Delete this project's vectors from LanceDB
  Files section:    C   Clear hash cache for this project
"""

from __future__ import annotations

import json
import queue
import shutil
import sqlite3
import sys
import threading
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional

import click
from rich import box
from rich.console import Console
from rich.layout import Layout
from rich.live import Live
from rich.panel import Panel
from rich.table import Table
from rich.text import Text

console = Console()

_LC_HOME_DATA = Path.home() / ".linear-canal" / "data"
_LC_LOCAL_DIR = ".linear_canal_data"

# Section indices
S_OVERVIEW, S_GRAPH, S_FILES, S_VECTORS, S_CTX, S_BRANCHES, S_LIVE = range(7)
_SECTION_NAMES = ["Overview", "Graph", "Files", "Vectors", "CTX", "Branches", "Live"]

# Brand palette (Vercel dark): primary=orange1 (amber), accent=grey74, muted=grey58, destructive=red1
TIER_COLOUR = {"CRITICAL": "bold red1", "HIGH": "orange1", "MEDIUM": "grey74", "LOW": "grey58", "SKIP": "grey42 italic"}
TIER_SYMBOL = {"CRITICAL": "●", "HIGH": "●", "MEDIUM": "●", "LOW": "○", "SKIP": "·"}

# Layout fixed sizes
_HEADER_ROWS = 6   # header panel height
_FOOTER_ROWS = 3   # footer panel height


# ── State ─────────────────────────────────────────────────────────────────────

@dataclass
class UIState:
    project: dict = field(default_factory=dict)
    subtitle: str = ""
    section: int = S_OVERVIEW
    scroll: int = 0          # generic vertical scroll offset
    graph_sub: int = 0       # 0=modules 1=risks 2=coupling
    files_page: int = 0
    ctx_files: list = field(default_factory=list)
    ctx_open: Optional[Path] = None
    ctx_scroll: int = 0
    branch_list: list = field(default_factory=list)
    live_log: list = field(default_factory=list)
    live_prev: dict = field(default_factory=dict)
    message: str = ""
    pending: str = ""        # branch action to run after Live exits
    running: bool = True

    def body_rows(self) -> int:
        """Available content rows inside the body panel."""
        return max(8, console.size.height - _HEADER_ROWS - _FOOTER_ROWS - 4)

    def table_rows(self) -> int:
        """Max data rows for a table inside body."""
        return max(4, self.body_rows() - 6)


# ── Keyboard ──────────────────────────────────────────────────────────────────

def _get_char() -> str:
    if sys.platform == "win32":
        import msvcrt
        ch = msvcrt.getwch()
        if ch in ("\x00", "\xe0"):
            nxt = msvcrt.getwch()
            return {"K": "LEFT", "M": "RIGHT", "H": "UP", "P": "DOWN"}.get(nxt, "")
        return ch
    else:
        import tty, termios
        fd = sys.stdin.fileno()
        old = termios.tcgetattr(fd)
        try:
            tty.setraw(fd)
            ch = sys.stdin.read(1)
            if ch == "\x1b":
                try:
                    n1, n2 = sys.stdin.read(1), sys.stdin.read(1)
                    if n1 == "[":
                        return {"A": "UP", "B": "DOWN", "C": "RIGHT", "D": "LEFT"}.get(n2, "")
                except Exception:
                    pass
                return "ESC"
            return ch
        finally:
            termios.tcsetattr(fd, termios.TCSADRAIN, old)


def _key_reader(q: "queue.Queue[str]", stop: threading.Event) -> None:
    while not stop.is_set():
        try:
            ch = _get_char()
            if ch:
                q.put(ch)
        except Exception:
            time.sleep(0.05)


# ── Helpers ───────────────────────────────────────────────────────────────────

def _read_json(path: Path) -> Optional[dict]:
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except Exception:
        return None


def _fmt_dt(iso: Optional[str]) -> str:
    if not iso:
        return "—"
    try:
        return datetime.fromisoformat(iso).strftime("%Y-%m-%d %H:%M")
    except Exception:
        return iso[:16]


def _fmt_dur(secs: Optional[float]) -> str:
    if secs is None:
        return "—"
    s = int(secs)
    return f"{s}s" if s < 60 else f"{s // 60}m {s % 60}s"


def _status_colour(status: str) -> str:
    s = status.lower()
    if s in {"completed", "complete", "done"}:  return "white"
    if s in {"running", "indexing", "scanning"}: return "grey74"
    if s in {"error", "failed"}:                 return "deep_pink1"
    return "grey74"


def _pbar(pct: float, width: int = 28, colour: str = "white") -> str:
    filled = int(width * pct / 100)
    return f"[{colour}]{'█' * filled}[/{colour}][grey42]{'░' * (width - filled)}[/grey42]"


def _open_db(path: Path) -> Optional[sqlite3.Connection]:
    if not path.exists():
        return None
    try:
        conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
        conn.row_factory = sqlite3.Row
        return conn
    except Exception:
        return None


def _db_count(conn: sqlite3.Connection, table: str,
              where: str = "", params: tuple = ()) -> int:
    sql = f"SELECT COUNT(*) FROM {table}"
    if where:
        sql += f" WHERE {where}"
    try:
        return conn.execute(sql, params).fetchone()[0]
    except Exception:
        return 0


# ── Layout builders ───────────────────────────────────────────────────────────

def _make_header(state: UIState) -> Panel:
    tabs: list[str] = []
    for i, name in enumerate(_SECTION_NAMES):
        if i == state.section:
            tabs.append(f"[bold black on orange1] {i+1}·{name} [/bold black on orange1]")
        else:
            tabs.append(f"[grey58] {i+1} {name} [/grey58]")
    tab_row = " ".join(tabs)
    title = (
        f"[bold orange1]Linear Canal[/bold orange1]"
        f"  [grey58]·[/grey58]  [bold white]{state.project.get('project_id', '')}[/bold white]"
        f"  [grey58]{state.subtitle}[/grey58]"
    )
    msg = f"\n[bold orange1]{state.message}[/bold orange1]" if state.message else ""
    return Panel(f"{title}\n{tab_row}{msg}", border_style="orange1", padding=(0, 1))


def _make_footer(state: UIState) -> Panel:
    section = state.section
    base = "[grey58]← →[/grey58] section   [grey58]r[/grey58] refresh   [grey58]q[/grey58] quit"
    extras = {
        S_GRAPH:    "  [grey58]↑ ↓[/grey58] sub-panel   [grey58]n[/grey58] next sub",
        S_FILES:    "  [grey58]↑ ↓[/grey58] scroll   [grey58]n p[/grey58] page   [grey74]C[/grey74] clear hash cache",
        S_VECTORS:  "  [grey58]↑ ↓[/grey58] scroll   [deep_pink1]D[/deep_pink1] delete project vectors",
        S_CTX:      "  [grey58]↑ ↓[/grey58] scroll   [grey58]b[/grey58] back" if state.ctx_open else "  [grey58]↑ ↓[/grey58] scroll   [grey58]Enter[/grey58] open",
        S_BRANCHES: "  [grey74]c[/grey74] create   [grey74]x[/grey74] checkout   [deep_pink1]d[/deep_pink1] delete   [grey74]m[/grey74] merge   [grey74]r[/grey74] reindex   [grey58]i[/grey58] info   [grey58]f[/grey58] diff   [grey58]l[/grey58] log   [grey58]↑ ↓[/grey58] select",
        S_LIVE:     "  [grey58]auto-refresh every 2s[/grey58]",
    }
    hint = base + extras.get(section, "")
    return Panel(hint, border_style="grey42", padding=(0, 1))


def _make_layout(state: UIState, body: Any) -> Layout:
    layout = Layout()
    layout.split_column(
        Layout(name="header", size=_HEADER_ROWS),
        Layout(name="body"),
        Layout(name="footer", size=_FOOTER_ROWS),
    )
    layout["header"].update(_make_header(state))
    layout["body"].update(body)
    layout["footer"].update(_make_footer(state))
    return layout


# ── Section 1: Overview ───────────────────────────────────────────────────────

def _body_overview(state: UIState) -> Panel:
    p      = state.project
    idx    = p.get("index_status", {})
    sct    = p.get("scout_status", {})
    ts     = p.get("tier_summary", {})
    pid    = p.get("project_id", "")
    status = idx.get("status", "unknown")
    colour = _status_colour(status)
    pct    = float(idx.get("progress_percent", 0) or 0)

    # ── Live stats: read from hash store + LanceDB (correct cumulative counts) ──
    # index_status.json only tracks per-run stats: on re-runs where all files are
    # cached (unchanged), files_indexed=0 and chunks_created=0 even though the
    # vectors are still in LanceDB.  We query the real sources instead.
    live_files: Optional[int] = None
    live_chunks: Optional[int] = None

    try:
        from indexer.hash_store import HashStore  # type: ignore[import-not-found]
        data_dir = p.get("data_dir")
        if data_dir is not None:
            hash_db = Path(data_dir).parent / "index_hashes.db"
            if hash_db.exists():
                stats = HashStore(hash_db).get_index_stats(pid)
                live_files  = stats.get("files_indexed", 0)
                live_chunks = stats.get("total_chunks", 0)
    except Exception:
        pass

    if live_chunks is None:
        # Fallback: query LanceDB directly (same logic as Vectors section)
        try:
            import lancedb  # type: ignore[import-not-found]
            db = lancedb.connect(_LANCEDB_PATH)
            live_chunks = sum(
                db.open_table(t).count_rows(f"project_id = '{pid}'")
                for t in db.table_names()
            )
        except Exception:
            pass

    # Prefer live data; fall back to index_status values
    files_indexed_disp = live_files  if live_files  is not None else idx.get("files_indexed", "—")
    chunks_disp        = live_chunks if live_chunks is not None else idx.get("chunks_created",  "—")

    # Recalculate progress if live_files is available and status shows completed
    if live_files is not None and status == "completed":
        files_total = idx.get("files_total") or sct.get("files_scanned") or 0
        if files_total:
            pct = min(100.0, live_files / files_total * 100)

    rows: list[str] = [
        f"  Status    [bold {colour}]{status.upper()}[/bold {colour}]",
        f"  Progress  {_pbar(pct, 30, colour)}  [bold]{pct:.1f}%[/bold]",
        "",
        f"  Files     [white]{files_indexed_disp}[/white] indexed"
        f"  [dim]/ {idx.get('files_total','—')} total  / {idx.get('files_skipped',0)} skipped[/dim]",
        f"  Vectors   [bold white]{chunks_disp}[/bold white] chunks",
        "",
        f"  Started   [dim]{_fmt_dt(idx.get('started_at'))}[/dim]",
        f"  Finished  [dim]{_fmt_dt(idx.get('completed_at'))}[/dim]",
        f"  Duration  [dim]{_fmt_dur(idx.get('duration_seconds'))}[/dim]",
    ]
    if idx.get("error"):
        rows.append(f"\n  [bold red]Error[/bold red]  {idx['error']}")

    # Tier bar (compact)
    rows += ["", "  Tier distribution:"]
    total = sum(ts.values()) or 1
    for tier in ("CRITICAL", "HIGH", "MEDIUM", "LOW"):
        cnt   = ts.get(tier, 0)
        pct_t = cnt / total * 100
        col   = TIER_COLOUR[tier]
        sym   = TIER_SYMBOL[tier]
        rows.append(f"  [{col}]{sym} {tier:<9}[/{col}]  {cnt:>5}  {_pbar(pct_t, 20, col.split()[-1])}  {pct_t:.1f}%")

    # Scout summary
    if sct:
        rows += [
            "",
            f"  Scout  phase=[dim]{sct.get('phase','—')}[/dim]"
            f"  scanned=[white]{sct.get('files_scanned','—')}[/white]"
            f"  scored=[white]{sct.get('files_scored','—')}[/white]"
            f"  updated=[dim]{_fmt_dt(sct.get('updated_at'))}[/dim]",
        ]

    # CTX ring
    ctx_files = p.get("ctx_files", [])
    if ctx_files:
        sys_c = " ".join(f.stem for f in ctx_files if f.stem.isupper())
        mod_c = " ".join(f.stem for f in ctx_files if not f.stem.isupper())
        rows += ["", f"  CTX   [orange1]{sys_c}[/orange1]"]
        if mod_c:
            rows.append(f"        [grey74]{mod_c}[/grey74]")

    return Panel("\n".join(rows), title="[bold orange1]Overview[/bold orange1]",
                 border_style=colour, padding=(0, 2))


# ── Section 2: Graph ──────────────────────────────────────────────────────────

def _body_graph_v2(state: UIState) -> Panel:
    """Cleaner graph body that works within layout bounds."""
    db_path = state.project["data_dir"] / "graph.db"
    conn    = _open_db(db_path)
    if conn is None:
        return Panel("[yellow]graph.db not found. Run the pipeline first.[/yellow]",
                     border_style="yellow", padding=(0, 2))

    max_r = state.table_rows()
    off   = max(0, state.scroll)

    with conn:
        counts = {t: _db_count(conn, t)
                  for t in ("nodes", "edges", "modules", "risks", "coupling_pairs")}

        sub_names = ["Modules", "Risks", "Coupling"]
        sub_idx   = state.graph_sub % len(sub_names)

        # Sub-panel tabs
        sub_tabs = "  ".join(
            f"[bold black on orange1] {n} ({counts[k]}) [/bold black on orange1]"
            if i == sub_idx
            else f"[grey58] {n} ({counts[k]}) [/grey58]"
            for i, (n, k) in enumerate(zip(sub_names,
                                           ["modules", "risks", "coupling_pairs"]))
        )

        lines: list[str] = [f"  {sub_tabs}", ""]

        if sub_idx == 0:
            all_rows = conn.execute(
                "SELECT name, total_files, critical_files, risk_count, avg_score, primary_language "
                "FROM modules ORDER BY avg_score DESC"
            ).fetchall() if counts["modules"] else []
            off   = min(off, max(0, len(all_rows) - max_r))
            slice_ = all_rows[off: off + max_r]
            lines.append(f"  [dim]{'Module':<24}{'Files':>6}{'Crit':>6}{'Risks':>6}{'Score':>8}  Lang[/dim]")
            lines.append("  " + "─" * 60)
            for r in slice_:
                sc  = r["avg_score"] or 0.0
                col = "bold deep_pink1" if sc >= 70 else "orange1" if sc >= 40 else "white"
                crit = f"[bold deep_pink1]{r['critical_files']}[/bold deep_pink1]" if r["critical_files"] else "[grey58]0[/grey58]"
                lines.append(
                    f"  [white]{(r['name'] or '(root)'):<24}[/white]"
                    f"  {r['total_files'] or 0:>4}"
                    f"  {crit:>4}"
                    f"  {r['risk_count'] or 0:>4}"
                    f"  [{col}]{sc:>6.1f}[/{col}]"
                    f"  [dim]{r['primary_language'] or '—'}[/dim]"
                )
            if len(all_rows) > max_r:
                lines.append(f"\n  [dim]rows {off+1}–{min(off+max_r, len(all_rows))} of {len(all_rows)}  ↑↓ to scroll[/dim]")

        elif sub_idx == 1:
            all_rows = conn.execute(
                "SELECT path, risk_type, description, confidence FROM risks ORDER BY confidence DESC"
            ).fetchall() if counts["risks"] else []
            off    = min(off, max(0, len(all_rows) - max_r))
            slice_ = all_rows[off: off + max_r]
            lines.append(f"  [dim]{'Conf':>5}  {'Type':<22}  Path / Description[/dim]")
            lines.append("  " + "─" * 70)
            for r in slice_:
                c   = r["confidence"] or 0.0
                col = "bold deep_pink1" if c >= 0.8 else "orange1" if c >= 0.5 else "grey58"
                desc = (r["description"] or "")[:50]
                lines.append(
                    f"  [{col}]{c:.2f}[/{col}]"
                    f"  [dim]{(r['risk_type'] or '—'):<22}[/dim]"
                    f"  [dim white]{(r['path'] or '—')[:30]}[/dim white]"
                    f"  {desc}"
                )
            if len(all_rows) > max_r:
                lines.append(f"\n  [dim]rows {off+1}–{min(off+max_r, len(all_rows))} of {len(all_rows)}  ↑↓ to scroll[/dim]")

        elif sub_idx == 2:
            all_rows = conn.execute(
                "SELECT file_a, file_b, co_commit_count FROM coupling_pairs ORDER BY co_commit_count DESC"
            ).fetchall() if counts["coupling_pairs"] else []
            off    = min(off, max(0, len(all_rows) - max_r))
            slice_ = all_rows[off: off + max_r]
            lines.append(f"  [dim]{'Commits':>8}  File A  /  File B[/dim]")
            lines.append("  " + "─" * 70)
            for r in slice_:
                cnt = r["co_commit_count"]
                col = "bold grey74" if cnt >= 5 else "grey74"
                lines.append(
                    f"  [{col}]{cnt:>8}[/{col}]  "
                    f"[grey58]{(r['file_a'] or '—')[:36]}[/grey58]  [grey42]↔[/grey42]  "
                    f"[grey58]{(r['file_b'] or '—')[:36]}[/grey58]"
                )
            if len(all_rows) > max_r:
                lines.append(f"\n  [dim]rows {off+1}–{min(off+max_r, len(all_rows))} of {len(all_rows)}  ↑↓ to scroll[/dim]")

    return Panel(
        "\n".join(lines),
        title="[bold orange1]Graph[/bold orange1]",
        border_style="orange1", padding=(0, 1),
    )


# ── Section 3: Files ──────────────────────────────────────────────────────────

def _body_files(state: UIState) -> Panel:
    db_path  = state.project["data_root"] / "index_hashes.db"
    pid      = state.project["project_id"]
    per_page = state.table_rows()
    conn     = _open_db(db_path)

    if conn is None:
        return Panel("[yellow]index_hashes.db not found.[/yellow]", border_style="yellow")

    with conn:
        total  = _db_count(conn, "file_hashes", "project_id = ?", (pid,))
        chunks = conn.execute(
            "SELECT SUM(chunk_count) FROM file_hashes WHERE project_id = ?", (pid,)
        ).fetchone()[0] or 0
        page   = max(0, min(state.files_page, max(0, (total - 1) // per_page)))
        state.files_page = page
        offset = page * per_page
        rows   = conn.execute(
            "SELECT path, sha256, indexed_at, chunk_count FROM file_hashes "
            "WHERE project_id = ? ORDER BY chunk_count DESC LIMIT ? OFFSET ?",
            (pid, per_page, offset),
        ).fetchall()

    total_pages = max(1, (total + per_page - 1) // per_page)

    lines = [
        f"  [grey58]Files tracked[/grey58]  [bold white]{total:,}[/bold white]"
        f"   [grey58]Total chunks[/grey58]  [bold orange1]{chunks:,}[/bold orange1]",
        "",
        f"  [grey58]{'Chunks':>7}  {'Last indexed':<18}  {'SHA256':<12}  Path[/grey58]",
        "  " + "─" * 72,
    ]
    for r in rows:
        cn  = r["chunk_count"] or 0
        col = "bold orange1" if cn >= 10 else "white" if cn else "grey58"
        lines.append(
            f"  [{col}]{cn:>7}[/{col}]"
            f"  [grey58]{_fmt_dt(r['indexed_at']):<18}[/grey58]"
            f"  [grey58]{(r['sha256'] or '')[:12]:<12}[/grey58]"
            f"  {r['path'] or '?'}"
        )
    lines.append(
        f"\n  [grey58]page {page+1}/{total_pages}  ·  n=next  p=prev  ·  {per_page} rows/page[/grey58]"
    )

    return Panel("\n".join(lines), title="[bold orange1]Files[/bold orange1]",
                 border_style="orange1", padding=(0, 1))


# ── Section 4: Vectors ────────────────────────────────────────────────────────

_DIM_TO_MODEL: dict[int, tuple[str, str]] = {
    768: ("nomic-ai/nomic-embed-text-v1.5", "standard"),
    384: ("BAAI/bge-small-en-v1.5",         "minimal"),
}
_LANCEDB_PATH = str(Path.home() / ".linear-canal" / "data" / "lancedb_global_store")


def _body_vectors(state: UIState) -> Panel:
    pid = state.project["project_id"]
    try:
        import lancedb
        db = lancedb.connect(_LANCEDB_PATH)
    except ImportError:
        return Panel(
            "[red]lancedb not installed.[/red]\n\n"
            "  Run:  [bold grey74]pip install lancedb[/bold grey74]",
            border_style="deep_pink1", padding=(0, 2),
        )
    except Exception as exc:
        return Panel(f"[red]Failed to open LanceDB store:[/red]\n  {exc}", border_style="deep_pink1")

    table_names = db.table_names()
    if not table_names:
        return Panel(
            "[orange1]No LanceDB tables found — project not indexed yet.[/orange1]\n\n"
            "  Run:  [bold grey74]linear-canal setup[/bold grey74]\n\n"
            f"  [grey58]Store: {_LANCEDB_PATH}[/grey58]",
            border_style="orange1", padding=(0, 2),
        )

    lines = [
        f"  [grey58]Store[/grey58]  [white]{_LANCEDB_PATH}[/white]",
        "",
        f"  [grey58]{'Table':<26}  {'Model':<38}  {'Profile':<10}  {'This project':>13}  {'All projects':>13}[/grey58]",
        "  " + "─" * 106,
    ]

    total_project_rows = 0
    for tname in sorted(table_names):
        try:
            tbl          = db.open_table(tname)
            total_rows   = tbl.count_rows()
            project_rows = tbl.count_rows(f"project_id = '{pid}'")
            total_project_rows += project_rows
            try:
                dim = int(tname.rsplit("_", 1)[-1])
            except (ValueError, IndexError):
                dim = 0
            model_name, profile = _DIM_TO_MODEL.get(dim, ("unknown", "unknown"))
            row_col = "bold orange1" if project_rows > 0 else "grey58"
            marker  = "[bold white]●[/bold white]" if project_rows > 0 else "[grey58]○[/grey58]"
        except Exception:
            total_rows, project_rows, model_name, profile, row_col, marker = 0, 0, "?", "?", "dim", "[dim]○[/dim]"

        lines.append(
            f"  {marker} [bold white]{tname:<26}[/bold white]"
            f"  [dim]{model_name:<38}[/dim]"
            f"  [dim]{profile:<10}[/dim]"
            f"  [{row_col}]{project_rows:>13,}[/{row_col}]"
            f"  [dim]{total_rows:>13,}[/dim]"
        )

    lines += [
        "",
        f"  [grey58]Project chunks:[/grey58]  [bold orange1]{total_project_rows:,}[/bold orange1]"
        f"  [grey58]indexed for[/grey58]  [bold white]{pid}[/bold white]",
    ]
    if total_project_rows > 0:
        lines.append(
            "\n  [grey58]Press [deep_pink1]D[/deep_pink1] to delete all vectors for "
            f"[bold white]{pid}[/bold white] from LanceDB  [orange1](requires re-index)[/orange1][/grey58]"
        )
    else:
        lines.append("\n  [grey58]No vectors found for this project. Run [orange1]linear-canal setup[/orange1] to index.[/grey58]")

    return Panel("\n".join(lines), title="[bold orange1]Vectors  ·  LanceDB[/bold orange1]",
                 border_style="orange1", padding=(0, 1))


# ── Section 5: CTX ────────────────────────────────────────────────────────────

def _body_ctx_list(state: UIState) -> Panel:
    ctx_files = state.project.get("ctx_files", [])
    if not ctx_files:
        return Panel("[yellow]No .ctx files found. Run the pipeline first.[/yellow]",
                     border_style="yellow")

    sys_f   = [f for f in ctx_files if f.stem.isupper()]
    mod_f   = [f for f in ctx_files if not f.stem.isupper()]
    ordered = sys_f + mod_f
    state.ctx_files = ordered

    max_r = state.table_rows()
    off   = max(0, min(state.scroll, max(0, len(ordered) - max_r)))
    slice_ = ordered[off: off + max_r]

    lines = [
        f"  [dim]{'#':>3}  {'File':<28}  {'Size':>8}  {'Lines':>6}  Modified[/dim]",
        "  " + "─" * 64,
    ]
    for local_i, f in enumerate(slice_):
        idx_g = off + local_i + 1
        try:
            stat  = f.stat()
            sz    = f"{stat.st_size / 1024:.1f} KB"
            mtime = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M")
            lines_n = len(f.read_text(encoding="utf-8", errors="replace").splitlines())
        except Exception:
            sz, mtime, lines_n = "?", "?", "?"
        nc = (f"[bold orange1]{f.stem}.ctx[/bold orange1]"
              if f.stem.isupper() else f"[grey74]{f.stem}.ctx[/grey74]")
        lines.append(f"  [dim]{idx_g:>3}[/dim]  {nc:<28}  {sz:>8}  {str(lines_n):>6}  [dim]{mtime}[/dim]")

    if len(ordered) > max_r:
        lines.append(f"\n  [dim]rows {off+1}–{min(off+max_r, len(ordered))} of {len(ordered)}  ↑↓ scroll  Enter/number to open[/dim]")
    else:
        lines.append("\n  [dim]↑↓ to select  Enter or number to open[/dim]")

    return Panel("\n".join(lines), title="[bold orange1]CTX Files[/bold orange1]",
                 border_style="orange1", padding=(0, 1))


def _body_ctx_file(state: UIState) -> Panel:
    path = state.ctx_open
    if path is None:
        return Panel("[dim]No file selected.[/dim]", border_style="dim")
    try:
        all_lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
    except Exception as exc:
        return Panel(f"[red]Could not read file: {exc}[/red]", border_style="deep_pink1")

    max_r  = state.body_rows() - 4
    off    = max(0, min(state.ctx_scroll, max(0, len(all_lines) - max_r)))
    state.ctx_scroll = off
    slice_ = all_lines[off: off + max_r]

    content = "\n".join(slice_)
    border  = "orange1" if path.stem.isupper() else "grey74"
    scroll_info = f"  [dim]line {off+1}–{min(off+max_r, len(all_lines))} / {len(all_lines)}  ↑↓ scroll  b=back[/dim]"
    return Panel(
        content + "\n" + scroll_info,
        title=f"[bold orange1]{path.stem}.ctx[/bold orange1]",
        border_style=border, padding=(0, 1),
    )


# ── Section 6: Branches ───────────────────────────────────────────────────────

def _load_branches(data_dir: Path) -> tuple[str, dict]:
    manifest = data_dir / "branches" / "branches.json"
    if not manifest.exists():
        return "full", {}
    try:
        d = json.loads(manifest.read_text(encoding="utf-8"))
        return d.get("active", "full"), d.get("branches", {})
    except Exception:
        return "full", {}


def _load_manifest_full(data_dir: Path) -> dict:
    """Load the full branches manifest (preserves history and extra keys)."""
    manifest = data_dir / "branches" / "branches.json"
    if not manifest.exists():
        return {"active": "full", "branches": {}, "history": []}
    try:
        d = json.loads(manifest.read_text(encoding="utf-8"))
        d.setdefault("history", [])
        return d
    except Exception:
        return {"active": "full", "branches": {}, "history": []}


def _save_branches(data_dir: Path, active: str, branches: dict) -> None:
    bdir = data_dir / "branches"
    bdir.mkdir(parents=True, exist_ok=True)
    # Preserve existing keys (e.g. history) from the current manifest
    existing = _load_manifest_full(data_dir)
    existing["active"]   = active
    existing["branches"] = branches
    tmp = bdir / "branches.json.tmp"
    try:
        tmp.write_text(json.dumps(existing, indent=2), encoding="utf-8")
        tmp.replace(bdir / "branches.json")
    except OSError as exc:
        try:
            tmp.unlink(missing_ok=True)
        except OSError:
            pass
        console.print(f"[red]Failed to save branches: {exc}[/red]")


def _body_branches(state: UIState) -> Panel:
    data_dir   = state.project["data_dir"]
    active, branches = _load_branches(data_dir)

    full_bdata: dict[str, dict] = {"full": {
        "focus": "full", "description": "Full index — all files, all ctx (default)",
        "ctx_scope": None, "include": [], "exclude": [],
        "vector_count": None, "last_indexed": None,
    }}
    full_bdata.update(branches)
    ordered = list(full_bdata.keys())
    state.branch_list = ordered

    max_r  = state.table_rows() - 2
    cursor = min(state.scroll, max(0, len(ordered) - 1))
    off    = max(0, min(cursor, max(0, len(ordered) - max_r)))
    slice_ = ordered[off: off + max_r]

    focus_colours = {"full": "white", "architecture": "orange1", "ui": "grey74",
                     "backend": "grey74", "security": "deep_pink1", "custom": "grey74"}

    try:
        from linear_canal.commands.branch import _fmt_dt as _branch_fmt_dt
    except Exception:
        def _branch_fmt_dt(v):  # type: ignore[misc]
            return str(v)[:10] if v else "\u2014"

    header = (
        f"  {'':>1}  [grey58]{'#':>3}  {'Branch':<18}  {'Focus':<14}"
        f"  {'Active':<8}  {'CTX':>4}  {'Vectors':>9}  {'Last Indexed':<16}  Description[/grey58]"
    )
    lines = [header, "  " + "─" * 92]

    for bname in slice_:
        bdata      = full_bdata[bname]
        idx_g      = ordered.index(bname)
        cursor_chr = "[bold orange1]>[/bold orange1]" if idx_g == cursor else " "
        focus      = bdata.get("focus", "custom")
        fcol       = focus_colours.get(focus, "white")
        is_act     = "[bold white]★ active[/bold white]" if bname == active else "       "
        ctx_dir    = data_dir / "ctx" if bname == "full" else data_dir / "branches" / bname / "ctx"
        ctx_n      = len(list(ctx_dir.glob("*.ctx"))) if ctx_dir.exists() else 0
        guard      = "[white]✓[/white]" if (ctx_dir / ".lc_sentinel").exists() else "[grey58]·[/grey58]"
        vec        = bdata.get("vector_count")
        vec_str    = f"[grey74]{vec:>9,}[/grey74]" if isinstance(vec, int) and vec >= 0 else "[grey58]        —[/grey58]"
        last_idx   = _branch_fmt_dt(bdata.get("last_indexed"))
        desc       = (bdata.get("description") or "")[:30]
        lines.append(
            f"  {cursor_chr}  [grey58]{idx_g+1:>3}[/grey58]  [bold white]{bname:<18}[/bold white]"
            f"  [{fcol}]{focus:<14}[/{fcol}]"
            f"  {is_act}"
            f"  [grey58]{ctx_n:>3}[/grey58]{guard}"
            f"  {vec_str}"
            f"  [grey58]{last_idx:<16}[/grey58]"
            f"  [grey58]{desc}[/grey58]"
        )

    if len(ordered) > max_r:
        lines.append(f"\n  [dim]rows {off+1}–{min(off+max_r, len(ordered))} of {len(ordered)}  ↑↓ scroll[/dim]")
    lines += [
        "",
        "  [grey74]c[/grey74] create   [grey74]x[/grey74] checkout   [deep_pink1]d[/deep_pink1] delete   [grey74]m[/grey74] merge"
        "   [grey74]r[/grey74] reindex   [grey58]i/Enter[/grey58] info   [grey58]f[/grey58] diff   [grey58]l[/grey58] log",
    ]

    return Panel("\n".join(lines), title="[bold orange1]Context Branches[/bold orange1]",
                 border_style="orange1", padding=(0, 1))


# ── Section 7: Live ───────────────────────────────────────────────────────────

def _body_live(state: UIState) -> Panel:
    p      = state.project
    idx    = _read_json(p["data_dir"] / "index_status.json") or {}
    sct    = _read_json(p["data_dir"] / "scout_status.json") or {}
    ts_d   = (_read_json(p["data_dir"] / "scores.json") or {}).get("tier_summary", {})
    status = idx.get("status", "unknown")
    colour = _status_colour(status)
    pct    = float(idx.get("progress_percent", 0) or 0)

    ts_now  = datetime.now().strftime("%H:%M:%S")
    files_i = idx.get("files_indexed", 0)
    files_t = idx.get("files_total", 0)
    chunks  = idx.get("chunks_created", 0)
    prev    = state.live_prev

    def _log(msg: str) -> None:
        state.live_log.append(f"[dim]{ts_now}[/dim]  {msg}")
        if len(state.live_log) > 12:
            state.live_log.pop(0)

    if prev.get("status") and prev["status"] != status:
        _log(f"Status [orange1]{prev['status']}[/orange1] → [bold {colour}]{status}[/bold {colour}]")
    if files_i > prev.get("files_indexed", 0):
        _log(f"[bold white]+{files_i - prev.get('files_indexed', 0)}[/bold white] files ({files_i}/{files_t})")
    if chunks > prev.get("chunks_created", 0):
        _log(f"[grey74]+{chunks - prev.get('chunks_created', 0)}[/grey74] chunks ({chunks} total)")

    ctx_dir = p["data_dir"] / "ctx"
    ctx_now = set(f.name for f in ctx_dir.glob("*.ctx")) if ctx_dir.exists() else set()
    for name in sorted(ctx_now - prev.get("ctx_set", ctx_now)):
        _log(f"[bold grey74]ctx[/bold grey74]  {name}  [white]created[/white]")

    prev.update({"status": status, "files_indexed": files_i,
                 "chunks_created": chunks, "ctx_set": ctx_now})

    tier_row = "  ".join(
        f"[{TIER_COLOUR[t]}]{TIER_SYMBOL[t]} {t} {ts_d.get(t, 0)}[/{TIER_COLOUR[t]}]"
        for t in ("CRITICAL", "HIGH", "MEDIUM", "LOW")
    )
    cur_f = idx.get("current_file", "")
    ctx_names = sorted(ctx_now)
    sys_c = " ".join(n for n in ctx_names if n.removesuffix(".ctx").isupper())
    mod_c = " ".join(n for n in ctx_names if not n.removesuffix(".ctx").isupper())

    lines = [
        f"  [bold {colour}]{status.upper()}[/bold {colour}]  {_pbar(pct, 36, colour)}  [bold]{pct:.1f}%[/bold]",
        "",
        f"  [grey58]Files  [/grey58]  [bold white]{files_i}[/bold white] [grey58]/ {files_t}  ({idx.get('files_skipped',0)} skipped)[/grey58]",
        f"  [grey58]Vectors[/grey58]  [bold orange1]{chunks}[/bold orange1] [grey58]chunks[/grey58]",
    ]
    if cur_f:
        lines.append(f"  [grey58]Current[/grey58]  [grey74]{cur_f}[/grey74]")
    if sct:
        lines.append(f"  [grey58]Scout  [/grey58]  phase=[grey58]{sct.get('phase','—')}[/grey58]  scored=[white]{sct.get('files_scored','—')}[/white]")
    lines += ["", f"  {tier_row}"]
    if ctx_names:
        lines += ["", f"  CTX  [orange1]{sys_c}[/orange1]"]
        if mod_c:
            lines.append(f"       [grey74]{mod_c}[/grey74]")
    lines += ["", "  [bold orange1]Event Log[/bold orange1]"]
    if state.live_log:
        lines += [f"  {e}" for e in state.live_log]
    else:
        lines.append("  [grey58]Waiting for activity...[/grey58]")

    return Panel("\n".join(lines), title="[bold orange1]Live[/bold orange1]  [grey58]auto-refresh 2s[/grey58]",
                 border_style=colour, padding=(0, 1))


# ── Render dispatcher ─────────────────────────────────────────────────────────

def _render(state: UIState) -> Layout:
    if state.section == S_OVERVIEW:
        body = _body_overview(state)
    elif state.section == S_GRAPH:
        body = _body_graph_v2(state)
    elif state.section == S_FILES:
        body = _body_files(state)
    elif state.section == S_VECTORS:
        body = _body_vectors(state)
    elif state.section == S_CTX:
        body = _body_ctx_file(state) if state.ctx_open else _body_ctx_list(state)
    elif state.section == S_BRANCHES:
        body = _body_branches(state)
    else:
        body = _body_live(state)
    return _make_layout(state, body)


# ── Key handler ───────────────────────────────────────────────────────────────

def _handle_key(ch: str, state: UIState) -> None:
    sec = state.section

    if ch in ("q", "Q", "ESC"):
        if sec == S_CTX and state.ctx_open:
            state.ctx_open   = None
            state.ctx_scroll = 0
        else:
            state.running = False
        return

    if ch in ("r", "R"):
        state.project = _reload_project(state.project)
        state.message = "Refreshed."
        return

    # ── Section switch: number keys ─────────────────────────────────────────
    if ch.isdigit():
        n = int(ch)
        if 1 <= n <= len(_SECTION_NAMES):
            if sec == S_BRANCHES and state.branch_list:
                # Number = quick-checkout for branch
                idx = n - 1
                if 0 <= idx < len(state.branch_list):
                    name = state.branch_list[idx]
                    state.pending = f"checkout:{name}"
                    state.running = False
                    return
            elif sec == S_CTX and not state.ctx_open and state.ctx_files:
                # Number = open ctx file
                idx = n - 1
                if 0 <= idx < len(state.ctx_files):
                    state.ctx_open   = state.ctx_files[idx]
                    state.ctx_scroll = 0
                    return
            # Default: jump to section
            state.section = n - 1
            state.scroll  = 0
            state.graph_sub = 0
        return

    # ── Arrow navigation ────────────────────────────────────────────────────
    if ch == "RIGHT":
        if not (sec == S_CTX and state.ctx_open):
            state.section   = (sec + 1) % len(_SECTION_NAMES)
            state.scroll    = 0
            state.graph_sub = 0

    elif ch == "LEFT":
        if sec == S_CTX and state.ctx_open:
            state.ctx_open   = None
            state.ctx_scroll = 0
        else:
            state.section   = (sec - 1) % len(_SECTION_NAMES)
            state.scroll    = 0
            state.graph_sub = 0

    elif ch == "DOWN":
        if sec == S_CTX and state.ctx_open:
            state.ctx_scroll += 3
        elif sec == S_GRAPH:
            state.scroll += 1
        else:
            state.scroll += 1

    elif ch == "UP":
        if sec == S_CTX and state.ctx_open:
            state.ctx_scroll = max(0, state.ctx_scroll - 3)
        elif sec == S_GRAPH:
            state.scroll = max(0, state.scroll - 1)
        else:
            state.scroll = max(0, state.scroll - 1)

    # ── Section-specific keys ───────────────────────────────────────────────
    elif sec == S_FILES and ch in ("n", "N"):
        state.files_page += 1

    elif sec == S_FILES and ch in ("p", "P"):
        state.files_page = max(0, state.files_page - 1)

    elif sec == S_GRAPH and ch in ("n", "N", "\t"):
        state.graph_sub = (state.graph_sub + 1) % 3
        state.scroll    = 0

    elif sec == S_CTX and ch in ("\r", "\n", " ") and not state.ctx_open:
        # Enter / Space opens highlighted file
        off = max(0, state.scroll)
        if 0 <= off < len(state.ctx_files):
            state.ctx_open   = state.ctx_files[off]
            state.ctx_scroll = 0

    elif sec == S_CTX and ch in ("b", "B"):
        state.ctx_open   = None
        state.ctx_scroll = 0

    elif sec == S_VECTORS and ch in ("d", "D"):
        state.pending = "delete_vectors"
        state.running = False

    elif sec == S_FILES and ch in ("c", "C"):
        state.pending = "clear_hashes"
        state.running = False

    elif sec == S_BRANCHES:
        if ch in ("c", "C"):
            state.pending = "branch_create"
            state.running = False
        elif ch in ("x", "X"):
            sel = min(state.scroll, len(state.branch_list) - 1)
            if 0 <= sel < len(state.branch_list):
                state.pending = f"checkout:{state.branch_list[sel]}"
                state.running = False
        elif ch in ("d", "D"):
            sel = min(state.scroll, len(state.branch_list) - 1)
            if 0 <= sel < len(state.branch_list):
                name = state.branch_list[sel]
                if name != "full":
                    state.pending = f"delete:{name}"
                    state.running = False
        elif ch in ("m", "M"):
            state.pending = "branch_merge"
            state.running = False
        elif ch in ("r", "R"):
            sel = min(state.scroll, len(state.branch_list) - 1)
            if 0 <= sel < len(state.branch_list):
                name = state.branch_list[sel]
                if name != "full":
                    state.pending = f"branch_reindex:{name}"
                    state.running = False
        elif ch in ("i", "I", "\r", "\n"):
            sel = min(state.scroll, len(state.branch_list) - 1)
            if 0 <= sel < len(state.branch_list):
                state.pending = f"branch_show:{state.branch_list[sel]}"
                state.running = False
        elif ch in ("f", "F"):
            state.pending = "branch_diff"
            state.running = False
        elif ch in ("l", "L"):
            state.pending = "branch_log"
            state.running = False


# ── Branch action handlers (outside Live context) ─────────────────────────────

_FOCUS_TEMPLATES = {
    "architecture": {"description": "Architecture + dependency structure only",
                     "include": [], "exclude": [], "ctx_scope": ["ARCHITECTURE", "DEPENDENCIES", "OWNERSHIP"]},
    "ui":           {"description": "Frontend / UI files only",
                     "include": ["*.tsx", "*.jsx", "*.css", "*.scss", "*.ts"], "exclude": ["**/node_modules/**"],
                     "ctx_scope": None},
    "backend":      {"description": "Backend / server files only",
                     "include": ["*.py", "*.go", "*.rs", "*.java", "*.cs"], "exclude": ["**/tests/**"],
                     "ctx_scope": None},
    "security":     {"description": "Security and risk analysis focus",
                     "include": [], "exclude": [], "ctx_scope": ["RISKS", "DEPENDENCIES", "OWNERSHIP"]},
    "custom":       {"description": "Custom — specify your own patterns",
                     "include": [], "exclude": [], "ctx_scope": None},
}


def _prompt(label: str, default: str = "") -> str:
    console.print(f"  [bold orange1]{label}[/bold orange1]" + (f" [grey58](default: {default})[/grey58]" if default else "") + "  ", end="")
    try:
        val = input()
    except EOFError:
        val = ""
    return val.strip() or default


def _run_branch_index(state: UIState, name: str, include: list, exclude: list, ctx_scope: Optional[list]) -> None:
    data_dir   = state.project["data_dir"]
    project_id = state.project["project_id"]
    console.print(f"\n  [grey58]Indexing branch '[bold orange1]{name}[/bold orange1]'...[/grey58]")
    try:
        from linear_canal.pipeline import run_pipeline_for_branch
        bdir = data_dir / "branches" / name
        bdir.mkdir(parents=True, exist_ok=True)
        run_pipeline_for_branch(
            target_dir=data_dir.parent, project_id=project_id, branch_name=name,
            branch_data_dir=bdir, include_patterns=include,
            exclude_patterns=exclude, ctx_scope=ctx_scope,
        )
        console.print(f"  [bold white]✓[/bold white]  Branch '[bold white]{name}[/bold white]' indexed.")
    except Exception as exc:
        console.print(f"  [orange1]Pipeline error:[/orange1] {exc}")


def _update_ambient(data_dir: Path, project_id: str, ctx_dir: Path) -> None:
    import re
    lc_dir = data_dir.parent / ".LC"
    if not lc_dir.exists():
        return
    try:
        ctx_rel = ctx_dir.relative_to(data_dir.parent)
    except ValueError:
        ctx_rel = ctx_dir
    ctx_str = str(ctx_rel).replace("\\", "/") + "/"
    pat = re.compile(r"\.linear_canal_data/" + re.escape(project_id) + r"(?:/branches/[^/]+)?/ctx/")
    for fname in ["CLAUDE.md", "GEMINI.md", "AGENTS.md", ".cursorrules",
                  "ANTIGRAVITY.md", "GLM.md", "WINDSURF.md"]:
        fp = lc_dir / fname
        if not fp.exists():
            continue
        txt = fp.read_text(encoding="utf-8")
        upd = pat.sub(ctx_str, txt)
        if upd != txt:
            fp.write_text(upd, encoding="utf-8")


def _action_branch_create(state: UIState) -> None:
    console.print(Panel(
        "[bold orange1]Create Context Branch[/bold orange1]\n"
        "[grey58]An isolated index of this codebase focused on a specific lens.[/grey58]",
        border_style="orange1", padding=(0, 2),
    ))
    console.print()
    for i, (k, v) in enumerate(list(_FOCUS_TEMPLATES.items()), 1):
        console.print(f"  [grey58]{i}[/grey58]  [grey74]{k:<14}[/grey74]  [grey58]{v['description']}[/grey58]")
    console.print()

    focus_raw = _prompt("Focus (1-5 or name)", "custom")
    focus_keys = list(_FOCUS_TEMPLATES.keys())
    if focus_raw.isdigit():
        idx   = int(focus_raw) - 1
        focus = focus_keys[idx] if 0 <= idx < len(focus_keys) else "custom"
    else:
        focus = focus_raw if focus_raw in _FOCUS_TEMPLATES else "custom"

    name = _prompt("Branch name").replace(" ", "_").lower()
    if not name:
        console.print("  [yellow]Cancelled.[/yellow]")
        time.sleep(1)
        return

    tmpl = _FOCUS_TEMPLATES.get(focus, _FOCUS_TEMPLATES["custom"])
    include, exclude, ctx_scope = tmpl["include"], tmpl["exclude"], tmpl["ctx_scope"]

    if focus == "custom":
        inc_raw = _prompt("Include globs (comma-sep, blank=all)", "")
        exc_raw = _prompt("Exclude globs (comma-sep, blank=none)", "")
        include  = [g.strip() for g in inc_raw.split(",") if g.strip()] if inc_raw else []
        exclude  = [g.strip() for g in exc_raw.split(",") if g.strip()] if exc_raw else []

    data_dir   = state.project["data_dir"]
    project_id = state.project["project_id"]
    active, branches = _load_branches(data_dir)

    if name in branches:
        console.print(f"  [yellow]Branch '{name}' already exists.[/yellow]")
        time.sleep(1.5)
        return

    branches[name] = {
        "name": name, "focus": focus,
        "include": include, "exclude": exclude, "ctx_scope": ctx_scope,
        "description": tmpl["description"] if focus != "custom" else f"Custom: {name}",
    }
    _save_branches(data_dir, active, branches)
    console.print(f"\n  [bold white]✓[/bold white]  Branch '[bold white]{name}[/bold white]' created.")
    _run_branch_index(state, name, include, exclude, ctx_scope)
    console.print("\n  [dim]Press any key...[/dim]", end="")
    _get_char()


def _action_checkout(state: UIState, name: str) -> None:
    data_dir   = state.project["data_dir"]
    project_id = state.project["project_id"]
    _, branches = _load_branches(data_dir)
    _save_branches(data_dir, name, branches)
    ctx_dir = data_dir / "ctx" if name == "full" else data_dir / "branches" / name / "ctx"
    _update_ambient(data_dir, project_id, ctx_dir)
    state.message = f"Checked out '{name}'."


def _action_delete(state: UIState, name: str) -> None:
    project_id = state.project["project_id"]
    data_dir   = state.project["data_dir"]
    active, branches = _load_branches(data_dir)

    # Try to get live vector count for confirmation message
    try:
        from linear_canal.commands.branch import _branch_lancedb_id, _fetch_vector_count
        ldb_id    = _branch_lancedb_id(project_id, name)
        vec_count = _fetch_vector_count(ldb_id)
        vec_info  = f"  [dim]({vec_count:,} LanceDB vectors will be purged)[/dim]" if vec_count > 0 else ""
    except Exception:
        ldb_id, vec_count, vec_info = None, 0, ""

    console.print(Panel(
        f"[bold deep_pink1]Delete branch[/bold deep_pink1] '[bold white]{name}[/bold white]'?  [grey58]y/N[/grey58]{vec_info}",
        border_style="deep_pink1", padding=(0, 2),
    ))
    ans = _prompt("Confirm (y/N)", "n").lower()
    if ans != "y":
        console.print("  [dim]Cancelled.[/dim]")
        time.sleep(0.8)
        return

    # Purge LanceDB vectors
    if ldb_id and vec_count > 0:
        try:
            from linear_canal.commands.branch import _delete_branch_vectors
            purged = _delete_branch_vectors(ldb_id)
            console.print(f"  [dim]Purged {purged:,} LanceDB vectors.[/dim]")
        except Exception as exc:
            console.print(f"  [yellow]Vector purge failed: {exc}[/yellow]")

    new_active = "full" if active == name else active
    branches.pop(name, None)
    _save_branches(data_dir, new_active, branches)
    bdir = data_dir / "branches" / name
    if bdir.exists():
        shutil.rmtree(bdir)

    # Record history
    try:
        from linear_canal.commands.branch import _record_history
        _record_history(data_dir / "branches", "delete", branch=name, vectors_purged=vec_count)
    except Exception:
        pass

    state.message = f"Deleted '{name}' ({vec_count:,} vectors purged)."


def _action_merge(state: UIState) -> None:
    data_dir   = state.project["data_dir"]
    project_id = state.project["project_id"]
    active, branches = _load_branches(data_dir)
    all_b      = {"full": _FOCUS_TEMPLATES.get("full", {}), **branches}
    ordered    = list(all_b.keys())

    console.print(Panel("[bold orange1]Merge Context Branches[/bold orange1]",
                        border_style="orange1", padding=(0, 2)))
    for i, n in enumerate(ordered, 1):
        console.print(f"  [grey58]{i}[/grey58]  [bold white]{n}[/bold white]")
    console.print()

    def _pick(label: str) -> str:
        raw = _prompt(label)
        if raw.isdigit():
            idx = int(raw) - 1
            return ordered[idx] if 0 <= idx < len(ordered) else ""
        return raw if raw in ordered else ""

    a = _pick("First branch (number or name)")
    b = _pick("Second branch (number or name)")
    if not a or not b:
        console.print("  [yellow]Invalid selection.[/yellow]")
        time.sleep(1)
        return

    new_name = _prompt(f"New branch name", f"{a}+{b}").replace(" ", "_").lower()
    if new_name in branches:
        console.print(f"  [yellow]Branch '{new_name}' already exists.[/yellow]")
        time.sleep(1)
        return

    da, db_ = all_b.get(a, {}), all_b.get(b, {})
    sa, sb  = da.get("ctx_scope"), db_.get("ctx_scope")
    merged_scope   = None if (sa is None or sb is None) else sorted(set(sa) | set(sb))
    merged_include = sorted(set(da.get("include", [])) | set(db_.get("include", [])))
    exc_a, exc_b   = set(da.get("exclude", [])), set(db_.get("exclude", []))
    merged_exclude = sorted(exc_a & exc_b) if (exc_a and exc_b) else []  # intersect = max coverage

    now = datetime.now(timezone.utc).isoformat()
    branches[new_name] = {
        "name": new_name, "focus": "custom",
        "include": merged_include, "exclude": merged_exclude, "ctx_scope": merged_scope,
        "description": f"Merged from '{a}' + '{b}'",
        "created_at": now, "updated_at": now,
        "parent_branch": a, "merge_parents": [a, b],
        "last_indexed": None, "vector_count": 0, "files_indexed": 0,
    }
    _save_branches(data_dir, active, branches)

    # Seed ctx files from both branches (A wins on conflict)
    merged_ctx = data_dir / "branches" / new_name / "ctx"
    merged_ctx.mkdir(parents=True, exist_ok=True)
    for src_branch in (b, a):
        src_ctx = (
            data_dir / "ctx" if src_branch == "full"
            else data_dir / "branches" / src_branch / "ctx"
        )
        if src_ctx.exists():
            for f in src_ctx.glob("*.ctx"):
                dst = merged_ctx / f.name
                if not dst.exists():
                    shutil.copy2(f, dst)
    seeded = len(list(merged_ctx.glob("*.ctx")))

    console.print(f"\n  [bold white]✓[/bold white]  Branch '[bold white]{new_name}[/bold white]' created (seeded {seeded} ctx files).")

    # Record history
    try:
        from linear_canal.commands.branch import _record_history
        _record_history(data_dir / "branches", "merge", branches=[a, b], into=new_name)
    except Exception:
        pass

    _run_branch_index(state, new_name, merged_include, merged_exclude, merged_scope)
    console.print("\n  [dim]Press any key...[/dim]", end="")
    _get_char()


# ── Branch info / reindex / diff / log actions ───────────────────────────────

def _action_branch_show(state: UIState, name: str) -> None:
    """Show detailed branch panel (delegates to branch show command output)."""
    data_dir   = state.project["data_dir"]
    project_id = state.project["project_id"]
    active, branches = _load_branches(data_dir)

    full_b: dict[str, dict] = {"full": {
        "focus": "full", "description": "Full index — all files, all ctx (default)",
        "ctx_scope": None, "include": [], "exclude": [],
        "created_at": None, "last_indexed": None, "vector_count": None,
        "parent_branch": None, "merge_parents": [],
    }}
    full_b.update(branches)
    bdata = full_b.get(name)
    if not bdata:
        console.print(f"  [yellow]Branch '{name}' not found.[/yellow]")
        time.sleep(1)
        return

    try:
        from linear_canal.commands.branch import (
            _branch_lancedb_id, _fetch_vector_count, _fmt_dt,
        )
        ldb_id    = _branch_lancedb_id(project_id, name)
        vec_count = _fetch_vector_count(ldb_id)
        _fmt_dt_fn = _fmt_dt
    except Exception:
        ldb_id, vec_count = "unavailable", -1
        def _fmt_dt_fn(v):  # type: ignore[misc]
            return str(v)[:16] if v else "\u2014"

    focus_colours = {"full": "white", "architecture": "orange1", "ui": "grey74",
                     "backend": "grey74", "security": "deep_pink1", "custom": "grey74"}
    focus = bdata.get("focus", "custom")
    fcol  = focus_colours.get(focus, "white")
    active_tag = " [bold white](active)[/bold white]" if name == active else ""

    console.print()
    console.print(Panel(
        f"[bold orange1]{name}[/bold orange1]{active_tag}",
        border_style="orange1", padding=(0, 2),
    ))
    meta = Table(show_header=False, box=None, padding=(0, 2))
    meta.add_column("Key",   style="dim",   width=18)
    meta.add_column("Value", style="white")
    meta.add_row("Focus",        f"[{fcol}]{focus}[/{fcol}]")
    meta.add_row("Description",  bdata.get("description", ""))
    meta.add_row("Parent",       bdata.get("parent_branch") or "\u2014")
    meta.add_row("Created",      _fmt_dt_fn(bdata.get("created_at")))
    meta.add_row("Last indexed", _fmt_dt_fn(bdata.get("last_indexed")))
    meta.add_row("LanceDB ID",   f"[dim]{ldb_id}[/dim]")
    vec_str = f"[grey74]{vec_count:,}[/grey74]" if vec_count >= 0 else "[grey58]unavailable[/grey58]"
    meta.add_row("Vectors",      vec_str)
    if bdata.get("include"):
        meta.add_row("Include",  " ".join(bdata["include"]))
    if bdata.get("exclude"):
        meta.add_row("Exclude",  " ".join(bdata["exclude"]))
    meta.add_row("CTX scope",    ", ".join(bdata["ctx_scope"]) if bdata.get("ctx_scope") else "[dim]all[/dim]")
    if bdata.get("merge_parents"):
        meta.add_row("Merged from", " + ".join(bdata["merge_parents"]))
    console.print(meta)

    ctx_dir = data_dir / "ctx" if name == "full" else data_dir / "branches" / name / "ctx"
    if ctx_dir.exists():
        ctx_files = sorted(ctx_dir.glob("*.ctx"))
        console.print(f"\n  [dim]CTX files ({len(ctx_files)}):[/dim]")
        for f in ctx_files:
            console.print(f"    [dim]{f.name:<42}  {f.stat().st_size:>8,} bytes[/dim]")

    manifest = _load_manifest_full(data_dir)
    history = [
        h for h in manifest.get("history", [])
        if h.get("branch") == name
        or name in (h.get("branches") or [])
        or h.get("into") == name
        or h.get("old") == name
    ]
    if history:
        console.print("\n  [dim]History:[/dim]")
        for h in history[-8:]:
            action = h.get("action", "?")
            at     = _fmt_dt_fn(h.get("at"))
            parts: list[str] = []
            if h.get("parent"):   parts.append(f"from {h['parent']}")
            if h.get("prev"):     parts.append(f"was {h['prev']}")
            if h.get("branches"): parts.append(" + ".join(h["branches"]))
            if h.get("old"):      parts.append(f"{h['old']} -> {name}")
            console.print(f"    [dim]{at}[/dim]  [yellow]{action:<12}[/yellow]  [dim]{'  '.join(parts)}[/dim]")

    console.print("\n  [dim]Press any key...[/dim]", end="")
    _get_char()


def _action_branch_reindex(state: UIState, name: str) -> None:
    """Re-run the index pipeline for a branch from within the TUI."""
    data_dir   = state.project["data_dir"]
    project_id = state.project["project_id"]
    _, branches = _load_branches(data_dir)
    bdata = branches.get(name)
    if not bdata:
        console.print(f"  [yellow]Branch '{name}' not found.[/yellow]")
        time.sleep(1)
        return

    _run_branch_index(
        state, name,
        bdata.get("include", []),
        bdata.get("exclude", []),
        bdata.get("ctx_scope"),
    )

    try:
        from linear_canal.commands.branch import _branch_lancedb_id, _fetch_vector_count, _record_history, _now_iso
        ldb_id    = _branch_lancedb_id(project_id, name)
        vec_count = _fetch_vector_count(ldb_id)
        now       = _now_iso()
        branches[name]["last_indexed"] = now
        branches[name]["vector_count"] = max(0, vec_count)
        _save_branches(data_dir, state.project.get("active_branch", "full"), branches)
        _record_history(data_dir / "branches", "reindex", branch=name, vector_count=vec_count)
        console.print(f"  [grey58]Vectors: [grey74]{vec_count:,}[/grey74][/grey58]")
    except Exception as exc:
        console.print(f"  [dim]Could not update metadata: {exc}[/dim]")

    console.print("\n  [dim]Press any key...[/dim]", end="")
    _get_char()


def _action_branch_diff(state: UIState) -> None:
    """Prompt for two branches and show a comparison table."""
    data_dir   = state.project["data_dir"]
    project_id = state.project["project_id"]
    active, branches = _load_branches(data_dir)
    all_b = {"full": {}, **branches}
    ordered = list(all_b.keys())

    console.print(Panel("[bold orange1]Compare Context Branches[/bold orange1]",
                        border_style="orange1", padding=(0, 2)))
    for i, n in enumerate(ordered, 1):
        console.print(f"  [dim]{i}[/dim]  {n}")
    console.print()

    def _pick(label: str) -> str:
        raw = _prompt(label)
        if raw.isdigit():
            idx = int(raw) - 1
            return ordered[idx] if 0 <= idx < len(ordered) else ""
        return raw if raw in ordered else ""

    branch_a = _pick("First branch")
    branch_b = _pick("Second branch")
    if not branch_a or not branch_b:
        console.print("  [yellow]Invalid selection.[/yellow]")
        time.sleep(1)
        return
    if branch_a == branch_b:
        console.print("  [yellow]Select two different branches.[/yellow]")
        time.sleep(1)
        return

    da  = all_b.get(branch_a, {})
    db_ = all_b.get(branch_b, {})

    def _ctx_set(name: str) -> set[str]:
        ctx_dir = (
            data_dir / "ctx" if name == "full"
            else data_dir / "branches" / name / "ctx"
        )
        return {f.name for f in ctx_dir.glob("*.ctx")} if ctx_dir.exists() else set()

    ctx_a  = _ctx_set(branch_a)
    ctx_b  = _ctx_set(branch_b)
    only_a = ctx_a - ctx_b
    only_b = ctx_b - ctx_a
    shared = ctx_a & ctx_b

    try:
        from linear_canal.commands.branch import _branch_lancedb_id, _fetch_vector_count
        vec_a = _fetch_vector_count(_branch_lancedb_id(project_id, branch_a))
        vec_b = _fetch_vector_count(_branch_lancedb_id(project_id, branch_b))
    except Exception:
        vec_a = vec_b = -1

    focus_colours = {"full": "white", "architecture": "orange1", "ui": "grey74",
                     "backend": "grey74", "security": "deep_pink1", "custom": "grey74"}
    col_a = focus_colours.get(da.get("focus", "custom"), "white")
    col_b = focus_colours.get(db_.get("focus", "custom"), "white")

    def _vec(n: int) -> str:
        return f"[white]{n:,}[/white]" if n >= 0 else "[dim]unavailable[/dim]"

    tbl = Table(show_header=True, header_style="bold dim", border_style="dim", padding=(0, 1))
    tbl.add_column("Property",  style="dim",  width=18)
    tbl.add_column(f"[{col_a}]{branch_a}[/{col_a}]", min_width=24)
    tbl.add_column(f"[{col_b}]{branch_b}[/{col_b}]", min_width=24)
    tbl.add_row("Focus",     f"[{col_a}]{da.get('focus','—')}[/{col_a}]",  f"[{col_b}]{db_.get('focus','—')}[/{col_b}]")
    tbl.add_row("CTX files", str(len(ctx_a)), str(len(ctx_b)))
    tbl.add_row("Vectors",   _vec(vec_a), _vec(vec_b))
    tbl.add_row("Shared CTX",str(len(shared)), str(len(shared)))
    console.print(tbl)
    if only_a:
        console.print(f"  [{col_a}]Only in {branch_a} ({len(only_a)}):[/{col_a}]  " + "  ".join(sorted(only_a)))
    if only_b:
        console.print(f"  [{col_b}]Only in {branch_b} ({len(only_b)}):[/{col_b}]  " + "  ".join(sorted(only_b)))
    console.print(f"\n  [dim]To merge: linear-canal branch merge {branch_a} {branch_b}[/dim]")
    console.print("\n  [dim]Press any key...[/dim]", end="")
    _get_char()


def _action_branch_log(state: UIState) -> None:
    """Show branch operation history."""
    data_dir = state.project["data_dir"]
    manifest = _load_manifest_full(data_dir)
    history  = list(reversed(manifest.get("history", [])[-30:]))

    if not history:
        console.print(Panel("[dim]No branch history recorded yet.[/dim]",
                            border_style="dim", padding=(0, 2)))
        console.print("\n  [dim]Press any key...[/dim]", end="")
        _get_char()
        return

    action_colours = {
        "create": "white", "checkout": "grey74", "merge": "grey74",
        "delete": "deep_pink1",   "reindex": "orange1", "rename": "grey74",
    }
    tbl = Table(
        title="[bold]Branch History[/bold]",
        header_style="bold dim", border_style="dim", show_header=True,
    )
    tbl.add_column("Time",   style="dim",        width=16)
    tbl.add_column("Action", style="yellow",     width=12)
    tbl.add_column("Branch", style="bold white", width=18)
    tbl.add_column("Detail", style="dim",        min_width=28)

    try:
        from linear_canal.commands.branch import _fmt_dt
        _fmt_dt_fn = _fmt_dt
    except Exception:
        def _fmt_dt_fn(v):  # type: ignore[misc]
            return str(v)[:16] if v else "\u2014"

    for h in history:
        action = h.get("action", "?")
        col    = action_colours.get(action, "white")
        branch = h.get("branch") or h.get("into") or "?"
        at     = _fmt_dt_fn(h.get("at"))
        parts: list[str] = []
        if h.get("parent"):          parts.append(f"from {h['parent']}")
        if h.get("prev"):            parts.append(f"was {h['prev']}")
        if h.get("branches"):        parts.append(" + ".join(h["branches"]))
        if h.get("old"):             parts.append(f"{h['old']} -> {branch}")
        if h.get("vector_count") is not None:
            parts.append(f"{h['vector_count']:,} vectors")
        if h.get("vectors_purged"):  parts.append(f"{h['vectors_purged']:,} purged")
        tbl.add_row(at, f"[{col}]{action}[/{col}]", branch, "  ".join(parts))

    console.print(tbl)
    console.print("\n  [dim]Press any key...[/dim]", end="")
    _get_char()


# ── DB management actions ─────────────────────────────────────────────────────

def _action_delete_vectors(state: UIState) -> None:
    """Delete all LanceDB vectors for this project (requires re-index)."""
    pid = state.project["project_id"]
    console.print(Panel(
        f"[bold deep_pink1]Delete all vectors for project '[white]{pid}[/white]' from LanceDB?[/bold deep_pink1]\n\n"
        f"  [grey58]Store: {_LANCEDB_PATH}[/grey58]\n\n"
        "  This removes all embedded chunks for this project.\n"
        "  You will need to re-run  [bold grey74]linear-canal setup[/bold grey74]  to re-index.",
        border_style="deep_pink1", padding=(0, 2),
    ))
    ans = _prompt("Confirm delete? (y/N)", "n").lower()
    if ans != "y":
        console.print("  [dim]Cancelled.[/dim]")
        time.sleep(0.8)
        return

    try:
        import lancedb
        db = lancedb.connect(_LANCEDB_PATH)
        deleted_total = 0
        for tname in db.table_names():
            try:
                tbl   = db.open_table(tname)
                before = tbl.count_rows(f"project_id = '{pid}'")
                if before > 0:
                    tbl.delete(f"project_id = '{pid}'")
                    deleted_total += before
                    console.print(f"  [white]Deleted {before:,} rows from [bold white]{tname}[/bold white][/white]")
            except Exception as exc:
                console.print(f"  [yellow]Warning: could not clean {tname}: {exc}[/yellow]")
        if deleted_total:
            console.print(f"\n  [bold white]✓ Removed {deleted_total:,} vectors for '{pid}'.[/bold white]")
        else:
            console.print(f"  [yellow]No vectors found for '{pid}'.[/yellow]")
    except ImportError:
        console.print("  [red]lancedb not installed.[/red]")
    except Exception as exc:
        console.print(f"  [red]Error:[/red] {exc}")

    console.print("\n  [dim]Press any key...[/dim]", end="")
    _get_char()


def _action_clear_hashes(state: UIState) -> None:
    """Clear the index hash cache for this project (forces full re-index of all files)."""
    pid      = state.project["project_id"]
    db_path  = state.project["data_root"] / "index_hashes.db"
    console.print(Panel(
        f"[bold orange1]Clear hash cache for project '[white]{pid}[/white]'?[/bold orange1]\n\n"
        f"  [grey58]File: {db_path}[/grey58]\n\n"
        "  This removes the file-hash records so every file is treated as changed\n"
        "  on the next run — forcing a full re-embed of all files.",
        border_style="orange1", padding=(0, 2),
    ))
    ans = _prompt("Confirm clear? (y/N)", "n").lower()
    if ans != "y":
        console.print("  [dim]Cancelled.[/dim]")
        time.sleep(0.8)
        return

    try:
        conn = _open_db(db_path)
        if conn is None:
            console.print(f"  [yellow]index_hashes.db not found at {db_path}[/yellow]")
        else:
            with conn:
                count = conn.execute(
                    "SELECT COUNT(*) FROM file_hashes WHERE project_id = ?", (pid,)
                ).fetchone()[0]
                conn.execute("DELETE FROM file_hashes WHERE project_id = ?", (pid,))
            console.print(f"\n  [bold white]✓ Cleared {count:,} hash records for '{pid}'.[/bold white]")
    except Exception as exc:
        console.print(f"  [red]Error:[/red] {exc}")

    console.print("\n  [dim]Press any key...[/dim]", end="")
    _get_char()


# ── TUI runner ────────────────────────────────────────────────────────────────

def _run_live_screen(state: UIState) -> None:
    """Full-screen Live loop. Exits when state.running = False."""
    kq: queue.Queue[str] = queue.Queue()
    stop = threading.Event()
    kt   = threading.Thread(target=_key_reader, args=(kq, stop), daemon=True)
    kt.start()

    live_refresh = state.section == S_LIVE

    with Live(_render(state), screen=True, refresh_per_second=10,
              console=console) as live:
        while state.running:
            # auto-refresh for Live section
            if live_refresh and state.section == S_LIVE:
                try:
                    ch = kq.get(timeout=2.0)
                except queue.Empty:
                    live.update(_render(state))
                    continue
            else:
                try:
                    ch = kq.get(timeout=0.15)
                except queue.Empty:
                    if state.message:
                        state.message = ""
                        live.update(_render(state))
                    live_refresh = state.section == S_LIVE
                    continue

            prev_sec = state.section
            _handle_key(ch, state)
            live_refresh = state.section == S_LIVE

            if prev_sec != state.section:
                state.scroll    = 0
                state.ctx_open  = None
                state.ctx_scroll = 0

            live.update(_render(state))

    stop.set()
    kt.join(timeout=1.0)  # wait for key-reader thread to exit cleanly


def _run_project_tui(project: dict, subtitle: str) -> None:
    state = UIState(project=project, subtitle=subtitle)

    while True:
        state.running = True
        _run_live_screen(state)

        if not state.pending:
            return  # quit

        pending      = state.pending
        state.pending = ""
        console.clear()

        if pending == "delete_vectors":
            _action_delete_vectors(state)
            state.project = _reload_project(state.project)
            state.section = S_VECTORS
        elif pending == "clear_hashes":
            _action_clear_hashes(state)
            state.project = _reload_project(state.project)
            state.section = S_FILES
        elif pending == "branch_create":
            _action_branch_create(state)
            state.project = _reload_project(state.project)
            state.section = S_BRANCHES
        elif pending.startswith("checkout:"):
            name = pending.split(":", 1)[1]
            _action_checkout(state, name)
            state.project = _reload_project(state.project)
            state.section = S_BRANCHES
        elif pending.startswith("delete:"):
            name = pending.split(":", 1)[1]
            _action_delete(state, name)
            state.project = _reload_project(state.project)
            state.section = S_BRANCHES
        elif pending == "branch_merge":
            _action_merge(state)
            state.project = _reload_project(state.project)
            state.section = S_BRANCHES
        elif pending.startswith("branch_reindex:"):
            name = pending.split(":", 1)[1]
            _action_branch_reindex(state, name)
            state.project = _reload_project(state.project)
            state.section = S_BRANCHES
        elif pending.startswith("branch_show:"):
            name = pending.split(":", 1)[1]
            _action_branch_show(state, name)
            state.project = _reload_project(state.project)
            state.section = S_BRANCHES
        elif pending == "branch_diff":
            _action_branch_diff(state)
            state.project = _reload_project(state.project)
            state.section = S_BRANCHES
        elif pending == "branch_log":
            _action_branch_log(state)
            state.section = S_BRANCHES
        else:
            return


# ── Data loading ──────────────────────────────────────────────────────────────

def _load_project_meta(project_dir: Path, project_id: str, data_root: Path) -> dict:
    index_status = _read_json(project_dir / "index_status.json")
    scout_status = _read_json(project_dir / "scout_status.json")
    scores       = _read_json(project_dir / "scores.json")
    ctx_dir      = project_dir / "ctx"
    ctx_files    = sorted(ctx_dir.glob("*.ctx")) if ctx_dir.exists() else []
    return {
        "project_id":   project_id,
        "data_dir":     project_dir,
        "data_root":    data_root,
        "index_status": index_status or {},
        "scout_status": scout_status or {},
        "scores":       scores or {},
        "tier_summary": (scores or {}).get("tier_summary", {}),
        "ctx_files":    ctx_files,
        "ctx_count":    len(ctx_files),
    }


def _reload_project(project: dict) -> dict:
    return _load_project_meta(
        project["data_dir"], project["project_id"], project["data_root"]
    )


def _discover_data_roots(local_only: bool = False) -> list[Path]:
    roots: list[Path] = []
    if not local_only and _LC_HOME_DATA.exists():
        roots.append(_LC_HOME_DATA)
    candidate = Path.cwd()
    for _ in range(5):
        local = candidate / _LC_LOCAL_DIR
        if local.exists() and local not in roots:
            roots.append(local)
        parent = candidate.parent
        if parent == candidate:
            break
        candidate = parent
    return roots


def _find_all_projects(local_only: bool = False) -> list[dict]:
    """Find all indexed projects.

    Discovery sources (merged, deduplicated):
      1. Global project registry (~/.linear-canal/data/projects.json)
         — written by the pipeline after every index run.
      2. Subdirectories of ~/.linear-canal/data/ (legacy / fallback).
      3. .linear_canal_data/ in/near the current working directory.

    Each returned dict includes an extra key ``"missing"`` (bool) that is
    True when the original project directory no longer exists on disk.
    """
    projects: list[dict] = []
    seen: set[str] = set()

    # ── Source 1: Global project registry ─────────────────────────────────
    if not local_only:
        try:
            from linear_canal.pipeline import get_all_registered_projects

            for pid, entry in get_all_registered_projects().items():
                data_dir = Path(entry.get("data_dir", ""))
                project_dir = data_dir / pid
                if project_dir.is_dir():
                    key = str(project_dir.resolve())
                    if key not in seen:
                        seen.add(key)
                        meta = _load_project_meta(project_dir, pid, data_dir)
                        meta["missing"] = not Path(entry.get("project_path", "")).exists()
                        projects.append(meta)
                elif entry.get("deleted_at") or not Path(entry.get("project_path", "")).exists():
                    # Project dir was inside the project (which is now gone), but
                    # we can still show a tombstoned entry so the user can see it.
                    key = f"__missing__{pid}"
                    if key not in seen:
                        seen.add(key)
                        # Build a minimal stub so the picker can display it
                        stub = {
                            "project_id":   pid,
                            "data_dir":     data_dir,
                            "data_root":    data_dir,
                            "index_status": {"status": "deleted", "progress_percent": 0},
                            "scout_status": {},
                            "scores":       {},
                            "tier_summary": {},
                            "ctx_files":    [],
                            "ctx_count":    0,
                            "missing":      True,
                            "_registry_entry": entry,
                        }
                        projects.append(stub)
        except Exception:
            pass  # registry missing or unreadable — fall through

    # ── Source 2+3: Walk data roots (home + local CWD chain) ──────────────
    for data_root in _discover_data_roots(local_only=local_only):
        if not data_root.exists():
            continue
        for entry in sorted(data_root.iterdir()):
            if not entry.is_dir():
                continue
            if entry.name in {"lancedb_global_store", "__pycache__"}:
                continue
            key = str(entry.resolve())
            if key in seen:
                continue
            if not any(
                (entry / f).exists()
                for f in ("index_status.json", "scores.json", "graph.db", "ctx")
            ):
                continue
            seen.add(key)
            meta = _load_project_meta(entry, entry.name, data_root)
            meta["missing"] = False
            projects.append(meta)
    return projects


# ── Global project picker ─────────────────────────────────────────────────────

def _run_global_picker(projects: list) -> None:
    """Full-screen project picker using the same Live engine."""

    @dataclass
    class PickState:
        projects: list = field(default_factory=list)
        cursor: int = 0
        running: bool = True
        selected: int = -1

    pstate = PickState(projects=projects)

    def _render_picker(ps: PickState) -> Layout:
        rows = max(4, console.size.height - _HEADER_ROWS - _FOOTER_ROWS - 8)
        off  = max(0, ps.cursor - rows + 1)
        sl   = ps.projects[off: off + rows]

        missing_count = sum(1 for p in ps.projects if p.get("missing"))

        lines = [
            f"  [dim]{'#':>3}  {'Project':<22}  {'Status':<10}  {'Files':>7}  "
            f"{'Vectors':>8}  {'CTX':>4}  {'Branches':>8}  {'Tier [C][H][M]':<18}  Last indexed[/dim]",
            "  " + "─" * 110,
        ]
        for p in sl:
            i      = ps.projects.index(p)
            idx    = p["index_status"]
            is_missing = p.get("missing", False)
            status = idx.get("status", "?")
            if is_missing:
                col    = "dim"
                sc_str = "deleted"
            else:
                col    = _status_colour(status)
                pct    = idx.get("progress_percent", 0) or 0
                sc_str = "done" if "complet" in status.lower() else f"{pct:.0f}%"
            ts     = p["tier_summary"]
            tier   = (f"[bold deep_pink1]{ts.get('CRITICAL',0)}[/bold deep_pink1] "
                      f"[orange1]{ts.get('HIGH',0)}[/orange1] "
                      f"[grey74]{ts.get('MEDIUM',0)}[/grey74]")
            bdir   = p["data_dir"] / "branches" if p.get("data_dir") else Path()
            try:
                bn = len([d for d in bdir.iterdir() if d.is_dir()]) if bdir.exists() else 0
            except Exception:
                bn = 0
            cursor  = "[bold orange1]>[/bold orange1]" if i == ps.cursor else " "
            tombstone_flag = " [dim red]†[/dim red]" if is_missing else ""
            lines.append(
                f"  {cursor} [{'dim' if is_missing else 'bold white'}]{p['project_id']:<22}[/{'dim' if is_missing else 'bold white'}]"
                f"{tombstone_flag}"
                f"  [{col}]{sc_str:<10}[/{col}]"
                f"  {str(idx.get('files_indexed','—')):>7}"
                f"  {str(idx.get('chunks_created','—')):>8}"
                f"  {p['ctx_count']:>4}"
                f"  {bn:>8}"
                f"  {tier:<18}"
                f"  [dim]{_fmt_dt(idx.get('completed_at') or idx.get('started_at'))}[/dim]"
            )

        # Missing projects hint
        footer_lines = []
        if missing_count:
            footer_lines.append(
                f"  [dim yellow]† {missing_count} project(s) missing — "
                "run [bold]linear-canal cleanup[/bold] to manage[/dim yellow]"
            )

        body_text = "\n".join(lines)
        if footer_lines:
            body_text += "\n\n" + "\n".join(footer_lines)

        body = Panel(body_text, title="[bold]All Projects[/bold]",
                     border_style="dim", padding=(0, 1))
        layout = Layout()
        layout.split_column(
            Layout(Panel(
                "[bold orange1]Linear Canal[/bold orange1]  [grey58]·[/grey58]  "
                "[bold white]Global Browser[/bold white]",
                border_style="orange1", padding=(0, 2)), size=_HEADER_ROWS),
            Layout(body),
            Layout(Panel(
                "[dim]↑ ↓[/dim] select   [dim]Enter[/dim] open   [dim]r[/dim] refresh   [dim]q[/dim] quit",
                border_style="dim", padding=(0, 2)), size=_FOOTER_ROWS),
        )
        return layout

    kq: queue.Queue[str] = queue.Queue()
    stop = threading.Event()
    kt   = threading.Thread(target=_key_reader, args=(kq, stop), daemon=True)
    kt.start()

    with Live(_render_picker(pstate), screen=True, refresh_per_second=10,
              console=console) as live:
        while pstate.running:
            try:
                ch = kq.get(timeout=0.2)
            except queue.Empty:
                continue

            if ch in ("q", "Q", "ESC"):
                pstate.running = False
            elif ch in ("r", "R"):
                fresh = _find_all_projects(local_only=False)
                if fresh:
                    pstate.projects = fresh
                    pstate.cursor   = min(pstate.cursor, len(fresh) - 1)
            elif ch == "DOWN":
                pstate.cursor = min(pstate.cursor + 1, len(pstate.projects) - 1)
            elif ch == "UP":
                pstate.cursor = max(pstate.cursor - 1, 0)
            elif ch in ("\r", "\n", " "):
                if 0 <= pstate.cursor < len(pstate.projects):
                    if not pstate.projects[pstate.cursor].get("missing"):
                        pstate.selected = pstate.cursor
                        pstate.running  = False
            elif ch.isdigit() and 1 <= int(ch) <= len(pstate.projects):
                idx_ = int(ch) - 1
                if not pstate.projects[idx_].get("missing"):
                    pstate.selected = idx_
                    pstate.running  = False

            live.update(_render_picker(pstate))

    stop.set()
    kt.join(timeout=1.0)  # wait for key-reader thread to exit cleanly

    if pstate.selected >= 0:
        _run_project_tui(pstate.projects[pstate.selected], "global")


# ── CLI Command ───────────────────────────────────────────────────────────────

@click.command("db")
@click.option("--project", "-p", type=click.Path(exists=True, file_okay=False),
              default=".", show_default=True,
              help="Project directory to open (default: current dir).")
@click.option("--global", "global_mode", is_flag=True, default=False,
              help="Open the global project picker instead of the local project.")
def db_cmd(project: str, global_mode: bool) -> None:
    """Full-screen TUI - browse databases, vectors, ctx files and branches.

    \b
    Auto-detects the local project and opens it directly.
    Falls back to the global picker when no local project is found
    or when --global is passed.

    \b
    Sections (< > or 1-7):
      1 Overview   2 Graph   3 Files   4 Vectors   5 CTX   6 Branches   7 Live

    \b
    DB management:
      Vectors section   D   Delete this project's vectors from LanceDB
      Files section     C   Clear hash cache (forces full re-index)
    """
    if global_mode:
        projects = _find_all_projects(local_only=False)
        if not projects:
            console.print(Panel(
                "[orange1]No indexed projects found.[/orange1]\n\n"
                "Run  [bold grey74]linear-canal setup[/bold grey74]  to index a project.",
                border_style="orange1", padding=(0, 2),
            ))
            return
        _run_global_picker(projects)
        return

    # ── Auto-detect local project ─────────────────────────────────────────────
    start     = Path(project).resolve()
    found_dir: Optional[Path] = None
    candidate = start
    for _ in range(5):
        local = candidate / _LC_LOCAL_DIR
        if local.exists():
            found_dir = local
            break
        parent = candidate.parent
        if parent == candidate:
            break
        candidate = parent

    if found_dir is not None:
        projects = [
            _load_project_meta(e, e.name, found_dir)
            for e in sorted(found_dir.iterdir())
            if e.is_dir()
        ]
        if projects:
            _run_project_tui(projects[0], "local")
            return
        console.print(Panel(
            f"[orange1]No project data found in {found_dir}[/orange1]\n\n"
            "Run  [bold grey74]linear-canal setup[/bold grey74]  to index this project.",
            border_style="orange1", padding=(0, 2),
        ))
        return

    # ── No local project — fall back to global picker ─────────────────────────
    projects = _find_all_projects(local_only=False)
    if not projects:
        console.print(Panel(
            "[orange1]No indexed projects found.[/orange1]\n\n"
            f"  Searched from: [grey58]{start}[/grey58]\n\n"
            "Run  [bold grey74]linear-canal setup[/bold grey74]  to index a project.\n"
            "Or pass  [bold grey74]--project /path/to/project[/bold grey74]  to specify a directory.",
            border_style="orange1", padding=(0, 2),
        ))
        return

    if len(projects) == 1:
        _run_project_tui(projects[0], "global")
    else:
        _run_global_picker(projects)
