"""Linear Canal — Zero-Config Pipeline runner (embedded in CLI package).

This module is the self-contained entry point that orchestrates the full
indexing pipeline:

  Scout → Graph Engine → Context Compiler → Indexer (fastembed + LanceDB global store)

All four service packages (scout, graph_engine, context_compiler, indexer)
are declared as dependencies of linear-canal-cli so they are installed
alongside the CLI. This module calls into them directly — no sys.path
gymnastics or file-system lookups required.
"""

from __future__ import annotations

import json
import logging
import os
import shutil
import sqlite3
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import Optional

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Global project registry — ~/.linear-canal/data/projects.json
# ---------------------------------------------------------------------------
_REGISTRY_PATH = Path.home() / ".linear-canal" / "data" / "projects.json"

# ---------------------------------------------------------------------------
# Project backup — ~/.linear-canal/backups/<project_id>/
# ---------------------------------------------------------------------------
_BACKUP_ROOT = Path.home() / ".linear-canal" / "backups"

# Small metadata files that are cheap to copy on every full index run
_BACKUP_META_FILES = [
    "index_status.json",
    "scores.json",
    "scout_status.json",
    "import_graph.json",
]

# Default grace period before tombstoned project data is purged
DEFAULT_GRACE_DAYS = 30


def _load_registry() -> dict:
    """Load the global project registry, returning {project_id: {...}} dict."""
    if not _REGISTRY_PATH.exists():
        return {}
    try:
        return json.loads(_REGISTRY_PATH.read_text(encoding="utf-8"))
    except Exception:
        return {}


def register_project(project_id: str, project_path: Path) -> None:
    """Register (or update) a project in the global registry.

    Called after every successful pipeline run so ``linear-canal db --global``
    can discover projects indexed from any directory.
    Clears any existing tombstone — the project is alive again.
    """
    registry = _load_registry()
    resolved = str(project_path.resolve())
    data_dir = str((project_path / ".linear_canal_data").resolve())

    existing = registry.get(project_id, {})
    registry[project_id] = {
        "project_id":   project_id,
        "project_path": resolved,
        "data_dir":     data_dir,
        "last_indexed": datetime.now(timezone.utc).isoformat(),
        # Preserve backup path from previous entry; clear tombstone fields
        "backup_path":  existing.get("backup_path"),
        "deleted_at":   None,
        "purge_after":  None,
    }

    _REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True)
    tmp = _REGISTRY_PATH.with_suffix(".tmp")
    tmp.write_text(json.dumps(registry, indent=2), encoding="utf-8")
    tmp.replace(_REGISTRY_PATH)
    logger.info("Registered project '%s' at %s in global registry", project_id, resolved)


def tombstone_project(project_id: str, grace_days: int = DEFAULT_GRACE_DAYS) -> None:
    """Mark a project as deleted in the registry.

    Called when the project directory is found to be missing.  The project's
    LanceDB vectors and backup are kept until ``purge_after`` has passed.
    """
    registry = _load_registry()
    if project_id not in registry:
        return
    entry = registry[project_id]
    if entry.get("deleted_at"):
        return  # already tombstoned

    now = datetime.now(timezone.utc)
    entry["deleted_at"] = now.isoformat()
    entry["purge_after"] = (now + timedelta(days=grace_days)).isoformat()

    _REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True)
    tmp = _REGISTRY_PATH.with_suffix(".tmp")
    tmp.write_text(json.dumps(registry, indent=2), encoding="utf-8")
    tmp.replace(_REGISTRY_PATH)
    logger.info("Tombstoned project '%s' — purge after %s days", project_id, grace_days)


def unregister_project(project_id: str) -> None:
    """Remove a project from the global registry."""
    registry = _load_registry()
    if project_id in registry:
        del registry[project_id]
        _REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True)
        tmp = _REGISTRY_PATH.with_suffix(".tmp")
        tmp.write_text(json.dumps(registry, indent=2), encoding="utf-8")
        tmp.replace(_REGISTRY_PATH)


