"""Algorithm performance benchmark runner.

Measures SDK graph algorithms at different scales. Each algorithm follows
the pattern: to_networkx() → NetworkX function → DataFrame. Two bottlenecks
are timed separately: (a) graph loading into NetworkX, (b) algorithm execution.

Graph loading is measured once, then algorithms run against the cached
NetworkX graph to isolate algorithm-only performance.

Supports `--db age` (default, AGE+NetworkX) and `--db neo4j` (Neo4j GDS).
"""

from __future__ import annotations

import json
import random
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

import numpy as np

from benchmarks.config import (
    AGE_GRAPH,
    ALGO_SCALE_CONFIG,
    ALGO_SCALE_SKIP,
    SCALES,
)
from benchmarks.harness import BenchmarkResult, BenchmarkRun, collect_system_info, run_workload

# ---------------------------------------------------------------------------
# Algorithm catalog
# ---------------------------------------------------------------------------

@dataclass(frozen=True)
class AlgoSpec:
    """Specification for a single algorithm benchmark."""

    algo_id: str
    family: str
    name: str
    builder: Callable[..., Callable[[], Any]]
    needs_pairs: bool = False
    needs_undirected: bool = False
    dep_check: Callable[[], bool] | None = None


def _check_node2vec() -> bool:
    try:
        import node2vec  # noqa: F401
        return True
    except ImportError:
        return False


def _check_leiden() -> bool:
    try:
        import igraph  # noqa: F401
        import leidenalg  # noqa: F401
        return True
    except ImportError:
        return False


# Builder functions — each takes a pre-loaded NetworkX graph and returns
# a zero-arg callable for run_workload().

def _build_centrality(G: Any, measure: str) -> Callable[[], Any]:
    import networkx as nx
    fn_map = {
        "degree": nx.degree_centrality,
        "betweenness": nx.betweenness_centrality,
        "closeness": nx.closeness_centrality,
        "eigenvector": nx.eigenvector_centrality_numpy,
        "harmonic": nx.harmonic_centrality,
    }
    algo_fn = fn_map[measure]
    return lambda: algo_fn(G)


def _build_betweenness_approx(G: Any) -> Callable[[], Any]:
    import networkx as nx
    k = min(100, len(G))
    return lambda: nx.betweenness_centrality(G, k=k)


def _build_eigenvector_safe(G: Any) -> Callable[[], Any]:
    """Eigenvector centrality with fallback for disconnected graphs."""
    import networkx as nx
    def _run() -> dict[Any, float]:
        try:
            return nx.eigenvector_centrality_numpy(G)
        except nx.NetworkXException:
            # Disconnected graph — run on largest strongly-connected component
            # (eigenvector_centrality_numpy requires strong connectivity)
            largest_scc = max(nx.strongly_connected_components(G), key=len)
            sub_g = G.subgraph(largest_scc)
            try:
                sub_scores = nx.eigenvector_centrality_numpy(sub_g)
            except nx.NetworkXException:
                # Last resort: iterative method with max_iter
                sub_scores = nx.eigenvector_centrality(
                    sub_g, max_iter=1000, tol=1e-6
                )
            return {n: sub_scores.get(n, 0.0) for n in G.nodes()}
    return _run


def _build_community(G_undirected: Any, method: str) -> Callable[[], Any]:
    import networkx as nx
    if method == "louvain":
        return lambda: nx.community.louvain_communities(G_undirected)
    elif method == "label_propagation":
        return lambda: list(nx.community.label_propagation_communities(G_undirected))
    elif method == "greedy_modularity":
        return lambda: list(nx.community.greedy_modularity_communities(G_undirected))
    elif method == "leiden":
        import igraph as ig
        import leidenalg
        ig_graph = ig.Graph.from_networkx(G_undirected)
        return lambda: leidenalg.find_partition(ig_graph, leidenalg.ModularityVertexPartition)
    raise ValueError(f"Unknown community method: {method}")


def _build_pagerank(G: Any) -> Callable[[], Any]:
    import networkx as nx
    return lambda: nx.pagerank(G)


def _build_shortest_path(
    G: Any, pairs: list[tuple[int, int]]
) -> Callable[[], Any]:
    import networkx as nx
    def _run() -> list[Any]:
        results = []
        for src, tgt in pairs[:10]:
            try:
                results.append(nx.shortest_path(G, source=src, target=tgt))
            except (nx.NetworkXNoPath, nx.NodeNotFound):
                results.append([])
        return results
    return _run


