"""linear-canal branch — Git-style context branching.

Commands:
    branch create <name>       Create and index a new context branch
    branch list                List all branches with metadata
    branch checkout <name>     Switch the active branch
    branch delete <name>       Delete a branch (+ its LanceDB vectors)
    branch show <name>         Detailed info + live LanceDB stats
    branch diff <a> <b>        Compare ctx coverage between two branches
    branch merge <a> <b>       Merge two branches into a new one
    branch reindex <name>      Re-run the index pipeline on an existing branch
    branch rename <old> <new>  Rename a branch (updates LanceDB row namespace)
    branch log                 Show history of branch operations

A context branch is a named, isolated index of the same codebase focused on a
different lens. Each branch has its own ctx dir and Qdrant collections namespaced
as <project_id>__<branch_name>_*.

Storage layout:
    .linear_canal_data/<project_id>/branches/branches.json   ← manifest
    .linear_canal_data/<project_id>/branches/<name>/         ← branch data dir
    .linear_canal_data/<project_id>/branches/<name>/ctx/     ← branch ctx files

The 'full' branch is the default and maps to the legacy ctx location:
    .linear_canal_data/<project_id>/ctx/
"""

from __future__ import annotations

import json
import re
import shutil
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional

import click
from rich.console import Console
from rich.panel import Panel
from rich.table import Table

console = Console()

# Built-in focus templates
# Each entry: include patterns, exclude patterns, ctx_scope (None = all ctx files)
FOCUS_TEMPLATES: dict[str, dict] = {
    "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", "*.js"],
        "exclude": ["**/node_modules/**", "**/dist/**"],
        "ctx_scope": None,
    },
    "backend": {
        "description": "Backend / server files only",
        "include": ["*.py", "*.go", "*.rs", "*.java", "*.cs", "*.rb"],
        "exclude": ["**/tests/**", "**/test_*"],
        "ctx_scope": None,
    },
    "security": {
        "description": "Security and risk analysis focus",
        "include": [],
        "exclude": [],
        "ctx_scope": ["RISKS", "DEPENDENCIES", "OWNERSHIP"],
    },
    "full": {
        "description": "Full index (default)",
        "include": [],
        "exclude": [],
        "ctx_scope": None,
    },
}


# ---------------------------------------------------------------------------
# Manifest helpers
# ---------------------------------------------------------------------------

def _branches_dir(project_path: Path, project_id: str) -> Path:
    return project_path / ".linear_canal_data" / project_id / "branches"


def _load_manifest(branches_dir: Path) -> dict:
    manifest_path = branches_dir / "branches.json"
    if not manifest_path.exists():
        return {"active": "full", "branches": {}, "history": []}
    data = json.loads(manifest_path.read_text(encoding="utf-8"))
    data.setdefault("history", [])
    return data


def _save_manifest(branches_dir: Path, manifest: dict) -> None:
    branches_dir.mkdir(parents=True, exist_ok=True)
    tmp = branches_dir / "branches.json.tmp"
    try:
        tmp.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
        tmp.replace(branches_dir / "branches.json")
    except OSError as exc:
        # Clean up orphaned tmp file if replace failed
        try:
            tmp.unlink(missing_ok=True)
        except OSError:
            pass
        raise RuntimeError(f"Failed to save branch manifest: {exc}") from exc


def get_active_branch(project_path: Path, project_id: str) -> str:
    """Return the name of the currently active context branch."""
    manifest = _load_manifest(_branches_dir(project_path, project_id))
    return manifest.get("active", "full")


def get_branch_ctx_dir(project_path: Path, project_id: str, branch: str) -> Path:
    """Return the ctx directory for a given branch."""
    if branch == "full":
        return project_path / ".linear_canal_data" / project_id / "ctx"
    return _branches_dir(project_path, project_id) / branch / "ctx"


def get_branch_data_dir(project_path: Path, project_id: str, branch: str) -> Path:
    """Return the data root dir for a given branch."""
    if branch == "full":
        return project_path / ".linear_canal_data" / project_id
    return _branches_dir(project_path, project_id) / branch


# ---------------------------------------------------------------------------
# Utility helpers
# ---------------------------------------------------------------------------

def _now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()


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


def _record_history(branches_dir: Path, action: str, **kwargs) -> None:
    """Append an operation to the branch history log (capped at 100 entries)."""
    manifest = _load_manifest(branches_dir)
    entry = {"action": action, "at": _now_iso(), **kwargs}
    manifest.setdefault("history", []).append(entry)
    manifest["history"] = manifest["history"][-100:]
    _save_manifest(branches_dir, manifest)


def _resolve_project(project: str) -> tuple[Path, str, Path]:
    """Return (project_path, project_id, branches_dir)."""
    p = Path(project).resolve()
    return p, p.name, _branches_dir(p, p.name)