def get_all_registered_projects() -> dict:
    """Return the full global registry dict."""
    return _load_registry()


# ---------------------------------------------------------------------------
# Backup helpers
# ---------------------------------------------------------------------------

def _backup_project_data(project_id: str, data_dir: Path) -> None:
    """Sync project ctx + lightweight metadata to the global backup store.

    Called at the end of every full ``run_pipeline()`` so the backup always
    reflects the latest successful index.  Only files that actually exist are
    copied; missing files are silently skipped.

    Backup layout::

        ~/.linear-canal/backups/<project_id>/
            ctx/               ← all .ctx files
            index_status.json
            scores.json
            scout_status.json
            import_graph.json
            index_hashes.json  ← project rows exported from SQLite
            backup_meta.json   ← when/where this backup was made
    """
    project_dir = data_dir / project_id
    if not project_dir.exists():
        return

    backup_dir = _BACKUP_ROOT / project_id
    backup_dir.mkdir(parents=True, exist_ok=True)

    # ── ctx/ directory ────────────────────────────────────────────────────
    src_ctx = project_dir / "ctx"
    dst_ctx = backup_dir / "ctx"
    if src_ctx.exists():
        if dst_ctx.exists():
            shutil.rmtree(dst_ctx)
        shutil.copytree(src_ctx, dst_ctx)

    # ── lightweight metadata files ─────────────────────────────────────────
    for fname in _BACKUP_META_FILES:
        src = project_dir / fname
        if src.exists():
            shutil.copy2(src, backup_dir / fname)

    # ── index hashes: export project rows as JSON ─────────────────────────
    # index_hashes.db is shared across projects; we export only this project's
    # rows so restoration doesn't corrupt other projects' records.
    hashes_db = data_dir / "index_hashes.db"
    if hashes_db.exists():
        try:
            conn = sqlite3.connect(f"file:{hashes_db}?mode=ro", uri=True)
            conn.row_factory = sqlite3.Row
            rows = conn.execute(
                "SELECT path, sha256, chunk_count, indexed_at "
                "FROM file_hashes WHERE project_id = ?",
                (project_id,),
            ).fetchall()
            conn.close()
            (backup_dir / "index_hashes.json").write_text(
                json.dumps([dict(r) for r in rows], indent=2), encoding="utf-8"
            )
        except Exception as exc:
            logger.debug("Hash backup skipped for '%s': %s", project_id, exc)

    # ── backup metadata ────────────────────────────────────────────────────
    (backup_dir / "backup_meta.json").write_text(
        json.dumps({
            "project_id":  project_id,
            "backed_up_at": datetime.now(timezone.utc).isoformat(),
            "source_path": str(data_dir.parent.resolve()),
        }, indent=2),
        encoding="utf-8",
    )

    # Update registry with backup path
    registry = _load_registry()
    if project_id in registry:
        registry[project_id]["backup_path"] = str(backup_dir)
        tmp = _REGISTRY_PATH.with_suffix(".tmp")
        tmp.write_text(json.dumps(registry, indent=2), encoding="utf-8")
        tmp.replace(_REGISTRY_PATH)

    logger.info("Backed up project '%s' to %s", project_id, backup_dir)