def _build_dijkstra(
    G: Any, pairs: list[tuple[int, int]]
) -> Callable[[], Any]:
    import networkx as nx
    def _run() -> list[Any]:
        results = []
        for src, tgt in pairs[:10]:
            try:
                results.append(nx.single_source_dijkstra(G, src, tgt, weight="weight"))
            except (nx.NetworkXNoPath, nx.NodeNotFound):
                results.append((float("inf"), []))
        return results
    return _run


def _build_all_shortest_paths(
    G: Any, pairs: list[tuple[int, int]]
) -> Callable[[], Any]:
    import networkx as nx
    def _run() -> list[Any]:
        results = []
        for src, tgt in pairs[:10]:
            try:
                results.append(list(nx.all_shortest_paths(G, source=src, target=tgt)))
            except (nx.NetworkXNoPath, nx.NodeNotFound):
                results.append([])
        return results
    return _run


def _build_jaccard(
    G_undirected: Any, pairs: list[tuple[int, int]]
) -> Callable[[], Any]:
    import networkx as nx
    return lambda: list(nx.jaccard_coefficient(G_undirected, ebunch=pairs))


def _build_adamic_adar(
    G_undirected: Any, pairs: list[tuple[int, int]]
) -> Callable[[], Any]:
    import networkx as nx
    return lambda: list(nx.adamic_adar_index(G_undirected, ebunch=pairs))


def _build_common_neighbors(
    G_undirected: Any, pairs: list[tuple[int, int]]
) -> Callable[[], Any]:
    import networkx as nx
    def _run() -> list[int]:
        results = []
        for u, v in pairs[:10]:
            try:
                results.append(len(list(nx.common_neighbors(G_undirected, u, v))))
            except nx.NetworkXError:
                results.append(0)
        return results
    return _run


def _build_preferential_attachment(
    G_undirected: Any, pairs: list[tuple[int, int]]
) -> Callable[[], Any]:
    import networkx as nx
    return lambda: list(nx.preferential_attachment(G_undirected, ebunch=pairs))


def _build_resource_allocation(
    G_undirected: Any, pairs: list[tuple[int, int]]
) -> Callable[[], Any]:
    import networkx as nx
    return lambda: list(nx.resource_allocation_index(G_undirected, ebunch=pairs))


# ---------------------------------------------------------------------------
# igraph C-backend builder functions
# ---------------------------------------------------------------------------

def _build_centrality_igraph(G_ig: Any, measure: str) -> Callable[[], Any]:
    if measure == "degree":
        return lambda: G_ig.degree()
    elif measure == "betweenness":
        return lambda: G_ig.betweenness(directed=True)
    elif measure == "closeness":
        return lambda: G_ig.closeness(mode="all")
    elif measure == "eigenvector":
        G_u = G_ig.as_undirected()
        return lambda: G_u.eigenvector_centrality()
    elif measure == "harmonic":
        return lambda: G_ig.harmonic_centrality(mode="all")
    raise ValueError(f"Unknown igraph centrality measure: {measure}")


def _build_community_igraph(G_ig_undirected: Any, method: str) -> Callable[[], Any]:
    if method == "louvain":
        return lambda: G_ig_undirected.community_multilevel()
    elif method == "label_propagation":
        return lambda: G_ig_undirected.community_label_propagation()
    elif method == "greedy_modularity":
        return lambda: G_ig_undirected.community_fastgreedy()
    elif method == "leiden":
        import leidenalg
        return lambda: leidenalg.find_partition(G_ig_undirected, leidenalg.ModularityVertexPartition)
    raise ValueError(f"Unknown igraph community method: {method}")


def _build_pagerank_igraph(G_ig: Any) -> Callable[[], Any]:
    return lambda: G_ig.pagerank(directed=True)


def _check_igraph() -> bool:
    try:
        import igraph  # noqa: F401
        return True
    except ImportError:
        return False


def _check_networkit() -> bool:
    try:
        import networkit  # noqa: F401
        return True
    except ImportError:
        return False


_HAS_IGRAPH = _check_igraph()
_HAS_NETWORKIT = _check_networkit()


# ---------------------------------------------------------------------------
# networkit C++ multi-threaded builder functions
# ---------------------------------------------------------------------------