def _require_initialized(project_path: Path, project_id: str) -> None:
    if not (project_path / ".linear_canal_data" / project_id).exists():
        console.print(
            "[red]Project not initialized.[/red] "
            "Run [bold cyan]linear-canal init[/bold cyan] first."
        )
        raise SystemExit(1)


def _require_branch(manifest: dict, name: str) -> dict:
    if name == "full":
        return {**FOCUS_TEMPLATES["full"], "name": "full"}
    if name not in manifest.get("branches", {}):
        console.print(
            f"[red]Branch '{name}' not found.[/red] "
            "Run [bold cyan]linear-canal branch list[/bold cyan] to see available branches."
        )
        raise SystemExit(1)
    return manifest["branches"][name]


# ---------------------------------------------------------------------------
# LanceDB helpers
# ---------------------------------------------------------------------------

def _branch_lancedb_id(project_id: str, branch: str) -> str:
    """Return the LanceDB project_id namespace for a branch."""
    return project_id if branch == "full" else f"{project_id}__{branch}"


def _fetch_vector_count(branch_lancedb_id: str) -> int:
    """Query LanceDB for vector row count for a branch. Returns -1 on error."""
    try:
        import lancedb  # type: ignore[import-not-found]
        db = lancedb.connect(str(Path.home() / ".linear-canal" / "data" / "lancedb_global_store"))
        total = 0
        safe = branch_lancedb_id.replace("'", "''")
        for tname in db.table_names():
            try:
                total += db.open_table(tname).count_rows(f"project_id = '{safe}'")
            except Exception:
                pass
        return total
    except Exception:
        return -1


def _delete_branch_vectors(branch_lancedb_id: str) -> int:
    """Delete all LanceDB rows for a branch. Returns count deleted."""
    try:
        import lancedb  # type: ignore[import-not-found]
        db = lancedb.connect(str(Path.home() / ".linear-canal" / "data" / "lancedb_global_store"))
        total = 0
        safe = branch_lancedb_id.replace("'", "''")
        for tname in db.table_names():
            try:
                tbl = db.open_table(tname)
                n = tbl.count_rows(f"project_id = '{safe}'")
                if n > 0:
                    tbl.delete(f"project_id = '{safe}'")
                    total += n
            except Exception:
                pass
        return total
    except Exception:
        return 0


def _rename_branch_vectors(old_id: str, new_id: str) -> int:
    """Rename LanceDB project_id from old_id to new_id. Returns rows updated."""
    try:
        import lancedb  # type: ignore[import-not-found]
        db = lancedb.connect(str(Path.home() / ".linear-canal" / "data" / "lancedb_global_store"))
        total = 0
        safe = old_id.replace("'", "''")
        for tname in db.table_names():
            try:
                tbl = db.open_table(tname)
                n = tbl.count_rows(f"project_id = '{safe}'")
                if n > 0:
                    tbl.update(where=f"project_id = '{safe}'", values={"project_id": new_id})
                    total += n
            except Exception:
                pass
        return total
    except Exception:
        return 0


# ---------------------------------------------------------------------------
# Post-pipeline manifest update
# ---------------------------------------------------------------------------

def _post_pipeline_update(
    branches_dir: Path,
    branch_name: str,
    project_id: str,
    branch_data_dir: Path,
) -> None:
    """Update branch metadata (last_indexed, vector_count, files_indexed) after a pipeline run."""
    manifest = _load_manifest(branches_dir)
    if branch_name not in manifest.get("branches", {}):
        return
    ldb_id = _branch_lancedb_id(project_id, branch_name)
    vec_count = _fetch_vector_count(ldb_id)
    ctx_dir = branch_data_dir / "ctx"
    files_indexed = len(list(ctx_dir.glob("*.ctx"))) if ctx_dir.exists() else 0
    now = _now_iso()
    entry = manifest["branches"][branch_name]
    entry["last_indexed"] = now
    entry["updated_at"] = now
    if vec_count >= 0:
        entry["vector_count"] = vec_count
    entry["files_indexed"] = files_indexed
    _save_manifest(branches_dir, manifest)

@click.group("branch")
def branch_cmd() -> None:
    """Manage context branches for this project.

    Context branches are isolated indexed views of the same codebase, each
    focused on a different lens (architecture, UI, backend, security).
    Each branch has its own ctx files and LanceDB vectors, namespaced
    separately so they never interfere with each other.

    \b
    Examples:
        linear-canal branch create ui --focus=ui
        linear-canal branch create arch --focus=architecture
        linear-canal branch list --vectors
        linear-canal branch checkout arch
        linear-canal branch diff ui backend
        linear-canal branch show ui
        linear-canal branch merge ui backend --name=fullstack
        linear-canal branch reindex ui
        linear-canal branch rename old-name new-name
        linear-canal branch log
    """