def restore_from_backup(project_id: str, target_dir: Path) -> bool:
    """Restore a project's ctx + metadata from the global backup store.

    Used automatically at the start of ``run_pipeline()`` when the project
    data dir is absent but a backup exists — this makes re-indexing after a
    restore much faster because the hash records let the indexer skip files
    whose content has not changed.

    Returns True if any files were restored.
    """
    backup_dir = _BACKUP_ROOT / project_id
    if not backup_dir.exists():
        return False

    data_dir    = target_dir / ".linear_canal_data"
    project_dir = data_dir / project_id
    data_dir.mkdir(exist_ok=True)
    project_dir.mkdir(exist_ok=True)

    restored = False

    # ── ctx/ ──────────────────────────────────────────────────────────────
    src_ctx = backup_dir / "ctx"
    dst_ctx = project_dir / "ctx"
    if src_ctx.exists() and not dst_ctx.exists():
        shutil.copytree(src_ctx, dst_ctx)
        restored = True

    # ── metadata files ─────────────────────────────────────────────────────
    for fname in _BACKUP_META_FILES:
        src = backup_dir / fname
        dst = project_dir / fname
        if src.exists() and not dst.exists():
            shutil.copy2(src, dst)
            restored = True

    # ── index hashes: import back into SQLite ──────────────────────────────
    hashes_json = backup_dir / "index_hashes.json"
    if hashes_json.exists():
        try:
            rows = json.loads(hashes_json.read_text(encoding="utf-8"))
            if rows:
                hashes_db = data_dir / "index_hashes.db"
                conn = sqlite3.connect(str(hashes_db))
                conn.execute("""
                    CREATE TABLE IF NOT EXISTS file_hashes (
                        project_id  TEXT NOT NULL,
                        path        TEXT NOT NULL,
                        sha256      TEXT NOT NULL,
                        chunk_count INTEGER DEFAULT 0,
                        indexed_at  TEXT,
                        PRIMARY KEY (project_id, path)
                    )
                """)
                conn.executemany(
                    "INSERT OR REPLACE INTO file_hashes "
                    "(project_id, path, sha256, chunk_count, indexed_at) "
                    "VALUES (?, ?, ?, ?, ?)",
                    [
                        (project_id, r["path"], r["sha256"],
                         r.get("chunk_count", 0), r.get("indexed_at"))
                        for r in rows
                    ],
                )
                conn.commit()
                conn.close()
                restored = True
        except Exception as exc:
            logger.warning("Hash restore failed for '%s': %s", project_id, exc)

    if restored:
        logger.info("Restored project '%s' from backup", project_id)
    return restored


def purge_project_data(
    project_id: str,
    lancedb_path: Optional[str] = None,
) -> dict:
    """Delete all persistent data for a tombstoned project.

    Removes:
    - LanceDB vector rows (all tables)
    - Backup files in ~/.linear-canal/backups/<project_id>/
    - Registry entry

    Returns a dict summarising what was deleted.
    """
    report: dict = {"vectors_deleted": 0, "backup_deleted": False, "registry_removed": False}

    if lancedb_path is None:
        lancedb_path = str(Path.home() / ".linear-canal" / "data" / "lancedb_global_store")

    # ── 1. LanceDB vectors ─────────────────────────────────────────────────
    try:
        import lancedb  # type: ignore[import-not-found]
        db = lancedb.connect(lancedb_path)
        for tname in db.table_names():
            try:
                tbl    = db.open_table(tname)
                before = tbl.count_rows(f"project_id = '{project_id}'")
                if before > 0:
                    tbl.delete(f"project_id = '{project_id}'")
                    report["vectors_deleted"] += before
            except Exception as exc:
                logger.warning("Could not clean LanceDB table %s: %s", tname, exc)
    except ImportError:
        pass
    except Exception as exc:
        logger.warning("LanceDB purge failed for '%s': %s", project_id, exc)

    # ── 2. Backup directory ────────────────────────────────────────────────
    backup_dir = _BACKUP_ROOT / project_id
    if backup_dir.exists():
        shutil.rmtree(backup_dir)
        report["backup_deleted"] = True

    # ── 3. Registry entry ──────────────────────────────────────────────────
    registry = _load_registry()
    if project_id in registry:
        del registry[project_id]
        tmp = _REGISTRY_PATH.with_suffix(".tmp")
        tmp.write_text(json.dumps(registry, indent=2), encoding="utf-8")
        tmp.replace(_REGISTRY_PATH)
        report["registry_removed"] = True

    logger.info("Purged project '%s': %s", project_id, report)
    return report