def _build_centrality_networkit(G_nk: Any, measure: str) -> Callable[[], Any]:
    import networkit as nk
    if measure == "closeness":
        algo = nk.centrality.Closeness(G_nk, True, nk.centrality.ClosenessVariant.Generalized)
        return lambda: algo.run().scores()
    elif measure == "harmonic":
        algo = nk.centrality.HarmonicCloseness(G_nk)
        return lambda: algo.run().scores()
    raise ValueError(f"Unknown networkit centrality measure: {measure}")


def _build_node2vec(G: Any, scale_name: str) -> Callable[[], Any]:
    from node2vec import Node2Vec
    if scale_name in ("100k", "1m"):
        nw, wl, dims = 10, 10, 32
    else:
        nw, wl, dims = 200, 30, 32
    def _run() -> Any:
        model = Node2Vec(
            G, dimensions=dims, walk_length=wl, num_walks=nw,
            p=1.0, q=1.0, workers=1, quiet=True,
        ).fit(window=10, min_count=1, batch_words=4)
        return model
    return _run


def build_algo_catalog() -> list[AlgoSpec]:
    """Return the full algorithm catalog."""
    return [
        # Centrality (operate on directed graph) — igraph C backend when available
        AlgoSpec("C01_degree", "centrality", "Degree Centrality",
                 lambda G, Gu, **kw: (
                     _build_centrality_igraph(kw["G_ig"], "degree")
                     if kw.get("G_ig") is not None
                     else _build_centrality(G, "degree")
                 )),
        AlgoSpec("C02_betweenness", "centrality", "Betweenness Centrality",
                 lambda G, Gu, **kw: (
                     _build_centrality_igraph(kw["G_ig"], "betweenness")
                     if kw.get("G_ig") is not None
                     else _build_centrality(G, "betweenness")
                 )),
        AlgoSpec("C02a_betweenness_approx", "centrality", "Betweenness (approx k=100)",
                 lambda G, Gu, **kw: _build_betweenness_approx(G)),
        AlgoSpec("C03_closeness", "centrality", "Closeness Centrality",
                 lambda G, Gu, **kw: (
                     _build_centrality_networkit(kw["G_nk"], "closeness")
                     if kw.get("G_nk") is not None
                     else _build_centrality_igraph(kw["G_ig"], "closeness")
                     if kw.get("G_ig") is not None
                     else _build_centrality(G, "closeness")
                 )),
        AlgoSpec("C04_eigenvector", "centrality", "Eigenvector Centrality",
                 lambda G, Gu, **kw: (
                     _build_centrality_igraph(kw["G_ig"], "eigenvector")
                     if kw.get("G_ig") is not None
                     else _build_eigenvector_safe(G)
                 )),
        AlgoSpec("C05_harmonic", "centrality", "Harmonic Centrality",
                 lambda G, Gu, **kw: (
                     _build_centrality_networkit(kw["G_nk"], "harmonic")
                     if kw.get("G_nk") is not None
                     else _build_centrality_igraph(kw["G_ig"], "harmonic")
                     if kw.get("G_ig") is not None
                     else _build_centrality(G, "harmonic")
                 )),
        # Community (operate on undirected graph) — igraph C backend when available
        AlgoSpec("M01_louvain", "community", "Louvain",
                 lambda G, Gu, **kw: (
                     _build_community_igraph(kw["G_ig_undirected"], "louvain")
                     if kw.get("G_ig_undirected") is not None
                     else _build_community(Gu, "louvain")
                 ), needs_undirected=True),
        AlgoSpec("M02_label_propagation", "community", "Label Propagation",
                 lambda G, Gu, **kw: (
                     _build_community_igraph(kw["G_ig_undirected"], "label_propagation")
                     if kw.get("G_ig_undirected") is not None
                     else _build_community(Gu, "label_propagation")
                 ), needs_undirected=True),
        AlgoSpec("M03_greedy_modularity", "community", "Greedy Modularity",
                 lambda G, Gu, **kw: (
                     _build_community_igraph(kw["G_ig_undirected"], "greedy_modularity")
                     if kw.get("G_ig_undirected") is not None
                     else _build_community(Gu, "greedy_modularity")
                 ), needs_undirected=True),
        AlgoSpec("M04_leiden", "community", "Leiden",
                 lambda G, Gu, **kw: (
                     _build_community_igraph(kw["G_ig_undirected"], "leiden")
                     if kw.get("G_ig_undirected") is not None
                     else _build_community(Gu, "leiden")
                 ), needs_undirected=True, dep_check=_check_leiden),
        # Paths (operate on directed graph) — stay on NetworkX (already WIN)
        AlgoSpec("P01_shortest_path", "paths", "Shortest Path",
                 lambda G, Gu, **kw: _build_shortest_path(G, kw["pairs"]),
                 needs_pairs=True),
        AlgoSpec("P02_dijkstra", "paths", "Dijkstra",
                 lambda G, Gu, **kw: _build_dijkstra(G, kw["pairs"]),
                 needs_pairs=True),
        AlgoSpec("P03_all_shortest_paths", "paths", "All Shortest Paths",
                 lambda G, Gu, **kw: _build_all_shortest_paths(G, kw["pairs"]),
                 needs_pairs=True),
        # Ranking (directed graph) — igraph C backend when available
        AlgoSpec("R01_pagerank", "ranking", "PageRank",
                 lambda G, Gu, **kw: (
                     _build_pagerank_igraph(kw["G_ig"])
                     if kw.get("G_ig") is not None
                     else _build_pagerank(G)
                 )),
        # Similarity (undirected graph) — stay on NetworkX (already WIN)
        AlgoSpec("S01_jaccard", "similarity", "Jaccard Similarity",
                 lambda G, Gu, **kw: _build_jaccard(Gu, kw["pairs"]),
                 needs_pairs=True, needs_undirected=True),
        AlgoSpec("S02_adamic_adar", "similarity", "Adamic-Adar",
                 lambda G, Gu, **kw: _build_adamic_adar(Gu, kw["pairs"]),
                 needs_pairs=True, needs_undirected=True),
        AlgoSpec("S03_common_neighbors", "similarity", "Common Neighbors",
                 lambda G, Gu, **kw: _build_common_neighbors(Gu, kw["pairs"]),
                 needs_pairs=True, needs_undirected=True),
        # Link Prediction (undirected graph) — stay on NetworkX (already WIN)
        AlgoSpec("L01_preferential_attachment", "link_prediction", "Preferential Attachment",
                 lambda G, Gu, **kw: _build_preferential_attachment(Gu, kw["pairs"]),
                 needs_pairs=True, needs_undirected=True),
        AlgoSpec("L02_resource_allocation", "link_prediction", "Resource Allocation",
                 lambda G, Gu, **kw: _build_resource_allocation(Gu, kw["pairs"]),
                 needs_pairs=True, needs_undirected=True),
        # Embeddings (directed graph) — requires NX graph for node2vec package
        AlgoSpec("E01_node2vec", "embeddings", "Node2Vec",
                 lambda G, Gu, **kw: _build_node2vec(G, kw["scale_name"]),
                 dep_check=_check_node2vec),
    ]