@branch_cmd.command("create")
@click.argument("name")
@click.option("--focus", type=click.Choice(list(FOCUS_TEMPLATES.keys())), default=None,
              help="Built-in focus template (architecture, ui, backend, security, full)")
@click.option("--include", "include_patterns", default=None,
              help="Comma-separated glob patterns to include (e.g. '*.tsx,*.css')")
@click.option("--exclude", "exclude_patterns", default=None,
              help="Comma-separated glob patterns to exclude")
@click.option("--from", "parent_branch", default=None,
              help="Fork from this branch (default: current active branch)")
@click.option("--skip-index", is_flag=True, default=False,
              help="Register the branch without running the index pipeline")
@click.option("--project", "-p", type=click.Path(exists=True, file_okay=False),
              default=".", help="Project path")
def branch_create(
    name: str,
    focus: Optional[str],
    include_patterns: Optional[str],
    exclude_patterns: Optional[str],
    parent_branch: Optional[str],
    skip_index: bool,
    project: str,
) -> None:
    """Create a new context branch and index it.

    \b
    Examples:
        linear-canal branch create ui --focus=ui
        linear-canal branch create arch --focus=architecture
        linear-canal branch create payments --include="src/payments/**,*.py"
        linear-canal branch create draft --skip-index
    """
    project_path, project_id, branches_dir = _resolve_project(project)
    _require_initialized(project_path, project_id)

    if name == "full":
        console.print("[yellow]'full' is the default branch and always exists.[/yellow]")
        return

    if not re.match(r"^[a-zA-Z0-9_-]+$", name):
        console.print("[red]Branch name may only contain letters, digits, hyphens, underscores.[/red]")
        raise SystemExit(1)

    template = FOCUS_TEMPLATES.get(focus or "full", FOCUS_TEMPLATES["full"])
    include  = [p.strip() for p in include_patterns.split(",")] if include_patterns else list(template["include"])
    exclude  = [p.strip() for p in exclude_patterns.split(",")] if exclude_patterns else list(template["exclude"])
    ctx_scope = list(template["ctx_scope"]) if template.get("ctx_scope") else None

    manifest = _load_manifest(branches_dir)
    if name in manifest.get("branches", {}):
        console.print(f"[yellow]Branch '{name}' already exists.[/yellow]")
        return

    from_branch = parent_branch or manifest.get("active", "full")
    now = _now_iso()
    branch_data: dict = {
        "name":          name,
        "focus":         focus or "custom",
        "include":       include,
        "exclude":       exclude,
        "ctx_scope":     ctx_scope,
        "description":   template["description"] if focus else f"Custom branch: {name}",
        "created_at":    now,
        "updated_at":    now,
        "parent_branch": from_branch,
        "last_indexed":  None,
        "vector_count":  0,
        "files_indexed": 0,
    }
    manifest.setdefault("branches", {})[name] = branch_data
    _save_manifest(branches_dir, manifest)
    _record_history(branches_dir, "create", branch=name, parent=from_branch, focus=focus or "custom")

    console.print(
        f"[green]Created branch '[bold]{name}[/bold]'[/green]  "
        f"[dim]parent={from_branch}  focus={focus or 'custom'}[/dim]"
    )

    if not skip_index:
        console.print(f"[dim]Indexing branch '{name}'...[/dim]")
        _run_branch_pipeline(project_path, project_id, name, branch_data)
        console.print(f"[green]Branch '{name}' indexed and ready.[/green]")
    else:
        console.print(f"[dim]Skipped indexing. Run  linear-canal branch reindex {name}  when ready.[/dim]")

    console.print(f"Switch to it:  [bold cyan]linear-canal branch checkout {name}[/bold cyan]")


@branch_cmd.command("list")
@click.option("--project", "-p", type=click.Path(exists=True, file_okay=False),
              default=".", help="Project path")
@click.option("--vectors", is_flag=True, default=False,
              help="Query LanceDB for live vector counts (adds a moment of latency)")