# ---------------------------------------------------------------------------
# Indexable file extensions (only these get embedded)
# ---------------------------------------------------------------------------
_INDEXABLE_EXTENSIONS = {
    # Backend / general source
    ".py", ".js", ".ts", ".tsx", ".jsx", ".go", ".rs", ".java",
    ".cs", ".cpp", ".c", ".h", ".hpp", ".rb", ".php", ".swift",
    ".kt", ".scala", ".r", ".lua", ".sh", ".bash", ".zsh",
    # Docs / config
    ".md", ".mdx", ".txt", ".rst",
    ".json", ".yaml", ".yml", ".toml", ".env",
    ".sql", ".graphql", ".proto",
    # Frontend: component formats
    ".vue",      # Vue SFC
    ".svelte",   # Svelte component
    # Frontend: stylesheets
    ".css", ".scss", ".sass", ".less",
    # Frontend: markup / templating
    ".html", ".htm",
    # Frontend: vector graphics (text XML — inline SVG components)
    ".svg",
}

_ALWAYS_SKIP_DIRS = {
    ".git", "node_modules", "__pycache__", ".linear_canal_data",
    ".venv", "venv", "dist", "build", ".next", "coverage",
    ".mypy_cache", ".pytest_cache", ".ruff_cache",
}

# Thresholds for size warnings
_SIZE_MEDIUM = 500     # files — show estimate
_SIZE_LARGE = 1500     # files — suggest Docker


class SizeWarning(Exception):
    """Raised when the project is very large and the caller should prompt."""
    def __init__(self, file_count: int) -> None:
        self.file_count = file_count
        super().__init__(f"Large codebase: {file_count} indexable files")


def count_project_files(target_dir: Path) -> int:
    """Fast O(n) count of indexable source files in target_dir.

    Skips common noise dirs (node_modules, .git, etc.) and only counts
    files with an indexable extension.
    """
    count = 0
    for dirpath, dirs, files in os.walk(target_dir):
        dirs[:] = [d for d in dirs if d not in _ALWAYS_SKIP_DIRS]
        for fname in files:
            if Path(fname).suffix.lower() in _INDEXABLE_EXTENSIONS:
                count += 1
    return count


def estimate_seconds(file_count: int) -> int:
    """Very rough estimate of indexing time in seconds.

    Based on ~0.15s per file on a mid-range laptop with the minimal profile.
    """
    return max(10, int(file_count * 0.15))