# ---------------------------------------------------------------------------
# Node pair selection
# ---------------------------------------------------------------------------

def _select_node_pairs(
    G: Any, count: int = 50, seed: int = 42
) -> list[tuple[int, int]]:
    """Select random node pairs from the graph for pairwise algorithms."""
    node_ids = list(G.nodes())
    if len(node_ids) < 2:
        return []

    rng = random.Random(seed)
    pairs: list[tuple[int, int]] = []
    for _ in range(count):
        a, b = rng.sample(node_ids, 2)
        pairs.append((a, b))
    return pairs


# ---------------------------------------------------------------------------
# Graph load timing
# ---------------------------------------------------------------------------

def _measure_graph_load(
    driver: Any, graph: str, warmup: int = 1, iterations: int = 3
) -> tuple[list[float], Any]:
    """Measure to_networkx_sql() load time separately."""
    from piggie._integrations.networkx import to_networkx_sql

    return run_workload(
        lambda: to_networkx_sql(driver, graph),
        warmup=warmup,
        iterations=iterations,
    )


# ---------------------------------------------------------------------------
# Neo4j GDS benchmark runner
# ---------------------------------------------------------------------------

def _run_neo4j_algo_benchmark(
    scale_name: str,
    *,
    families: list[str] | None = None,
    algo_ids: list[str] | None = None,
    results_dir: str = "benchmarks/results",
    bench_round: int | None = None,
) -> Path:
    """Run algorithm benchmarks against Neo4j GDS."""
    from benchmarks.adapters.neo4j_gds_adapter import Neo4jGdsAdapter
    from benchmarks.datagen.synthetic import generate

    scale = SCALES[scale_name]
    config = ALGO_SCALE_CONFIG[scale_name]

    print("=== Algorithm Benchmark (Neo4j GDS) ===")
    print(f"Scale: {scale.name} ({scale.num_nodes:,} nodes, {scale.num_edges:,} edges)")
    print(f"Warmup: {config['warmup']}, Iterations: {config['iterations']}")
    print()

    # Generate and load data
    print("Generating synthetic data...", flush=True)
    data = generate(scale)
    print(f"  {len(data.nodes):,} nodes, {len(data.edges):,} edges")

    adapter = Neo4jGdsAdapter()
    try:
        adapter.connect()
        adapter.setup_schema(scale.vec_dims)
        print("Loading data into Neo4j...", flush=True)
        adapter.load_nodes(data.nodes)
        adapter.load_edges(data.edges)
        print("  Data loaded.")
        print()

        # Measure graph projection time (equivalent to to_networkx)
        print("Measuring GDS graph projection time...", flush=True)
        project_times, _ = run_workload(
            lambda: adapter.project_graph(),
            warmup=1,
            iterations=3,
        )
        project_p50 = float(np.percentile(project_times, 50))
        print(f"  Graph projection p50: {project_p50:.1f}ms")
        print()

        # Ensure graph is projected for algorithm runs
        adapter.project_graph()

        # Select node pairs from Neo4j data (bench_id-based)
        print("Selecting node pairs...", flush=True)
        rng = random.Random(42)
        max_id = scale.num_nodes
        pairs = [(rng.randint(0, max_id - 1), rng.randint(0, max_id - 1)) for _ in range(50)]
        print(f"  {len(pairs)} pairs selected")
        print()

        # GDS algorithm mapping
        gds_algos: list[tuple[str, str, str, Callable[[], Any]]] = [
            ("C01_degree", "centrality", "Degree Centrality",
             lambda: adapter.degree_centrality()),
            ("C02_betweenness", "centrality", "Betweenness Centrality",
             lambda: adapter.betweenness_centrality()),
            ("C03_closeness", "centrality", "Closeness Centrality",
             lambda: adapter.closeness_centrality()),
            ("C04_eigenvector", "centrality", "Eigenvector Centrality",
             lambda: adapter.eigenvector_centrality()),
            ("C05_harmonic", "centrality", "Harmonic Centrality",
             lambda: adapter.harmonic_centrality()),
            ("M01_louvain", "community", "Louvain",
             lambda: adapter.louvain()),
            ("M02_label_propagation", "community", "Label Propagation",
             lambda: adapter.label_propagation()),
            ("M03_greedy_modularity", "community", "Modularity Optimization",
             lambda: adapter.modularity_optimization()),
            ("M04_leiden", "community", "Leiden",
             lambda: adapter.leiden()),
            ("P01_shortest_path", "paths", "Dijkstra Shortest Path",
             lambda: adapter.shortest_paths(pairs)),
            ("P02_dijkstra", "paths", "Dijkstra (weighted)",
             lambda: adapter.shortest_paths(pairs)),
            ("R01_pagerank", "ranking", "PageRank",
             lambda: adapter.pagerank()),
            ("S01_jaccard", "similarity", "Jaccard Similarity",
             lambda: adapter.jaccard(pairs)),
            ("S02_adamic_adar", "similarity", "Adamic-Adar",
             lambda: adapter.adamic_adar(pairs)),
            ("S03_common_neighbors", "similarity", "Common Neighbors",
             lambda: adapter.common_neighbors(pairs)),
            ("L01_preferential_attachment", "link_prediction", "Preferential Attachment",
             lambda: adapter.preferential_attachment(pairs)),
            ("L02_resource_allocation", "link_prediction", "Resource Allocation",
             lambda: adapter.resource_allocation(pairs)),
            ("E01_node2vec", "embeddings", "Node2Vec",
             lambda: adapter.node2vec()),
        ]

        # Filter
        if families:
            gds_algos = [a for a in gds_algos if a[1] in families]
        if algo_ids:
            gds_algos = [a for a in gds_algos if a[0] in algo_ids]

        run = BenchmarkRun(
            timestamp=datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S"),
            system_info={
                **collect_system_info(),
                "graph_load_p50_ms": project_p50,
                "graph_load_times_ms": project_times,
            },
            scale=scale.name,
        )

        for algo_id, _family, name, fn in gds_algos:
            print(f"  [{algo_id}] {name} ...", end=" ", flush=True)

            result = BenchmarkResult(
                db="neo4j_gds",
                workload_id=algo_id,
                scale=scale.name,
            )

            try:
                times, val = run_workload(
                    fn,
                    warmup=config["warmup"],
                    iterations=config["iterations"],
                )
                result.times_ms = times

                if hasattr(val, "__len__"):
                    result.result_value = f"{len(val)} items"
                elif hasattr(val, "shape"):
                    result.result_value = f"shape {val.shape}"
                else:
                    result.result_value = str(val)[:100]

                print(f"{result.p50:.1f}ms (p50)")

            except Exception as exc:
                result.error = f"{type(exc).__name__}: {exc}"
                print(f"ERROR: {exc}")

            run.results.append(result)

        # Save results
        results_path = Path(results_dir)
        results_path.mkdir(parents=True, exist_ok=True)
        filename = f"algo_neo4j_{scale.name}_{run.timestamp}.json"
        filepath = results_path / filename

        out: dict[str, Any] = {
            "type": "algorithm",
            "db": "neo4j_gds",
            "timestamp": run.timestamp,
            "system_info": run.system_info,
            "scale": run.scale,
            "results": [r.to_dict() for r in run.results],
        }
        if bench_round is not None:
            out["round"] = bench_round
        filepath.write_text(json.dumps(out, indent=2, default=str))
        print()
        print(f"Results saved to {filepath}")
        return filepath

    finally:
        try:
            adapter.teardown()
        except Exception:
            pass
        try:
            adapter.close()
        except Exception:
            pass


# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------

def run_algo_benchmark(
    scale_name: str,
    *,
    db: str = "age",
    families: list[str] | None = None,
    algo_ids: list[str] | None = None,
    results_dir: str = "benchmarks/results",
    bench_round: int | None = None,
) -> Path:
    """Run algorithm benchmarks at the given scale.

    Args:
        scale_name: Scale preset key ("10k", "100k", "1m").
        db: Database to benchmark — "age" (default) or "neo4j".
        families: Filter to specific algorithm families. None = all.
        algo_ids: Filter to specific algorithm IDs. None = all.
        results_dir: Directory for JSON results.
        bench_round: Optional round number for tracking optimization progress.

    Returns:
        Path to the saved results file.
    """
    if db == "neo4j":
        return _run_neo4j_algo_benchmark(
            scale_name,
            families=families,
            algo_ids=algo_ids,
            results_dir=results_dir,
            bench_round=bench_round,
        )

    from benchmarks.adapters.age_adapter import AgeAdapter
    from benchmarks.datagen.synthetic import generate

    scale = SCALES[scale_name]
    config = ALGO_SCALE_CONFIG[scale_name]
    skip_set = ALGO_SCALE_SKIP.get(scale_name, set())

    print("=== Algorithm Benchmark ===")
    print(f"Scale: {scale.name} ({scale.num_nodes:,} nodes, {scale.num_edges:,} edges)")
    print(f"Warmup: {config['warmup']}, Iterations: {config['iterations']}")
    print()

    # Generate and load data
    print("Generating synthetic data...", flush=True)
    data = generate(scale)
    print(f"  {len(data.nodes):,} nodes, {len(data.edges):,} edges")

    adapter = AgeAdapter()
    try:
        adapter.connect()
        adapter.setup_schema(scale.vec_dims)
        print("Loading data into AGE...", flush=True)
        adapter.load_nodes(data.nodes)
        adapter.load_edges(data.edges)
        print("  Data loaded.")
        print()

        driver = adapter.db

        # Measure graph load time (separate phase) — now uses fast SQL path
        print("Measuring to_networkx_sql() load time...", flush=True)
        load_times, G = _measure_graph_load(driver, AGE_GRAPH)
        load_p50 = float(np.percentile(load_times, 50))
        print(f"  Graph load p50: {load_p50:.1f}ms")
        print(f"  Graph: {G.number_of_nodes():,} nodes, {G.number_of_edges():,} edges")
        print()

        # Pre-compute undirected version for community/similarity/link_prediction
        G_undirected = G.to_undirected()

        # Load igraph graph (C backend for centrality/community/ranking)
        G_ig = None
        G_ig_undirected = None
        try:
            from piggie._integrations.igraph_integration import to_igraph_sql
            G_ig, _ig_id_map = to_igraph_sql(driver, AGE_GRAPH)
            G_ig_undirected = G_ig.as_undirected()
            print(f"  igraph backend: available ({G_ig.vcount()} nodes, {G_ig.ecount()} edges)")
        except ImportError:
            print("  igraph backend: not available (falling back to NetworkX)")

        # Load networkit graph (multi-threaded C++ for closeness/harmonic)
        G_nk = None
        if G_ig_undirected is not None and _HAS_NETWORKIT:
            import networkit as nk
            G_nk = nk.Graph(G_ig_undirected.vcount(), weighted=False, directed=False)
            for e in G_ig_undirected.get_edgelist():
                if not G_nk.hasEdge(e[0], e[1]):
                    G_nk.addEdge(e[0], e[1])
            print(f"  networkit backend: available ({G_nk.numberOfNodes()} nodes, {G_nk.numberOfEdges()} edges)")
        elif not _HAS_NETWORKIT:
            print("  networkit backend: not available")

        # Select node pairs from pre-loaded graph
        print("Selecting node pairs...", flush=True)
        pairs = _select_node_pairs(G)
        print(f"  {len(pairs)} pairs selected")
        print()

        # Build algorithm catalog and filter
        catalog = build_algo_catalog()
        if families:
            catalog = [a for a in catalog if a.family in families]
        if algo_ids:
            catalog = [a for a in catalog if a.algo_id in algo_ids]

        # Build the run
        run = BenchmarkRun(
            timestamp=datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S"),
            system_info={
                **collect_system_info(),
                "graph_load_p50_ms": load_p50,
                "graph_load_times_ms": load_times,
                "graph_nodes": G.number_of_nodes(),
                "graph_edges": G.number_of_edges(),
            },
            scale=scale.name,
        )

        # Run each algorithm against pre-loaded graph
        for spec in catalog:
            print(f"  [{spec.algo_id}] {spec.name} ...", end=" ", flush=True)

            result = BenchmarkResult(
                db="age",
                workload_id=spec.algo_id,
                scale=scale.name,
            )

            # Check scale-aware skips
            if spec.algo_id in skip_set:
                result.skipped = True
                result.skip_reason = f"Skipped at {scale_name} scale (prohibitive complexity)"
                print("SKIPPED (complexity)")
                run.results.append(result)
                continue

            # Check dependency
            if spec.dep_check and not spec.dep_check():
                result.skipped = True
                result.skip_reason = "Missing dependency"
                print("SKIPPED (missing dep)")
                run.results.append(result)
                continue

            try:
                # Build the callable (uses pre-loaded graph)
                builder_kwargs: dict[str, Any] = {
                    "scale_name": scale_name,
                    "G_ig": G_ig,
                    "G_ig_undirected": G_ig_undirected,
                    "G_nk": G_nk,
                }
                if spec.needs_pairs:
                    builder_kwargs["pairs"] = pairs
                fn = spec.builder(G, G_undirected, **builder_kwargs)

                # Run with harness
                times, val = run_workload(
                    fn,
                    warmup=config["warmup"],
                    iterations=config["iterations"],
                )
                result.times_ms = times

                # Summarize result value
                if hasattr(val, "__len__"):
                    result.result_value = f"{len(val)} items"
                elif hasattr(val, "shape"):
                    result.result_value = f"shape {val.shape}"
                else:
                    result.result_value = str(val)[:100]

                print(f"{result.p50:.1f}ms (p50)")

            except Exception as exc:
                result.error = f"{type(exc).__name__}: {exc}"
                print(f"ERROR: {exc}")

            run.results.append(result)

        # Save results
        results_path = Path(results_dir)
        results_path.mkdir(parents=True, exist_ok=True)
        filename = f"algo_{scale.name}_{run.timestamp}.json"
        filepath = results_path / filename

        out: dict[str, Any] = {
            "type": "algorithm",
            "db": "age",
            "timestamp": run.timestamp,
            "system_info": run.system_info,
            "scale": run.scale,
            "results": [r.to_dict() for r in run.results],
        }
        if bench_round is not None:
            out["round"] = bench_round
        filepath.write_text(json.dumps(out, indent=2, default=str))
        print()
        print(f"Results saved to {filepath}")
        return filepath

    finally:
        try:
            adapter.teardown()
        except Exception:
            pass
        try:
            adapter.close()
        except Exception:
            pass