def branch_list(project: str, vectors: bool) -> None:
    """List all context branches with metadata."""
    project_path, project_id, branches_dir = _resolve_project(project)
    manifest = _load_manifest(branches_dir)
    active = manifest.get("active", "full")

    focus_colours = {
        "full": "white", "architecture": "yellow", "ui": "cyan",
        "backend": "blue", "security": "red", "custom": "magenta",
    }
    all_branches: dict = {
        "full": {
            **FOCUS_TEMPLATES["full"], "name": "full",
            "description": "Full index (default)", "created_at": None,
            "last_indexed": None, "vector_count": None, "parent_branch": None,
        }
    }
    all_branches.update(manifest.get("branches", {}))

    tbl = Table(
        title="[bold]Context Branches[/bold]",
        header_style="bold dim", border_style="dim", show_header=True,
    )
    tbl.add_column("",             width=2)
    tbl.add_column("Branch",       style="bold white", min_width=16)
    tbl.add_column("Focus",        min_width=14)
    tbl.add_column("Vectors",      justify="right", width=9)
    tbl.add_column("CTX Files",    justify="right", width=9)
    tbl.add_column("Last Indexed", justify="right", min_width=16)
    tbl.add_column("Description",  style="dim",    min_width=30)

    for bname, bdata in all_branches.items():
        marker = "[bold bright_cyan]*[/bold bright_cyan]" if bname == active else " "
        focus  = bdata.get("focus", "custom")
        fcol   = focus_colours.get(focus, "white")

        if vectors:
            vc = _fetch_vector_count(_branch_lancedb_id(project_id, bname))
            vec_s = f"{vc:,}" if vc >= 0 else "?"
        else:
            vc = bdata.get("vector_count")
            vec_s = f"{vc:,}" if vc is not None else "\u2014"

        ctx_dir = (
            project_path / ".linear_canal_data" / project_id / "ctx"
            if bname == "full"
            else branches_dir / bname / "ctx"
        )
        ctx_n = len(list(ctx_dir.glob("*.ctx"))) if ctx_dir.exists() else 0

        tbl.add_row(
            marker,
            f"[{fcol}]{bname}[/{fcol}]",
            f"[{fcol}]{focus}[/{fcol}]",
            vec_s,
            str(ctx_n) if ctx_n else "\u2014",
            _fmt_dt(bdata.get("last_indexed")),
            (bdata.get("description") or "")[:40],
        )

    console.print(tbl)
    console.print(f"\n  Active: [bold bright_cyan]{active}[/bold bright_cyan]")
    if not vectors:
        console.print("  [dim]Add --vectors to query live LanceDB counts[/dim]")


@branch_cmd.command("checkout")
@click.argument("name")
@click.option("--project", "-p", type=click.Path(exists=True, file_okay=False),
              default=".", help="Project path")
def branch_checkout(name: str, project: str) -> None:
    """Switch to a different context branch.

    Updates agent config files (.LC/) to point to this branch's ctx directory
    so AI agents immediately read this branch's context.
    """
    project_path, project_id, branches_dir = _resolve_project(project)
    manifest = _load_manifest(branches_dir)

    known = set(manifest.get("branches", {}).keys()) | {"full"}
    if name not in known:
        console.print(
            f"[red]Branch '{name}' not found.[/red] "
            "Run [bold]linear-canal branch list[/bold] to see available branches."
        )
        raise SystemExit(1)

    prev = manifest.get("active", "full")
    manifest["active"] = name
    _save_manifest(branches_dir, manifest)
    _record_history(branches_dir, "checkout", branch=name, prev=prev)

    ctx_dir = get_branch_ctx_dir(project_path, project_id, name)
    _update_ambient_branch_pointer(project_path, project_id, ctx_dir)

    console.print(f"[green]Switched to context branch '[bold]{name}[/bold]'[/green]")
    console.print(f"  [dim]Previous: {prev}[/dim]")
    try:
        console.print(f"  [dim]CTX path:  {ctx_dir.relative_to(project_path)}[/dim]")
    except ValueError:
        console.print(f"  [dim]CTX path: {ctx_dir}[/dim]")


@branch_cmd.command("delete")
@click.argument("name")
@click.option("--no-purge", is_flag=True, default=False,
              help="Keep LanceDB vectors (do not purge embeddings)")
@click.option("--yes", "-y", is_flag=True, default=False,
              help="Skip confirmation prompt")
@click.option("--project", "-p", type=click.Path(exists=True, file_okay=False),
              default=".", help="Project path")