def run_pipeline(
    target_dir: Path,
    project_id: str,
    profile: Optional[str] = None,
    *,
    verbose: bool = False,
) -> None:
    """Run the full zero-config indexing pipeline for any codebase.

    Args:
        target_dir: Root directory of the project to index.
        project_id: Unique name for this project (used in LanceDB project_id filter).
        profile: Embedding profile — ``'standard'`` or ``'minimal'``.
                 ``None`` → auto-detected from available system RAM.
        verbose: If True, print progress to stdout.
    """

    def log(msg: str) -> None:
        if verbose:
            print(msg)
        logger.info(msg)

    # Silence verbose per-file logs from sub-packages — only WARNING+ passes through
    for _pkg in ("scout", "graph_engine", "context_compiler", "indexer"):
        logging.getLogger(_pkg).setLevel(logging.WARNING)

    # Resolve embedding config (auto-detects RAM if profile is None)
    from linear_canal.embed_config import resolve_embedding_config  # local re-export

    embedding_cfg = resolve_embedding_config(profile)

    log(f"=== Linear Canal — Zero-Config Pipeline for '{project_id}' ===")
    log(f"  Profile : {embedding_cfg.profile}")
    log(f"  Model   : {embedding_cfg.model_name} ({embedding_cfg.dimension}-dim)")
    if profile is None:
        log("  (profile auto-selected from system RAM)")

    data_dir = target_dir / ".linear_canal_data"
    data_dir.mkdir(exist_ok=True)

    # ── 0. Restore from backup if available (fast-path for re-indexed projects) ──
    backup_dir = _BACKUP_ROOT / project_id
    if backup_dir.exists() and not (data_dir / project_id / "ctx").exists():
        try:
            if restore_from_backup(project_id, target_dir):
                log(f"   Restored ctx + hashes from backup — incremental re-index")
        except Exception as exc:
            log(f"   [WARN] Backup restore failed: {exc}")

    # ── 1. Scout ─────────────────────────────────────────────────────────────
    log("1. Running Scout (scoring + manifest)...")
    try:
        from scout.main import run_scan  # type: ignore[import-not-found]

        run_scan(workspace=target_dir, data_dir=data_dir, project_id=project_id)
        log("   Scout OK")
    except ImportError:
        log("   [WARN] scout package not installed — skipping scout step")
    except Exception as exc:
        log(f"   [WARN] Scout failed: {exc}")

    # ── 2. Graph Engine ──────────────────────────────────────────────────────
    log("2. Running Graph Engine (AST + SQLite graph)...")
    try:
        from graph_engine.main import run_build  # type: ignore[import-not-found]

        run_build(workspace=target_dir, data_dir=data_dir, project_id=project_id)
        log("   Graph Engine OK")
    except ImportError:
        log("   [WARN] graph_engine package not installed — skipping graph step")
    except Exception as exc:
        log(f"   [WARN] Graph Engine failed: {exc}")

    # ── 3. Context Compiler ──────────────────────────────────────────────────
    log("3. Running Context Compiler (.ctx + ambient files)...")
    try:
        from context_compiler.main import run_compile  # type: ignore[import-not-found]

        run_compile(workspace=target_dir, data_dir=data_dir, project_id=project_id)
        log("   Context Compiler OK")
    except ImportError:
        log("   [WARN] context_compiler package not installed — skipping compile step")
    except Exception as exc:
        log(f"   [WARN] Context Compiler failed: {exc}")

    # ── 4. Indexer (fastembed ONNX + LanceDB global store) ───────────────────
    log(f"4. Running Indexer (profile={embedding_cfg.profile}, model={embedding_cfg.model_name})...")
    try:
        from indexer.main import IndexerPipeline  # type: ignore[import-not-found]
        from indexer.models import EmbeddingConfig  # type: ignore[import-not-found]
        from pathlib import Path as _Path

        lancedb_path = str(_Path.home() / ".linear-canal" / "data" / "lancedb_global_store")
        os.environ["LANCEDB_PATH"] = lancedb_path
        os.environ["LC_PROFILE"] = embedding_cfg.profile

        # Build a plain EmbeddingConfig from our resolved values
        # (EmbeddingConfig lives in the indexer package; we pass all fields explicitly)
        idx_cfg = EmbeddingConfig(
            backend=embedding_cfg.backend,
            model_name=embedding_cfg.model_name,
            dimension=embedding_cfg.dimension,
            batch_size=embedding_cfg.batch_size,
            max_tokens=embedding_cfg.max_tokens,
            profile=embedding_cfg.profile,
        )

        pipeline = IndexerPipeline(
            project_id=project_id,
            workspace_path=target_dir,
            data_dir=data_dir,
            lancedb_path=lancedb_path,
            embedding_config=idx_cfg,
        )
        pipeline.run_full_index()
        log("   Indexer OK")
    except ImportError:
        log("   [WARN] indexer package not installed — skipping embedding step")
    except Exception as exc:
        log(f"   [WARN] Indexer failed: {exc}")

    # ── 5. Register project in global registry ───────────────────────────────
    try:
        register_project(project_id, target_dir)
        log("   Registered in global project registry")
    except Exception as exc:
        log(f"   [WARN] Registry update failed: {exc}")

    # ── 6. Sync backup ────────────────────────────────────────────────────────
    try:
        _backup_project_data(project_id, data_dir)
        log("   Backup synced")
    except Exception as exc:
        log(f"   [WARN] Backup sync failed: {exc}")

    log("=== Pipeline complete ===")