def branch_delete(name: str, no_purge: bool, yes: bool, project: str) -> None:
    """Delete a context branch and its LanceDB vectors."""
    if name == "full":
        console.print("[red]Cannot delete the 'full' branch.[/red]")
        raise SystemExit(1)

    project_path, project_id, branches_dir = _resolve_project(project)
    manifest = _load_manifest(branches_dir)

    if name not in manifest.get("branches", {}):
        console.print(f"[yellow]Branch '{name}' not found.[/yellow]")
        return

    ldb_id    = _branch_lancedb_id(project_id, name)
    vec_count = _fetch_vector_count(ldb_id)
    vec_str   = f"{vec_count:,}" if vec_count >= 0 else "?"

    if not yes:
        console.print(
            f"  Delete branch [bold red]{name}[/bold red]?\n"
            f"  Vectors in LanceDB: [cyan]{vec_str}[/cyan]"
            + (" (will be purged)" if not no_purge else " (kept)")
        )
        ans = click.prompt("Confirm (y/N)", default="n")
        if ans.lower() != "y":
            console.print("[dim]Cancelled.[/dim]")
            return

    if manifest.get("active") == name:
        manifest["active"] = "full"
        console.print(f"[yellow]Branch '{name}' was active - switching to 'full'.[/yellow]")

    del manifest["branches"][name]
    _save_manifest(branches_dir, manifest)

    branch_dir = branches_dir / name
    if branch_dir.exists():
        shutil.rmtree(branch_dir)

    purged = 0
    if not no_purge:
        purged = _delete_branch_vectors(ldb_id)

    _record_history(branches_dir, "delete", branch=name, vectors_purged=purged)
    console.print(f"[green]Deleted branch '{name}'[/green]  vectors purged: [cyan]{purged:,}[/cyan]")

# ---------------------------------------------------------------------------
# Pipeline runner + ambient pointer
# ---------------------------------------------------------------------------

def _run_branch_pipeline(
    project_path: Path,
    project_id: str,
    branch_name: str,
    branch_data: dict,
) -> None:
    """Run the LC pipeline for a specific branch and update manifest metadata."""
    from linear_canal.pipeline import run_pipeline_for_branch

    branches_dir    = _branches_dir(project_path, project_id)
    branch_data_dir = get_branch_data_dir(project_path, project_id, branch_name)
    branch_data_dir.mkdir(parents=True, exist_ok=True)

    run_pipeline_for_branch(
        target_dir=project_path,
        project_id=project_id,
        branch_name=branch_name,
        branch_data_dir=branch_data_dir,
        include_patterns=branch_data.get("include", []),
        exclude_patterns=branch_data.get("exclude", []),
        ctx_scope=branch_data.get("ctx_scope"),
    )
    _post_pipeline_update(branches_dir, branch_name, project_id, branch_data_dir)


def _update_ambient_branch_pointer(
    project_path: Path,
    project_id: str,
    ctx_dir: Path,
) -> None:
    """Rewrite LC_GENERATED blocks in .LC/ agent files to point to the new ctx dir."""
    lc_dir = project_path / ".LC"
    if not lc_dir.exists():
        return

    try:
        ctx_rel = ctx_dir.relative_to(project_path)
    except ValueError:
        ctx_rel = ctx_dir
    ctx_path_str = str(ctx_rel).replace("\\", "/") + "/"

    old_pattern = re.compile(
        r"\.linear_canal_data/" + re.escape(project_id) + r"(?:/branches/[^/]+)?/ctx/"
    )

    agent_files = [
        "CLAUDE.md", "GEMINI.md", "AGENTS.md", ".cursorrules",
        "ANTIGRAVITY.md", "GLM.md", "WINDSURF.md",
        ".github/copilot-instructions.md",
    ]
    for fname in agent_files:
        fpath = lc_dir / fname
        if not fpath.exists():
            continue
        text = fpath.read_text(encoding="utf-8")
        updated = old_pattern.sub(ctx_path_str, text)
        if updated != text:
            fpath.write_text(updated, encoding="utf-8")


# ---------------------------------------------------------------------------
# New commands: show, diff, merge, reindex, rename, log
# ---------------------------------------------------------------------------

@branch_cmd.command("show")
@click.argument("name")
@click.option("--project", "-p", type=click.Path(exists=True, file_okay=False),
              default=".", help="Project path")
def branch_show(name: str, project: str) -> None:
    """Show detailed information and live LanceDB stats for a branch."""
    project_path, project_id, branches_dir = _resolve_project(project)
    manifest = _load_manifest(branches_dir)
    bdata     = _require_branch(manifest, name)
    active    = manifest.get("active", "full")
    ldb_id    = _branch_lancedb_id(project_id, name)

    focus_colours = {
        "full": "white", "architecture": "yellow", "ui": "cyan",
        "backend": "blue", "security": "red", "custom": "magenta",
    }
    focus = bdata.get("focus", "custom")
    fcol  = focus_colours.get(focus, "white")

    active_tag = " [bold green](active)[/bold green]" if name == active else ""
    console.print()
    console.print(Panel(
        f"[bold bright_cyan]{name}[/bold bright_cyan]{active_tag}",
        border_style="bright_cyan", 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(bdata.get("created_at")))
    meta.add_row("Last indexed", _fmt_dt(bdata.get("last_indexed")))
    meta.add_row("LanceDB ID",   f"[dim]{ldb_id}[/dim]")
    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)

    # Live LanceDB stats
    console.print()
    console.print("  [dim]Querying LanceDB...[/dim]", end="\r")
    vec_count = _fetch_vector_count(ldb_id)
    vec_str   = f"[cyan]{vec_count:,}[/cyan]" if vec_count >= 0 else "[dim]unavailable[/dim]"
    console.print(f"  [dim]Vectors in LanceDB:[/dim]  {vec_str}           ")

    # CTX files on disk
    ctx_dir = (
        project_path / ".linear_canal_data" / project_id / "ctx"
        if name == "full"
        else branches_dir / name / "ctx"
    )
    if ctx_dir.exists():
        ctx_files = sorted(ctx_dir.glob("*.ctx"))
        console.print(f"  [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]")
    else:
        console.print("  [dim]CTX directory not found - branch not yet indexed.[/dim]")

    # Recent history for this branch
    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()
        console.print("  [dim]History:[/dim]")
        for h in history[-8:]:
            action   = h.get("action", "?")
            at       = _fmt_dt(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]")


@branch_cmd.command("diff")
@click.argument("branch_a")
@click.argument("branch_b")
@click.option("--project", "-p", type=click.Path(exists=True, file_okay=False),
              default=".", help="Project path")
def branch_diff(branch_a: str, branch_b: str, project: str) -> None:
    """Compare ctx coverage and scope between two context branches.

    \b
    Shows which ctx file types each branch has, include/exclude patterns,
    and live LanceDB vector count for each branch.

    \b
    Examples:
        linear-canal branch diff ui backend
        linear-canal branch diff full ui
    """
    project_path, project_id, branches_dir = _resolve_project(project)
    manifest = _load_manifest(branches_dir)

    da  = _require_branch(manifest, branch_a)
    db_ = _require_branch(manifest, branch_b)

    focus_colours = {
        "full": "white", "architecture": "yellow", "ui": "cyan",
        "backend": "blue", "security": "red", "custom": "magenta",
    }
    col_a = focus_colours.get(da.get("focus", "custom"), "white")
    col_b = focus_colours.get(db_.get("focus", "custom"), "white")

    def _ctx_set(name: str) -> set[str]:
        ctx_dir = (
            project_path / ".linear_canal_data" / project_id / "ctx"
            if name == "full"
            else branches_dir / 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

    console.print("  [dim]Querying LanceDB...[/dim]", end="\r")
    vec_a = _fetch_vector_count(_branch_lancedb_id(project_id, branch_a))
    vec_b = _fetch_vector_count(_branch_lancedb_id(project_id, branch_b))

    console.print()
    console.print(Panel(
        f"[bold]Branch Diff[/bold]   "
        f"[{col_a}]{branch_a}[/{col_a}]  [dim]vs[/dim]  [{col_b}]{branch_b}[/{col_b}]",
        border_style="dim", padding=(0, 2),
    ))

    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=28)
    tbl.add_column(f"[{col_b}]{branch_b}[/{col_b}]", min_width=28)

    def _scope(s) -> str:
        return ", ".join(s) if s else "[dim]all ctx types[/dim]"

    def _pats(p) -> str:
        return " ".join(p) if p else "[dim]all files[/dim]"

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

    tbl.add_row("Focus",     f"[{col_a}]{da.get('focus','custom')}[/{col_a}]",
                             f"[{col_b}]{db_.get('focus','custom')}[/{col_b}]")
    tbl.add_row("Include",   _pats(da.get("include", [])), _pats(db_.get("include", [])))
    tbl.add_row("Exclude",   _pats(da.get("exclude", [])), _pats(db_.get("exclude", [])))
    tbl.add_row("CTX scope", _scope(da.get("ctx_scope")),  _scope(db_.get("ctx_scope")))
    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"\n  [{col_a}]Only in {branch_a} ({len(only_a)}):[/{col_a}]")
        for f in sorted(only_a):
            console.print(f"  [green]+[/green]  [dim]{f}[/dim]")
    if only_b:
        console.print(f"\n  [{col_b}]Only in {branch_b} ({len(only_b)}):[/{col_b}]")
        for f in sorted(only_b):
            console.print(f"  [blue]+[/blue]  [dim]{f}[/dim]")
    if shared:
        console.print(f"\n  [dim]Shared ({len(shared)}): {', '.join(sorted(shared)[:8])}"
                      + ("..." if len(shared) > 8 else "") + "[/dim]")

    console.print()
    console.print(
        f"  [dim]To merge:  [cyan]linear-canal branch merge {branch_a} {branch_b}[/cyan][/dim]"
    )


@branch_cmd.command("merge")
@click.argument("branch_a")
@click.argument("branch_b")
@click.option("--name", "output_name", default=None,
              help="Name for the merged branch (default: <a>+<b>)")