def run_incremental(
    changed_files: list[str],
    target_dir: Path,
    project_id: str,
    profile: Optional[str] = None,
) -> None:
    """Run incremental pipeline for a list of changed file paths.

    Used by ``linear-canal watch`` to process only the files that changed.

    Args:
        changed_files: Relative paths (from target_dir) of changed files.
        target_dir: Project root.
        project_id: Project identifier.
        profile: Embedding profile override.
    """
    import json as _json

    from linear_canal.embed_config import resolve_embedding_config

    embedding_cfg = resolve_embedding_config(profile)
    data_dir = target_dir / ".linear_canal_data"

    # 1. Re-scout only when there are genuinely new files (not yet in the manifest).
    #    For modifications to already-tracked files, the existing scores.json is
    #    still valid — running a full 180-file scan for a README edit is wasteful.
    manifest_path = data_dir / project_id / "scores.json"
    needs_scout = True
    if manifest_path.exists():
        try:
            existing_paths = {
                e["path"]
                for e in _json.loads(manifest_path.read_text(encoding="utf-8")).get("manifest", [])
            }
            needs_scout = any(f not in existing_paths for f in changed_files)
        except Exception:
            needs_scout = True  # unreadable manifest — rescan to be safe

    if needs_scout:
        try:
            from scout.main import run_scan  # type: ignore[import-not-found]

            run_scan(workspace=target_dir, data_dir=data_dir, project_id=project_id)
        except Exception as exc:
            logger.warning("Scout incremental failed: %s", exc)
    else:
        logger.info("Incremental watch: skipping scout scan (no new files detected)")

    # 2. Incremental index — only embed the changed files
    try:
        from indexer.main import IndexerPipeline  # type: ignore[import-not-found]
        from indexer.models import EmbeddingConfig  # type: ignore[import-not-found]
        from pathlib import Path as _Path

        lancedb_path = str(_Path.home() / ".linear-canal" / "data" / "lancedb_global_store")
        os.environ["LANCEDB_PATH"] = lancedb_path
        os.environ["LC_PROFILE"] = embedding_cfg.profile
        idx_cfg = EmbeddingConfig(
            backend=embedding_cfg.backend,
            model_name=embedding_cfg.model_name,
            dimension=embedding_cfg.dimension,
            batch_size=embedding_cfg.batch_size,
            max_tokens=embedding_cfg.max_tokens,
            profile=embedding_cfg.profile,
        )
        pipeline = IndexerPipeline(
            project_id=project_id,
            workspace_path=target_dir,
            data_dir=data_dir,
            lancedb_path=lancedb_path,
            embedding_config=idx_cfg,
        )
        pipeline.run_incremental(changed_files)
    except Exception as exc:
        logger.warning("Indexer incremental failed: %s", exc)

    # 3. Recompile context ring
    try:
        from context_compiler.main import run_compile  # type: ignore[import-not-found]

        run_compile(workspace=target_dir, data_dir=data_dir, project_id=project_id)
    except Exception as exc:
        logger.warning("Context compiler incremental failed: %s", exc)