@click.option("--skip-index", is_flag=True, default=False,
              help="Create the branch without re-indexing")
@click.option("--project", "-p", type=click.Path(exists=True, file_okay=False),
              default=".", help="Project path")
def branch_merge(
    branch_a: str,
    branch_b: str,
    output_name: Optional[str],
    skip_index: bool,
    project: str,
) -> None:
    """Merge two context branches into a new branch.

    The merged branch gets the union of both branches' include patterns,
    the intersection of their exclude lists (maximising coverage), the
    union of ctx_scope, and ctx files seeded from both source branches.

    \b
    Examples:
        linear-canal branch merge ui backend
        linear-canal branch merge ui backend --name=fullstack
    """
    project_path, project_id, branches_dir = _resolve_project(project)
    _require_initialized(project_path, project_id)
    manifest = _load_manifest(branches_dir)

    da  = _require_branch(manifest, branch_a)
    db_ = _require_branch(manifest, branch_b)

    new_name = (output_name or f"{branch_a}+{branch_b}").replace(" ", "_").lower()
    if not re.match(r"^[a-zA-Z0-9_+.-]+$", new_name):
        console.print("[red]Invalid merged branch name.[/red]")
        raise SystemExit(1)

    if new_name in manifest.get("branches", {}):
        console.print(f"[yellow]Branch '{new_name}' already exists.[/yellow]")
        return

    # Union includes, intersection excludes (keeps maximal coverage)
    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 []
    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))

    now = _now_iso()
    branch_data: dict = {
        "name":           new_name,
        "focus":          "custom",
        "include":        merged_include,
        "exclude":        merged_exclude,
        "ctx_scope":      merged_scope,
        "description":    f"Merged: {branch_a} + {branch_b}",
        "created_at":     now,
        "updated_at":     now,
        "parent_branch":  branch_a,
        "merge_parents":  [branch_a, branch_b],
        "last_indexed":   None,
        "vector_count":   0,
        "files_indexed":  0,
    }
    manifest.setdefault("branches", {})[new_name] = branch_data
    _save_manifest(branches_dir, manifest)

    console.print(
        f"[green]Created merged branch '[bold]{new_name}[/bold]'[/green]\n"
        f"  [dim]Include: {merged_include or 'all'}[/dim]\n"
        f"  [dim]Exclude: {merged_exclude or 'none'}[/dim]\n"
        f"  [dim]CTX scope: {merged_scope or 'all'}[/dim]"
    )

    # Seed ctx files from both source branches (A wins on conflict)
    merged_data_dir = get_branch_data_dir(project_path, project_id, new_name)
    merged_ctx_dir  = merged_data_dir / "ctx"
    merged_ctx_dir.mkdir(parents=True, exist_ok=True)

    def _seed_ctx(src_branch: str) -> None:
        src_dir = (
            project_path / ".linear_canal_data" / project_id / "ctx"
            if src_branch == "full"
            else branches_dir / src_branch / "ctx"
        )
        if src_dir.exists():
            for f in src_dir.glob("*.ctx"):
                dst = merged_ctx_dir / f.name
                if not dst.exists():
                    shutil.copy2(f, dst)

    _seed_ctx(branch_b)   # B first so A can overwrite
    _seed_ctx(branch_a)
    seeded = len(list(merged_ctx_dir.glob("*.ctx")))
    console.print(f"  [dim]Seeded {seeded} ctx files from source branches[/dim]")

    _record_history(branches_dir, "merge", branches=[branch_a, branch_b], into=new_name)

    if not skip_index:
        console.print(f"[dim]Indexing merged branch '{new_name}'...[/dim]")
        _run_branch_pipeline(project_path, project_id, new_name, branch_data)
        console.print(f"[green]Merged branch '{new_name}' indexed and ready.[/green]")
    else:
        console.print(
            f"[dim]Skipped indexing. Run  linear-canal branch reindex {new_name}  when ready.[/dim]"
        )

    console.print(
        f"Switch to it:  [bold cyan]linear-canal branch checkout {new_name}[/bold cyan]"
    )


@branch_cmd.command("reindex")
@click.argument("name")
@click.option("--project", "-p", type=click.Path(exists=True, file_okay=False),
              default=".", help="Project path")
def branch_reindex(name: str, project: str) -> None:
    """Re-run the index pipeline for an existing branch.

    Use this to refresh a branch's LanceDB vectors and ctx files after
    files in the branch scope have changed.
    """
    project_path, project_id, branches_dir = _resolve_project(project)
    _require_initialized(project_path, project_id)
    manifest = _load_manifest(branches_dir)

    if name == "full":
        console.print(
            "[yellow]To re-index the full branch run:[/yellow]\n"
            "  [bold cyan]linear-canal setup[/bold cyan]"
        )
        return

    bdata = _require_branch(manifest, name)
    console.print(f"[dim]Re-indexing branch '[bold]{name}[/bold]'...[/dim]")
    _run_branch_pipeline(project_path, project_id, name, bdata)

    ldb_id    = _branch_lancedb_id(project_id, name)
    vec_count = _fetch_vector_count(ldb_id)
    _record_history(branches_dir, "reindex", branch=name, vector_count=vec_count)

    vec_str = f"[cyan]{vec_count:,}[/cyan]" if vec_count >= 0 else "[dim]unavailable[/dim]"
    console.print(f"[green]Branch '{name}' re-indexed.[/green]  Vectors: {vec_str}")


@branch_cmd.command("rename")
@click.argument("old_name")
@click.argument("new_name")
@click.option("--project", "-p", type=click.Path(exists=True, file_okay=False),
              default=".", help="Project path")
def branch_rename(old_name: str, new_name: str, project: str) -> None:
    """Rename a context branch and update its LanceDB vector namespace."""
    if old_name == "full":
        console.print("[red]Cannot rename the 'full' branch.[/red]")
        raise SystemExit(1)
    if not re.match(r"^[a-zA-Z0-9_-]+$", new_name):
        console.print("[red]New name may only contain letters, digits, hyphens, underscores.[/red]")
        raise SystemExit(1)

    project_path, project_id, branches_dir = _resolve_project(project)
    manifest = _load_manifest(branches_dir)

    if old_name not in manifest.get("branches", {}):
        console.print(f"[red]Branch '{old_name}' not found.[/red]")
        raise SystemExit(1)
    if new_name in manifest.get("branches", {}):
        console.print(f"[red]Branch '{new_name}' already exists.[/red]")
        raise SystemExit(1)

    bdata         = manifest["branches"].pop(old_name)
    bdata["name"] = new_name
    bdata["updated_at"] = _now_iso()
    manifest["branches"][new_name] = bdata

    if manifest.get("active") == old_name:
        manifest["active"] = new_name
    _save_manifest(branches_dir, manifest)

    # Rename directory on disk
    old_dir = branches_dir / old_name
    new_dir = branches_dir / new_name
    if old_dir.exists():
        old_dir.rename(new_dir)

    # Update LanceDB vector namespace
    old_ldb = _branch_lancedb_id(project_id, old_name)
    new_ldb = _branch_lancedb_id(project_id, new_name)
    console.print(f"  [dim]Updating LanceDB: {old_ldb} -> {new_ldb}[/dim]")
    updated = _rename_branch_vectors(old_ldb, new_ldb)

    # Update ambient pointers if this was the active branch
    if manifest.get("active") == new_name:
        ctx_dir = get_branch_ctx_dir(project_path, project_id, new_name)
        _update_ambient_branch_pointer(project_path, project_id, ctx_dir)

    _record_history(branches_dir, "rename", old=old_name, branch=new_name, vectors_updated=updated)
    console.print(
        f"[green]Renamed '[bold]{old_name}[/bold]' -> '[bold]{new_name}[/bold]'[/green]  "
        f"[dim]({updated:,} LanceDB rows updated)[/dim]"
    )


@branch_cmd.command("log")
@click.option("--project", "-p", type=click.Path(exists=True, file_okay=False),
              default=".", help="Project path")
@click.option("-n", "limit", default=20, show_default=True,
              help="Number of history entries to show")
@click.option("--branch", "filter_branch", default=None,
              help="Filter by branch name")
def branch_log(project: str, limit: int, filter_branch: Optional[str]) -> None:
    """Show the history of branch operations.

    \b
    Examples:
        linear-canal branch log
        linear-canal branch log -n 50
        linear-canal branch log --branch ui
    """
    project_path, project_id, branches_dir = _resolve_project(project)
    manifest = _load_manifest(branches_dir)
    history  = manifest.get("history", [])

    if filter_branch:
        history = [
            h for h in history
            if h.get("branch") == filter_branch
            or filter_branch in (h.get("branches") or [])
            or h.get("into") == filter_branch
            or h.get("old") == filter_branch
        ]

    history = list(reversed(history[-limit:]))

    if not history:
        console.print("[dim]No branch history recorded yet.[/dim]")
        return

    action_colours = {
        "create":   "green",
        "checkout": "cyan",
        "merge":    "magenta",
        "delete":   "red",
        "reindex":  "blue",
        "rename":   "yellow",
    }

    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=30)

    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(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")
        if h.get("vectors_updated"):parts.append(f"{h['vectors_updated']:,} rows updated")
        tbl.add_row(at, f"[{col}]{action}[/{col}]", branch, "  ".join(parts))

    console.print(tbl)