def run_pipeline_for_branch(
    target_dir: Path,
    project_id: str,
    branch_name: str,
    branch_data_dir: Path,
    include_patterns: list[str],
    exclude_patterns: list[str],
    ctx_scope: Optional[list[str]],
    profile: Optional[str] = None,
) -> None:
    """Run the LC pipeline for a specific context branch.

    All branch data is written to ``branch_data_dir``. LanceDB rows are
    filtered by ``project_id`` field namespaced as ``<project_id>__<branch_name>``
    so branch indexes never collide with the full index.

    Args:
        target_dir: Root directory of the project to index.
        project_id: Project identifier.
        branch_name: Name of the context branch.
        branch_data_dir: Directory to write branch scores.json, graph.db, ctx/.
        include_patterns: Glob patterns for files to include (empty = all).
        exclude_patterns: Glob patterns for files to exclude.
        ctx_scope: Which ctx file types to generate (None = all).
                   Values: 'ARCHITECTURE', 'DEPENDENCIES', 'INTERFACES',
                   'PATTERNS', 'RISKS', 'OWNERSHIP', 'modules'
        profile: Embedding profile ('standard', 'minimal', None = auto).
    """
    from linear_canal.embed_config import resolve_embedding_config

    embedding_cfg = resolve_embedding_config(profile)
    # Namespaced project ID keeps branch LanceDB rows separate
    branch_project_id = f"{project_id}__{branch_name}"

    logger.info("=== Branch pipeline: project=%s branch=%s ===", project_id, branch_name)
    branch_data_dir.mkdir(parents=True, exist_ok=True)
    ctx_dir = branch_data_dir / "ctx"
    ctx_dir.mkdir(parents=True, exist_ok=True)

    # data_dir parent: scout/graph-engine write to data_dir/<project_id>/
    # we pass branch_data_dir.parent so they output to branch_data_dir/<branch_project_id>/
    data_dir_parent = branch_data_dir.parent

    # ── 1. Scout ─────────────────────────────────────────────────────────────
    try:
        from scout.main import run_scan  # type: ignore[import-not-found]

        run_scan(
            workspace=target_dir,
            data_dir=data_dir_parent,
            project_id=branch_project_id,
        )
        logger.info("   Branch scout OK")
    except Exception as exc:
        logger.warning("   Branch scout failed: %s", exc)

    # ── 2. Graph Engine ───────────────────────────────────────────────────────
    try:
        from graph_engine.main import run_build  # type: ignore[import-not-found]

        run_build(
            workspace=target_dir,
            data_dir=data_dir_parent,
            project_id=branch_project_id,
        )
        logger.info("   Branch graph engine OK")
    except Exception as exc:
        logger.warning("   Branch graph engine failed: %s", exc)

    # ── 3. Context Compiler (scope-aware, custom ctx dir) ─────────────────────
    try:
        from context_compiler.main import run_compile  # type: ignore[import-not-found]

        run_compile(
            workspace=target_dir,
            data_dir=data_dir_parent,
            project_id=branch_project_id,
            ctx_scope=ctx_scope,
            ctx_dir_override=ctx_dir,
        )
        logger.info("   Branch context compiler OK")
    except Exception as exc:
        logger.warning("   Branch context compiler failed: %s", exc)

    # ── 4. Indexer (namespaced LanceDB project_id) ───────────────────────────
    try:
        from indexer.main import IndexerPipeline  # type: ignore[import-not-found]
        from indexer.models import EmbeddingConfig  # type: ignore[import-not-found]
        from pathlib import Path as _Path

        # Global LanceDB store — branch rows namespaced by project_id
        lancedb_path = str(_Path.home() / ".linear-canal" / "data" / "lancedb_global_store")
        os.environ["LANCEDB_PATH"] = lancedb_path
        os.environ["LC_PROFILE"] = embedding_cfg.profile

        idx_cfg = EmbeddingConfig(
            backend=embedding_cfg.backend,
            model_name=embedding_cfg.model_name,
            dimension=embedding_cfg.dimension,
            batch_size=embedding_cfg.batch_size,
            max_tokens=embedding_cfg.max_tokens,
            profile=embedding_cfg.profile,
        )
        pipeline = IndexerPipeline(
            project_id=branch_project_id,  # namespaced — keeps rows separate
            workspace_path=target_dir,
            data_dir=data_dir_parent,
            lancedb_path=lancedb_path,
            embedding_config=idx_cfg,
        )
        pipeline.run_full_index()
        logger.info("   Branch indexer OK")
    except Exception as exc:
        logger.warning("   Branch indexer failed: %s", exc)

    logger.info("=== Branch pipeline complete: %s ===", branch_name)
