"""Analysis, visualization, and complex computation data access functions."""
from __future__ import annotations

import json
import re
from collections import Counter, defaultdict
from typing import TypedDict

import numpy as np
from sklearn.cluster import AgglomerativeClustering
from sklearn.manifold import TSNE
from sklearn.preprocessing import normalize as sk_normalize

from ietf_analyzer.config import Config
from ietf_analyzer.db import Database

SAFETY_CATEGORIES = {"AI safety/alignment", "Agent identity/auth", "Policy/governance"}
CAPABILITY_CATEGORIES = {"A2A protocols", "Agent discovery/reg", "Autonomous netops",
                         "Data formats/interop", "Human-agent interaction", "Model serving/inference"}
from webui.data._shared import _cached, _extract_month
from webui.data.drafts import get_draft_detail

_ARCH_LAYERS = [
    {"id": "transport", "label": "Transport & Networking", "order": 0,
     "keywords": {"transport", "network", "routing", "tunnel", "packet", "flow", "traffic", "qos", "sdwan", "mpls", "bgp", "ospf", "segment", "srv6", "quic", "http", "grpc", "mqtt", "yang", "snmp", "netconf", "restconf"}},
    {"id": "identity", "label": "Identity & Trust", "order": 1,
     "keywords": {"identity", "auth", "authentication", "authorization", "credential", "certificate", "trust", "attestation", "oauth", "token", "signing", "verification", "verifiable", "did", "vc", "pki", "spiffe", "acl"}},
    {"id": "discovery", "label": "Discovery & Registration", "order": 2,
     "keywords": {"discovery", "registration", "registry", "catalog", "advertisement", "announce", "capability", "service", "lookup", "resolution", "dns", "directory"}},
    {"id": "communication", "label": "Agent Communication", "order": 3,
     "keywords": {"a2a", "agent", "communication", "message", "messaging", "protocol", "exchange", "negotiation", "handshake", "session", "dialogue", "interaction", "mcp", "interop"}},
    {"id": "coordination", "label": "Task & Coordination", "order": 4,
     "keywords": {"task", "delegation", "orchestration", "workflow", "planning", "coordination", "consensus", "collaboration", "multi-agent", "swarm", "composition", "scheduling"}},
    {"id": "intelligence", "label": "AI & Inference", "order": 5,
     "keywords": {"model", "inference", "learning", "training", "ml", "neural", "llm", "embedding", "reasoning", "decision", "prediction", "classification", "generative", "rag", "fine-tuning"}},
    {"id": "safety", "label": "Safety & Governance", "order": 6,
     "keywords": {"safety", "ethical", "governance", "policy", "audit", "explainability", "transparency", "accountability", "bias", "fairness", "compliance", "regulation", "risk", "shutdown", "alignment", "adversarial", "privacy", "consent"}},
    {"id": "application", "label": "Application Domains", "order": 7,
     "keywords": {"healthcare", "autonomous", "vehicle", "robotics", "iot", "digital twin", "supply chain", "finance", "manufacturing", "energy", "smart", "edge", "cloud", "sensing"}},
]

_LAYER_KEYWORDS = {l["id"]: l["keywords"] for l in _ARCH_LAYERS}


class TimelineData(TypedDict):
    """Monthly category counts from :func:`get_timeline_data`."""
    months: list[str]
    series: dict[str, list[int]]
    categories: list[str]

class SimilarityGraphStats(TypedDict):
    """Stats sub-dict in similarity graph."""
    node_count: int
    edge_count: int
    avg_similarity: float

class SimilarityGraph(TypedDict):
    """Draft similarity network from :func:`get_similarity_graph`."""
    nodes: list[dict]
    edges: list[dict]
    stats: SimilarityGraphStats

class CitationGraphStats(TypedDict):
    """Stats sub-dict in citation graph."""
    node_count: int
    edge_count: int
    rfc_count: int
    draft_count: int

class CitationGraph(TypedDict):
    """Citation network from :func:`get_citation_graph`."""
    nodes: list[dict]
    edges: list[dict]
    stats: CitationGraphStats

class MonitorCost(TypedDict):
    """Cost sub-dict in monitor status."""
    input_tokens: int
    output_tokens: int
    estimated_usd: float

class MonitorPipeline(TypedDict):
    """Pipeline sub-dict in monitor status."""
    total_drafts: int
    rated: int
    embedded: int
    with_ideas: int
    idea_total: int
    gap_count: int

class MonitorStatus(TypedDict):
    """Monitor status from :func:`get_monitor_status`."""
    last_run: dict | None
    runs: list[dict]
    unprocessed: dict[str, int]
    total_runs: int
    pipeline: MonitorPipeline
    cost: MonitorCost

def get_ideas_by_type(db: Database) -> dict:
    """Return ideas grouped by type with counts."""
    all_ideas = db.all_ideas()
    type_counts = Counter(i.get("type", "other") or "other" for i in all_ideas)
    return {
        "total": len(all_ideas),
        "by_type": dict(type_counts.most_common()),
        "ideas": all_ideas,
    }

def get_idea_detail(db: Database, idea_id: int) -> dict | None:
    """Return a single idea with source draft info and similar ideas."""
    row = db.conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone()
    if not row:
        return None

    idea = {
        "id": row["id"],
        "title": row["title"],
        "description": row["description"],
        "type": row["idea_type"],
        "draft_name": row["draft_name"],
        "novelty_score": row["novelty_score"],
    }

    # Get source draft info
    draft = db.get_draft(row["draft_name"])
    if draft:
        idea["draft_title"] = draft.title
        idea["draft_date"] = draft.date

    # Get category from ratings
    rated = db.drafts_with_ratings(limit=2000)
    for d, r in rated:
        if d.name == row["draft_name"]:
            idea["categories"] = r.categories
            break

    # Find similar ideas using embeddings
    similar = []
    emb_row = db.conn.execute(
        "SELECT vector FROM idea_embeddings WHERE idea_id = ?", (idea_id,)
    ).fetchone()
    if emb_row:
        target_vec = np.frombuffer(emb_row["vector"], dtype=np.float32)
        all_embs = db.all_idea_embeddings()
        # Compute cosine similarities
        scores = []
        for other_id, other_vec in all_embs.items():
            if other_id == idea_id:
                continue
            cos_sim = float(np.dot(target_vec, other_vec) / (
                np.linalg.norm(target_vec) * np.linalg.norm(other_vec) + 1e-9))
            scores.append((other_id, cos_sim))
        scores.sort(key=lambda x: x[1], reverse=True)
        top_5 = scores[:5]

        # Fetch idea details for top 5
        if top_5:
            ids = [s[0] for s in top_5]
            sim_map = {s[0]: s[1] for s in top_5}
            placeholders = ",".join("?" * len(ids))
            sim_rows = db.conn.execute(
                f"SELECT id, title, idea_type, draft_name FROM ideas WHERE id IN ({placeholders})",
                ids,
            ).fetchall()
            sim_dict = {r["id"]: r for r in sim_rows}
            for sid, score in top_5:
                sr = sim_dict.get(sid)
                if sr:
                    similar.append({
                        "id": sr["id"],
                        "title": sr["title"],
                        "type": sr["idea_type"],
                        "draft_name": sr["draft_name"],
                        "similarity": round(score, 3),
                    })

    idea["similar"] = similar
    return idea


def get_timeline_data(db: Database) -> TimelineData:
    """Return monthly counts by category for timeline chart."""
    pairs = db.drafts_with_ratings(limit=1000)
    all_drafts = db.list_drafts(limit=1000, order_by="time ASC")
    rating_map = {d.name: r for d, r in pairs}

    month_cat: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
    for d in all_drafts:
        month = _extract_month(d.time)
        r = rating_map.get(d.name)
        if r:
            cat = r.categories[0] if r.categories else "Other"
            month_cat[month][cat] += 1

    months = sorted(month_cat.keys())
    cat_totals: Counter = Counter()
    for mc in month_cat.values():
        for c, cnt in mc.items():
            cat_totals[c] += cnt
    top_cats = [c for c, _ in cat_totals.most_common(10)]

    series = {}
    for cat in top_cats:
        series[cat] = [month_cat[m].get(cat, 0) for m in months]

    return {"months": months, "series": series, "categories": top_cats}

def get_similarity_graph(db: Database, threshold: float = 0.75) -> SimilarityGraph:
    """Return draft similarity network (cached)."""
    return _cached(f"similarity_{threshold}", lambda: _compute_similarity_graph(db, threshold))

def _compute_similarity_graph(db: Database, threshold: float = 0.75) -> SimilarityGraph:
    """Return draft similarity network for force-directed graph.

    Returns {nodes: [{name, title, category, score}],
             edges: [{source, target, similarity}],
             stats: {node_count, edge_count, avg_similarity}}
    """


    embeddings = db.all_embeddings()
    if len(embeddings) < 2:
        return {"nodes": [], "edges": [], "stats": {"node_count": 0, "edge_count": 0, "avg_similarity": 0}}

    pairs = db.drafts_with_ratings(limit=1000)
    rating_map = {d.name: r for d, r in pairs}
    draft_map = {d.name: d for d, _ in pairs}

    # Filter to drafts with both embeddings and ratings
    names = [n for n in embeddings if n in rating_map]
    if len(names) < 2:
        return {"nodes": [], "edges": [], "stats": {"node_count": 0, "edge_count": 0, "avg_similarity": 0}}

    matrix = np.array([embeddings[n] for n in names])

    # L2-normalize and compute cosine similarity
    norms = np.linalg.norm(matrix, axis=1, keepdims=True)
    norms[norms == 0] = 1.0
    normalized = matrix / norms
    sim_matrix = normalized @ normalized.T

    # Find pairs above threshold (upper triangle only)
    edges = []
    node_set = set()
    for i in range(len(names)):
        for j in range(i + 1, len(names)):
            sim = float(sim_matrix[i, j])
            if sim >= threshold:
                edges.append({"source": names[i], "target": names[j], "similarity": round(sim, 4)})
                node_set.add(names[i])
                node_set.add(names[j])

    # Build nodes from connected drafts only
    nodes = []
    for name in names:
        if name not in node_set:
            continue
        r = rating_map[name]
        d = draft_map.get(name)
        nodes.append({
            "name": name,
            "title": d.title if d else name,
            "category": r.categories[0] if r.categories else "Other",
            "score": round(r.composite_score, 2),
        })

    avg_sim = round(sum(e["similarity"] for e in edges) / max(len(edges), 1), 4)

    return {
        "nodes": nodes,
        "edges": edges,
        "stats": {"node_count": len(nodes), "edge_count": len(edges), "avg_similarity": avg_sim},
    }

def get_idea_clusters(db: Database) -> dict:
    """Cluster ideas (cached for 5 min)."""
    return _cached("idea_clusters", lambda: _compute_idea_clusters(db))

def _compute_idea_clusters(db: Database) -> dict:
    """Cluster ideas by embedding similarity, return clusters + t-SNE scatter.

    Uses Ward linkage on L2-normalized embeddings (approximates cosine) with
    a target of ~30 clusters for readable groupings.  Enriches each cluster
    with WG info and category breakdown.
    """


    embeddings = db.all_idea_embeddings()
    if not embeddings:
        return {"clusters": [], "scatter": [], "stats": {"total": 0, "clustered": 0, "num_clusters": 0}, "empty": True}

    # Exclude ideas from false-positive drafts
    fp_names = db.false_positive_names()

    # Fetch ideas with IDs for metadata lookup
    rows = db.conn.execute("SELECT id, title, description, idea_type, draft_name FROM ideas").fetchall()
    idea_map = {r["id"]: {"title": r["title"], "description": r["description"],
                           "type": r["idea_type"], "draft_name": r["draft_name"]}
                for r in rows if r["draft_name"] not in fp_names}

    # Remove FP ideas from embeddings too
    embeddings = {k: v for k, v in embeddings.items() if k in idea_map}

    # Draft -> WG and category lookup
    draft_rows = db.conn.execute('SELECT name, "group", title FROM drafts').fetchall()
    draft_wg = {r["name"]: r["group"] or "none" for r in draft_rows}
    draft_title_map = {r["name"]: r["title"] for r in draft_rows}
    rating_rows = db.conn.execute("SELECT draft_name, categories FROM ratings WHERE COALESCE(false_positive, 0) = 0").fetchall()
    draft_cats: dict[str, list[str]] = {}
    for r in rating_rows:
        try:
            draft_cats[r["draft_name"]] = json.loads(r["categories"]) if r["categories"] else []
        except (json.JSONDecodeError, TypeError):
            draft_cats[r["draft_name"]] = []

    # Build matrix from embeddings that have matching ideas
    idea_ids = [iid for iid in embeddings if iid in idea_map]
    if len(idea_ids) < 5:
        return {"clusters": [], "scatter": [], "stats": {"total": len(idea_ids), "clustered": 0, "num_clusters": 0}, "empty": True}

    matrix = np.array([embeddings[iid] for iid in idea_ids])
    matrix_norm = sk_normalize(matrix)

    # Ward clustering on normalized vectors — target ~30 clusters scaled by dataset size
    n_target = max(10, min(40, len(idea_ids) // 12))
    try:
        clustering = AgglomerativeClustering(n_clusters=n_target, linkage='ward')
        labels = clustering.fit_predict(matrix_norm)
    except Exception:
        return {"clusters": [], "scatter": [], "stats": {"total": len(idea_ids), "clustered": 0, "num_clusters": 0}, "empty": True}

    # Build cluster data
    cluster_ideas_map: dict[int, list] = defaultdict(list)
    for idx, iid in enumerate(idea_ids):
        cluster_ideas_map[labels[idx]].append(iid)

    stop = {"a", "an", "the", "of", "for", "in", "to", "and", "or", "with",
            "on", "by", "is", "as", "at", "from", "that", "this", "it",
            "based", "using", "protocol", "mechanism", "framework", "system",
            "network", "agent", "agents"}
    clusters = []
    for cid in sorted(cluster_ideas_map.keys()):
        members = cluster_ideas_map[cid]
        ideas_in_cluster = [idea_map[iid] for iid in members if iid in idea_map]
        if len(ideas_in_cluster) < 2:
            continue

        # Theme: most common significant words in titles
        words = Counter()
        for idea in ideas_in_cluster:
            for w in idea["title"].lower().split():
                w_clean = w.strip("()[].,;:-\"'")
                if len(w_clean) > 2 and w_clean not in stop:
                    words[w_clean] += 1
        top_words = [w for w, _ in words.most_common(4)]
        theme = " ".join(top_words).title() if top_words else f"Cluster {cid}"

        drafts = list({idea["draft_name"] for idea in ideas_in_cluster})

        # Enrich: WG breakdown
        wg_counts: dict[str, int] = Counter()
        cat_counts: dict[str, int] = Counter()
        for dname in drafts:
            wg = draft_wg.get(dname, "none")
            wg_counts[wg] += 1
            for cat in draft_cats.get(dname, []):
                cat_counts[cat] += 1

        wg_list = [{"wg": wg, "count": cnt} for wg, cnt in wg_counts.most_common(5)]
        cat_list = [{"cat": cat, "count": cnt} for cat, cnt in cat_counts.most_common(3)]
        cross_wg = len([w for w in wg_counts if w != "none"]) >= 2

        clusters.append({
            "id": len(clusters),
            "theme": theme,
            "size": len(ideas_in_cluster),
            "ideas": ideas_in_cluster[:20],
            "drafts": drafts,
            "wgs": wg_list,
            "categories": cat_list,
            "cross_wg": cross_wg,
            "wg_count": len(wg_counts),
        })

    clusters.sort(key=lambda c: c["size"], reverse=True)

    # Build mapping: original cluster label -> sorted index
    # Each cluster remembers which original label it came from via its member ids
    old_label_to_new: dict[int, int] = {}
    for new_idx, c in enumerate(clusters):
        c["id"] = new_idx
        # Find original label for any member of this cluster
        for old_cid, members in cluster_ideas_map.items():
            if members and members[0] in [iid for iid in members if iid in idea_map]:
                member_titles = {idea_map[m]["title"] for m in members if m in idea_map}
                c_titles = {idea["title"] for idea in c["ideas"]}
                if member_titles == c_titles or (member_titles & c_titles and len(members) == c["size"]):
                    old_label_to_new[old_cid] = new_idx
                    break

    # Fallback: build from idea_id -> label mapping
    iid_to_new: dict[int, int] = {}
    for old_cid, members in cluster_ideas_map.items():
        new_idx = old_label_to_new.get(old_cid, old_cid)
        for iid in members:
            iid_to_new[iid] = new_idx

    # t-SNE for scatter
    scatter = []
    try:
        perp = min(30, len(idea_ids) - 1)
        tsne = TSNE(n_components=2, perplexity=perp, random_state=42, max_iter=500)
        coords = tsne.fit_transform(matrix_norm)

        for idx, iid in enumerate(idea_ids):
            info = idea_map.get(iid, {})
            scatter.append({
                "x": round(float(coords[idx, 0]), 3),
                "y": round(float(coords[idx, 1]), 3),
                "cluster_id": iid_to_new.get(iid, int(labels[idx])),
                "title": info.get("title", ""),
                "draft_name": info.get("draft_name", ""),
                "wg": draft_wg.get(info.get("draft_name", ""), ""),
            })
    except Exception:
        pass

    # --- Cross-cluster links ---
    # Find pairs of clusters whose ideas are semantically related
    # Use centroid similarity + best idea-pair links
    links = []
    if len(clusters) >= 2:
        # Build cluster centroids from normalized embeddings
        cluster_centroids = {}
        cluster_member_indices: dict[int, list[int]] = defaultdict(list)
        for idx, iid in enumerate(idea_ids):
            cid = iid_to_new.get(iid, int(labels[idx]))
            cluster_member_indices[cid].append(idx)

        for cid, indices in cluster_member_indices.items():
            if indices:
                centroid = matrix_norm[indices].mean(axis=0)
                norm = np.linalg.norm(centroid)
                if norm > 0:
                    cluster_centroids[cid] = centroid / norm

        # Compute pairwise centroid similarity for all cluster pairs
        cids_sorted = sorted(cluster_centroids.keys())
        for ci_idx, ci in enumerate(cids_sorted):
            for cj in cids_sorted[ci_idx + 1:]:
                sim = float(np.dot(cluster_centroids[ci], cluster_centroids[cj]))
                if sim < 0.45:
                    continue

                # Find the best idea pair across these two clusters
                best_sim = 0.0
                best_pair = (None, None)
                # Sample up to 20 ideas per cluster to keep it fast
                ci_members = cluster_member_indices[ci][:20]
                cj_members = cluster_member_indices[cj][:20]
                for mi in ci_members:
                    for mj in cj_members:
                        pair_sim = float(np.dot(matrix_norm[mi], matrix_norm[mj]))
                        if pair_sim > best_sim:
                            best_sim = pair_sim
                            best_pair = (idea_ids[mi], idea_ids[mj])

                if best_sim < 0.5:
                    continue

                # Get theme names
                ci_theme = next((c["theme"] for c in clusters if c["id"] == ci), f"Cluster {ci}")
                cj_theme = next((c["theme"] for c in clusters if c["id"] == cj), f"Cluster {cj}")

                idea_a = idea_map.get(best_pair[0], {})
                idea_b = idea_map.get(best_pair[1], {})

                links.append({
                    "source": ci,
                    "target": cj,
                    "source_theme": ci_theme,
                    "target_theme": cj_theme,
                    "similarity": round(sim, 3),
                    "best_pair_sim": round(best_sim, 3),
                    "idea_a": idea_a.get("title", ""),
                    "idea_a_draft": idea_a.get("draft_name", ""),
                    "idea_b": idea_b.get("title", ""),
                    "idea_b_draft": idea_b.get("draft_name", ""),
                })

        links.sort(key=lambda l: l["best_pair_sim"], reverse=True)
        links = links[:50]  # cap at top 50 links

    total = len(idea_ids)
    clustered = sum(c["size"] for c in clusters)
    return {
        "clusters": clusters,
        "scatter": scatter,
        "links": links,
        "stats": {"total": total, "clustered": clustered, "num_clusters": len(clusters)},
        "empty": False,
    }

def get_monitor_status(db: Database) -> MonitorStatus:
    """Return monitoring status data for dashboard."""
    runs = db.get_monitor_runs(limit=20)
    last = runs[0] if runs else None
    total_drafts = db.count_drafts()
    rated_count = len(db.drafts_with_ratings(limit=10000))
    unrated = len(db.unrated_drafts(limit=9999))
    unembedded = len(db.drafts_without_embeddings(limit=9999))
    embedded_count = total_drafts - unembedded
    no_ideas = len(db.drafts_without_ideas(limit=9999))
    ideas_count = total_drafts - no_ideas
    idea_total = db.idea_count()
    gap_count = len(db.all_gaps())
    input_tok, output_tok = db.total_tokens_used()

    # Estimate cost (Sonnet pricing: $3/M input, $15/M output)
    est_cost = (input_tok * 3.0 / 1_000_000) + (output_tok * 15.0 / 1_000_000)

    return {
        "last_run": last,
        "runs": runs,
        "unprocessed": {"unrated": unrated, "unembedded": unembedded, "no_ideas": no_ideas},
        "total_runs": len(runs),
        "pipeline": {
            "total_drafts": total_drafts,
            "rated": rated_count,
            "embedded": embedded_count,
            "with_ideas": ideas_count,
            "idea_total": idea_total,
            "gap_count": gap_count,
        },
        "cost": {
            "input_tokens": input_tok,
            "output_tokens": output_tok,
            "estimated_usd": round(est_cost, 2),
        },
    }

def get_citation_graph(db: Database, min_refs: int = 2) -> CitationGraph:
    """Return citation graph (cached for 5 min)."""
    return _cached(f"citation_graph_{min_refs}", lambda: _compute_citation_graph(db, min_refs))

def _compute_citation_graph(db: Database, min_refs: int = 2) -> CitationGraph:
    """Return citation network data for force-directed graph.

    Returns {nodes: [{id, type, title, influence, ...}],
             edges: [{source, target}],
             stats: {node_count, edge_count, ...}}
    """
    # Get all references
    rows = db.conn.execute(
        "SELECT draft_name, ref_type, ref_id FROM draft_refs"
    ).fetchall()

    # Count in-degree for each referenced item
    in_degree: dict[str, int] = Counter()
    edges_raw = []
    for r in rows:
        ref_key = f"{r['ref_type']}:{r['ref_id']}"
        in_degree[ref_key] += 1
        edges_raw.append((r["draft_name"], ref_key))

    # Also count drafts as source nodes
    draft_out: dict[str, int] = Counter()
    for draft_name, _ in edges_raw:
        draft_out[draft_name] += 1

    # Get draft titles for labeling
    draft_rows = db.conn.execute("SELECT name, title FROM drafts").fetchall()
    draft_titles = {r["name"]: r["title"] for r in draft_rows}

    # Get rating categories for draft coloring
    rating_rows = db.conn.execute("SELECT draft_name, categories FROM ratings").fetchall()
    draft_cats = {}
    for r in rating_rows:
        try:
            cats = json.loads(r["categories"]) if r["categories"] else []
            draft_cats[r["draft_name"]] = cats[0] if cats else "Other"
        except Exception:
            draft_cats[r["draft_name"]] = "Other"

    # Filter: keep RFCs with min_refs+ references and all drafts that reference them
    top_refs = {k: v for k, v in in_degree.items() if v >= min_refs}

    # Build node set
    node_set = set()
    filtered_edges = []
    for draft_name, ref_key in edges_raw:
        if ref_key in top_refs:
            node_set.add(draft_name)
            node_set.add(ref_key)
            filtered_edges.append({"source": draft_name, "target": ref_key})

    # Limit to ~200 nodes max for readability
    if len(node_set) > 250:
        # Keep only refs with higher in-degree
        sorted_refs = sorted(top_refs.items(), key=lambda x: x[1], reverse=True)
        keep_refs = set(k for k, _ in sorted_refs[:80])
        node_set = set()
        filtered_edges = []
        for draft_name, ref_key in edges_raw:
            if ref_key in keep_refs:
                node_set.add(draft_name)
                node_set.add(ref_key)
                filtered_edges.append({"source": draft_name, "target": ref_key})

    # Build nodes
    nodes = []
    for nid in node_set:
        if ":" in nid and not nid.startswith("draft-"):
            # It's a reference node (rfc:1234, bcp:14, etc.)
            ref_type, ref_id = nid.split(":", 1)
            influence = in_degree.get(nid, 0)
            if ref_type == "rfc":
                try:
                    title = f"RFC {int(ref_id)}"
                except ValueError:
                    title = f"RFC {ref_id}"
            else:
                title = f"{ref_type.upper()} {ref_id}"
            nodes.append({
                "id": nid,
                "type": ref_type,
                "title": title,
                "influence": influence,
                "ref_id": ref_id,
            })
        else:
            # It's a draft node
            influence = in_degree.get(nid, 0) + draft_out.get(nid, 0)
            nodes.append({
                "id": nid,
                "type": "draft",
                "title": draft_titles.get(nid, nid),
                "influence": draft_out.get(nid, 0),
                "category": draft_cats.get(nid, "Other"),
            })

    # Stats
    rfc_count = sum(1 for n in nodes if n["type"] == "rfc")
    draft_count = sum(1 for n in nodes if n["type"] == "draft")

    return {
        "nodes": nodes,
        "edges": filtered_edges,
        "stats": {
            "node_count": len(nodes),
            "edge_count": len(filtered_edges),
            "rfc_count": rfc_count,
            "draft_count": draft_count,
        },
    }

def get_landscape_tsne(db: Database) -> list[dict]:
    """Compute t-SNE (cached for 5 min)."""
    return _cached("landscape_tsne", lambda: _compute_landscape_tsne(db))

def _compute_landscape_tsne(db: Database) -> list[dict]:
    """Compute t-SNE from embeddings, return [{name, title, x, y, category, score}]."""


    embeddings = db.all_embeddings()
    if len(embeddings) < 5:
        return []

    pairs = db.drafts_with_ratings(limit=1000)
    rating_map = {d.name: r for d, r in pairs}
    draft_map = {d.name: d for d, _ in pairs}

    # Filter to drafts that have both embeddings and ratings
    names = [n for n in embeddings if n in rating_map]
    if len(names) < 5:
        return []

    matrix = np.array([embeddings[n] for n in names])

    try:
        tsne = TSNE(n_components=2, perplexity=min(30, len(names) - 1),
                     random_state=42, max_iter=500)
        coords = tsne.fit_transform(matrix)
    except Exception:
        return []

    result = []
    for i, name in enumerate(names):
        r = rating_map[name]
        d = draft_map.get(name)
        result.append({
            "name": name,
            "title": d.title if d else name,
            "x": round(float(coords[i, 0]), 3),
            "y": round(float(coords[i, 1]), 3),
            "category": r.categories[0] if r.categories else "Other",
            "score": round(r.composite_score, 2),
        })
    return result

def get_comparison_data(db: Database, names: list[str]) -> dict | None:
    """Get comparison data for a list of drafts.

    Returns {
        drafts: [{name, title, abstract, rating, ideas, refs, ...}],
        shared_ideas: [{title, drafts: [name,...]}],
        unique_ideas: {name: [{title, description}]},
        shared_refs: [{type, id, drafts: [name,...]}],
        unique_refs: {name: [{type, id}]},
        similarities: [{a, b, similarity}],
        comparison_text: str | None,
    }
    """


    drafts_data = []
    all_ideas: dict[str, list[dict]] = {}
    all_refs: dict[str, list[tuple[str, str]]] = {}

    for name in names:
        detail = get_draft_detail(db, name)
        if not detail:
            continue
        drafts_data.append(detail)
        all_ideas[name] = detail.get("ideas", [])
        all_refs[name] = [(r["type"], r["id"]) for r in detail.get("refs", [])]

    if len(drafts_data) < 2:
        return None

    # Find shared vs unique ideas (by title similarity)
    idea_title_drafts: dict[str, list[str]] = {}
    for name, ideas in all_ideas.items():
        for idea in ideas:
            title_lower = idea["title"].lower().strip()
            if title_lower not in idea_title_drafts:
                idea_title_drafts[title_lower] = []
            idea_title_drafts[title_lower].append(name)

    shared_ideas = [
        {"title": title, "drafts": draft_list}
        for title, draft_list in idea_title_drafts.items()
        if len(set(draft_list)) > 1
    ]
    unique_ideas: dict[str, list[dict]] = {}
    for name, ideas in all_ideas.items():
        unique = []
        for idea in ideas:
            title_lower = idea["title"].lower().strip()
            if len(set(idea_title_drafts.get(title_lower, []))) <= 1:
                unique.append({"title": idea["title"], "description": idea.get("description", "")})
        unique_ideas[name] = unique

    # Find shared vs unique references
    ref_drafts: dict[tuple[str, str], list[str]] = {}
    for name, refs in all_refs.items():
        for ref in refs:
            if ref not in ref_drafts:
                ref_drafts[ref] = []
            ref_drafts[ref].append(name)

    shared_refs = [
        {"type": ref[0], "id": ref[1], "drafts": draft_list}
        for ref, draft_list in ref_drafts.items()
        if len(set(draft_list)) > 1
    ]
    unique_refs: dict[str, list[dict]] = {}
    for name, refs in all_refs.items():
        unique = []
        for ref in refs:
            if len(set(ref_drafts.get(ref, []))) <= 1:
                unique.append({"type": ref[0], "id": ref[1]})
        unique_refs[name] = unique

    # Pairwise embedding similarities
    embeddings = db.all_embeddings()
    similarities = []
    valid_names = [d["name"] for d in drafts_data]
    for i in range(len(valid_names)):
        for j in range(i + 1, len(valid_names)):
            a, b = valid_names[i], valid_names[j]
            if a in embeddings and b in embeddings:
                vec_a = embeddings[a]
                vec_b = embeddings[b]
                dot = np.dot(vec_a, vec_b)
                norm = np.linalg.norm(vec_a) * np.linalg.norm(vec_b)
                sim = float(dot / norm) if norm > 0 else 0.0
                similarities.append({"a": a, "b": b, "similarity": round(sim, 4)})

    return {
        "drafts": drafts_data,
        "shared_ideas": shared_ideas,
        "unique_ideas": unique_ideas,
        "shared_refs": shared_refs,
        "unique_refs": unique_refs,
        "similarities": similarities,
        "comparison_text": None,
    }

def _classify_to_layer(text: str) -> str:
    """Classify a piece of text to the best-matching architectural layer."""
    text_lower = text.lower()
    words = set(re.findall(r"[a-z][a-z0-9-]+", text_lower))
    scores: dict[str, int] = {}
    for layer_id, kws in _LAYER_KEYWORDS.items():
        scores[layer_id] = len(words & kws)
        # Also check for multi-word keywords as substrings
        for kw in kws:
            if len(kw) > 4 and kw in text_lower:
                scores[layer_id] += 1
    best = max(scores, key=lambda k: scores[k])
    return best if scores[best] > 0 else "communication"  # default

def get_architecture(db: Database) -> dict:
    """Build system-of-systems architecture from idea clusters, gaps, and source coverage."""
    return _cached("architecture", lambda: _compute_architecture(db), ttl=600)

def _compute_architecture(db: Database) -> dict:
    """Compute the architecture view.

    Returns:
        {
            "components": [...],  # architectural building blocks
            "dependencies": [...],  # edges between components
            "gaps": [...],  # gaps mapped to layers
            "layers": [...],  # layer definitions
            "source_coverage": {...},  # per-layer source coverage
            "stats": {...}
        }
    """
    # --- Gather raw data ---
    cluster_data = get_idea_clusters(db)
    clusters = cluster_data.get("clusters", [])
    links = cluster_data.get("links", [])
    all_gaps = db.all_gaps()

    # Source coverage: count drafts per source per layer
    draft_rows = db.conn.execute(
        "SELECT d.name, d.title, d.abstract, d.source, r.categories "
        "FROM drafts d LEFT JOIN ratings r ON d.name = r.draft_name "
        "WHERE COALESCE(r.false_positive, 0) = 0"
    ).fetchall()

    # Build components from idea clusters
    components = []
    cluster_to_component: dict[int, int] = {}  # cluster_id -> component index

    for cl in clusters:
        if cl["size"] < 3:
            continue  # skip tiny clusters

        # Determine layer from cluster theme + idea titles
        text_blob = cl.get("theme", "")
        for idea in cl.get("ideas", [])[:10]:
            text_blob += " " + idea.get("title", "") + " " + idea.get("description", "")
        layer = _classify_to_layer(text_blob)

        # Source coverage for this component's drafts
        draft_names = set(cl.get("drafts", []))
        sources: Counter = Counter()
        comp_drafts: list[dict] = []
        for dr in draft_rows:
            if dr["name"] in draft_names:
                sources[dr["source"] or "ietf"] += 1
                comp_drafts.append({"name": dr["name"], "title": (dr["title"] or dr["name"])[:80], "source": dr["source"] or "ietf"})

        # Idea type breakdown
        type_counts: Counter = Counter()
        for idea in cl.get("ideas", []):
            t = idea.get("type", "")
            if t:
                type_counts[t] += 1

        # Maturity: rough proxy from idea count and source diversity
        maturity = min(5, 1 + len(sources) + (1 if cl["size"] >= 10 else 0) + (1 if cl.get("cross_wg") else 0))

        comp = {
            "id": len(components),
            "cluster_id": cl["id"],
            "name": cl.get("theme", f"Component {cl['id']}"),
            "layer": layer,
            "size": cl["size"],
            "draft_count": len(draft_names),
            "drafts": comp_drafts[:20],
            "sources": dict(sources.most_common()),
            "type_breakdown": dict(type_counts.most_common(5)),
            "maturity": maturity,
            "wgs": cl.get("wgs", [])[:3],
            "top_ideas": [{"title": i["title"], "type": i.get("type", ""), "draft_name": i.get("draft_name", "")}
                          for i in cl.get("ideas", [])[:5]],
            "categories": cl.get("categories", []),
        }
        cluster_to_component[cl["id"]] = comp["id"]
        components.append(comp)

    # Build dependencies from cross-cluster links
    dependencies = []
    for link in links:
        src_comp = cluster_to_component.get(link["source"])
        tgt_comp = cluster_to_component.get(link["target"])
        if src_comp is not None and tgt_comp is not None and src_comp != tgt_comp:
            dependencies.append({
                "source": src_comp,
                "target": tgt_comp,
                "similarity": link.get("best_pair_sim", link.get("similarity", 0)),
                "idea_a": link.get("idea_a", ""),
                "idea_b": link.get("idea_b", ""),
            })

    # Map gaps to layers
    gap_items = []
    for gap in all_gaps:
        text = gap["topic"] + " " + gap.get("description", "") + " " + gap.get("category", "")
        layer = _classify_to_layer(text)
        gap_items.append({
            "id": gap["id"],
            "topic": gap["topic"],
            "description": gap["description"],
            "evidence": gap.get("evidence", ""),
            "severity": gap.get("severity", "medium"),
            "category": gap.get("category", ""),
            "layer": layer,
        })

    # Source coverage per layer
    source_coverage: dict[str, dict[str, int]] = {l["id"]: Counter() for l in _ARCH_LAYERS}
    for dr in draft_rows:
        text = (dr["title"] or "") + " " + (dr["abstract"] or "")[:200]
        layer = _classify_to_layer(text)
        source_coverage[layer][dr["source"] or "ietf"] += 1
    # Convert Counters to dicts
    source_coverage = {k: dict(v) for k, v in source_coverage.items()}

    # Layer summary stats
    layer_info = []
    for l in _ARCH_LAYERS:
        lid = l["id"]
        comp_count = sum(1 for c in components if c["layer"] == lid)
        idea_count = sum(c["size"] for c in components if c["layer"] == lid)
        gap_count = sum(1 for g in gap_items if g["layer"] == lid)
        layer_info.append({
            "id": l["id"],
            "label": l["label"],
            "order": l["order"],
            "component_count": comp_count,
            "idea_count": idea_count,
            "gap_count": gap_count,
            "coverage": source_coverage.get(lid, {}),
            "total_drafts": sum(source_coverage.get(lid, {}).values()),
        })

    return {
        "components": components,
        "dependencies": dependencies,
        "gaps": gap_items,
        "layers": layer_info,
        "stats": {
            "total_components": len(components),
            "total_dependencies": len(dependencies),
            "total_gaps": len(gap_items),
            "layers_with_gaps": len(set(g["layer"] for g in gap_items)),
        },
    }

def get_idea_analysis(db: Database) -> dict:
    """Return comprehensive idea analysis data for the idea-analysis page.

    Includes novelty distribution, type breakdown with avg novelty,
    top novel ideas, ideas-per-draft distribution, cross-tab of type x source,
    shared ideas across drafts, and idea novelty vs draft rating correlation.
    """
    from collections import Counter, defaultdict
    from difflib import SequenceMatcher

    # Fetch raw data
    all_ideas = db.conn.execute(
        """SELECT i.id, i.draft_name, i.title, i.description, i.idea_type,
                  i.novelty_score
           FROM ideas i ORDER BY i.novelty_score DESC NULLS LAST"""
    ).fetchall()
    all_ideas = [dict(r) for r in all_ideas]

    # Draft ratings lookup
    ratings_rows = db.conn.execute(
        """SELECT d.name, d.title as draft_title, d.source,
                  r.novelty AS r_novelty, r.maturity, r.overlap, r.momentum, r.relevance
           FROM drafts d LEFT JOIN ratings r ON d.name = r.draft_name"""
    ).fetchall()
    draft_info = {}
    for r in ratings_rows:
        row = dict(r)
        # Compute composite score (average of 5 dimensions)
        dims = [row.get("r_novelty"), row.get("maturity"), row.get("overlap"),
                row.get("momentum"), row.get("relevance")]
        valid = [d for d in dims if d is not None]
        row["composite_score"] = sum(valid) / len(valid) if valid else None
        draft_info[row["name"]] = row

    total = len(all_ideas)
    scored = [i for i in all_ideas if i.get("novelty_score") is not None]
    unscored = total - len(scored)
    avg_novelty = sum(i["novelty_score"] for i in scored) / len(scored) if scored else 0

    # Embedding coverage
    embed_count = db.conn.execute("SELECT COUNT(*) FROM idea_embeddings").fetchone()[0]

    # --- Novelty score distribution (histogram) ---
    novelty_dist = Counter(i["novelty_score"] for i in scored)
    novelty_histogram = {
        "labels": [1, 2, 3, 4, 5],
        "values": [novelty_dist.get(s, 0) for s in [1, 2, 3, 4, 5]],
    }

    # --- Ideas by type with counts and avg novelty ---
    type_data = defaultdict(lambda: {"count": 0, "novelty_sum": 0, "novelty_n": 0})
    for idea in all_ideas:
        t = idea.get("idea_type") or "other"
        type_data[t]["count"] += 1
        if idea.get("novelty_score") is not None:
            type_data[t]["novelty_sum"] += idea["novelty_score"]
            type_data[t]["novelty_n"] += 1

    by_type = []
    for t, d in sorted(type_data.items(), key=lambda x: x[1]["count"], reverse=True):
        avg = d["novelty_sum"] / d["novelty_n"] if d["novelty_n"] > 0 else 0
        by_type.append({"type": t, "count": d["count"], "avg_novelty": round(avg, 2)})

    type_names = [t["type"] for t in by_type]

    # --- Top 20 most novel ideas (score 4-5) ---
    top_novel = []
    for idea in all_ideas:
        if idea.get("novelty_score") and idea["novelty_score"] >= 4:
            di = draft_info.get(idea["draft_name"], {})
            top_novel.append({
                "title": idea["title"],
                "description": idea["description"],
                "type": idea.get("idea_type", "other"),
                "novelty_score": idea["novelty_score"],
                "draft_name": idea["draft_name"],
                "draft_title": di.get("draft_title", ""),
                "draft_score": di.get("composite_score"),
            })
    top_novel.sort(key=lambda x: (x["novelty_score"], x.get("draft_score") or 0), reverse=True)
    top_novel = top_novel[:20]

    # --- Ideas per draft distribution ---
    ideas_per_draft = Counter(i["draft_name"] for i in all_ideas)
    ipd_dist = Counter(ideas_per_draft.values())
    ideas_per_draft_hist = {
        "labels": sorted(ipd_dist.keys()),
        "values": [ipd_dist[k] for k in sorted(ipd_dist.keys())],
    }
    # Also top drafts by idea count
    top_idea_drafts = []
    for name, count in ideas_per_draft.most_common(10):
        di = draft_info.get(name, {})
        top_idea_drafts.append({
            "name": name,
            "draft_title": di.get("draft_title", ""),
            "idea_count": count,
            "score": di.get("composite_score"),
        })

    # --- Cross-tabulation: idea_type x source ---
    type_source = defaultdict(lambda: defaultdict(int))
    for idea in all_ideas:
        t = idea.get("idea_type") or "other"
        di = draft_info.get(idea["draft_name"], {})
        source = di.get("source", "ietf") or "ietf"
        type_source[t][source] += 1

    sources = sorted(set(
        di.get("source", "ietf") or "ietf" for di in draft_info.values()
    ))
    cross_tab = []
    for t in type_names:
        row = {"type": t}
        for s in sources:
            row[s] = type_source[t].get(s, 0)
        cross_tab.append(row)

    # --- Shared ideas across drafts ---
    idea_groups: list[dict] = []
    for idea in all_ideas:
        title_lower = idea["title"].lower().strip()
        matched = False
        for group in idea_groups:
            ratio = SequenceMatcher(None, title_lower, group["canonical"]).ratio()
            if ratio >= 0.75:
                group["ideas"].append(idea)
                group["drafts"].add(idea["draft_name"])
                matched = True
                break
        if not matched:
            idea_groups.append({
                "canonical": title_lower,
                "title": idea["title"],
                "ideas": [idea],
                "drafts": {idea["draft_name"]},
            })

    shared_ideas = []
    for g in sorted(idea_groups, key=lambda x: len(x["drafts"]), reverse=True):
        if len(g["drafts"]) < 2:
            break
        shared_ideas.append({
            "title": g["title"],
            "appearances": len(g["drafts"]),
            "drafts": sorted(g["drafts"])[:8],
            "types": list(set(i.get("idea_type", "other") for i in g["ideas"])),
        })

    # --- Scatter: draft avg idea novelty vs draft relevance ---
    draft_idea_novelty = defaultdict(list)
    for idea in scored:
        draft_idea_novelty[idea["draft_name"]].append(idea["novelty_score"])

    scatter_data = []
    for name, scores in draft_idea_novelty.items():
        di = draft_info.get(name, {})
        if di.get("relevance") is not None and di.get("composite_score") is not None:
            scatter_data.append({
                "name": name,
                "avg_idea_novelty": round(sum(scores) / len(scores), 2),
                "relevance": di["relevance"],
                "score": di["composite_score"],
                "idea_count": len(scores),
                "source": di.get("source", "ietf") or "ietf",
            })

    # --- Sunburst data: type -> novelty band ---
    sunburst_labels = []
    sunburst_parents = []
    sunburst_values = []
    # Root
    sunburst_labels.append("All Ideas")
    sunburst_parents.append("")
    sunburst_values.append(total)

    novelty_bands = {"High (4-5)": lambda s: s is not None and s >= 4,
                     "Medium (3)": lambda s: s is not None and s == 3,
                     "Low (1-2)": lambda s: s is not None and s <= 2,
                     "Unscored": lambda s: s is None}

    for t_info in by_type:
        t = t_info["type"]
        sunburst_labels.append(t)
        sunburst_parents.append("All Ideas")
        sunburst_values.append(t_info["count"])
        # Sub-bands
        type_ideas = [i for i in all_ideas if (i.get("idea_type") or "other") == t]
        for band, fn in novelty_bands.items():
            cnt = sum(1 for i in type_ideas if fn(i.get("novelty_score")))
            if cnt > 0:
                sunburst_labels.append(f"{t} - {band}")
                sunburst_parents.append(t)
                sunburst_values.append(cnt)

    return {
        "total": total,
        "scored": len(scored),
        "unscored": unscored,
        "avg_novelty": round(avg_novelty, 2),
        "embed_count": embed_count,
        "embed_pct": round(embed_count / total * 100, 1) if total > 0 else 0,
        "type_count": len(by_type),
        "novelty_histogram": novelty_histogram,
        "by_type": by_type,
        "top_novel": top_novel,
        "ideas_per_draft_hist": ideas_per_draft_hist,
        "top_idea_drafts": top_idea_drafts,
        "cross_tab": cross_tab,
        "sources": sources,
        "shared_ideas": shared_ideas,
        "scatter_data": scatter_data,
        "sunburst": {
            "labels": sunburst_labels,
            "parents": sunburst_parents,
            "values": sunburst_values,
        },
    }

def get_trends_data(db: Database) -> dict:
    """Return temporal evolution data for the /trends page.

    Returns dict with:
      - monthly_submissions: [{month, source, count}, ...]
      - monthly_ratings: [{month, novelty, maturity, overlap, momentum, relevance}, ...]
      - monthly_categories: [{month, category, count}, ...]
      - safety_ratio: [{month, safety, capability, ratio}, ...]
      - cumulative_ideas: [{month, total}, ...]
      - monthly_new_authors: [{month, count}, ...]
      - stats: {fastest_growing, newest_active}
      - monthly_table: [{month, total, sources: {}, avg_score}, ...]
    """
    conn = db.conn

    # 1. Monthly submissions by source
    rows = conn.execute("""
        SELECT substr(time, 1, 7) AS month, source, COUNT(*) AS cnt
        FROM drafts
        WHERE time IS NOT NULL AND time != ''
        GROUP BY month, source
        ORDER BY month
    """).fetchall()
    monthly_submissions = [{"month": r["month"], "source": r["source"], "count": r["cnt"]} for r in rows]

    # 2. Monthly average ratings (all 5 dimensions)
    rows = conn.execute("""
        SELECT substr(d.time, 1, 7) AS month,
               AVG(r.novelty) AS novelty, AVG(r.maturity) AS maturity,
               AVG(r.overlap) AS overlap, AVG(r.momentum) AS momentum,
               AVG(r.relevance) AS relevance,
               COUNT(*) AS cnt
        FROM drafts d
        JOIN ratings r ON d.name = r.draft_name
        WHERE d.time IS NOT NULL AND d.time != '' AND r.false_positive = 0
        GROUP BY month
        ORDER BY month
    """).fetchall()
    monthly_ratings = [{
        "month": r["month"],
        "novelty": round(r["novelty"], 2),
        "maturity": round(r["maturity"], 2),
        "overlap": round(r["overlap"], 2),
        "momentum": round(r["momentum"], 2),
        "relevance": round(r["relevance"], 2),
        "count": r["cnt"],
    } for r in rows]

    # 3. Monthly category distribution
    rows = conn.execute("""
        SELECT substr(d.time, 1, 7) AS month, r.categories
        FROM drafts d
        JOIN ratings r ON d.name = r.draft_name
        WHERE d.time IS NOT NULL AND d.time != '' AND r.false_positive = 0
    """).fetchall()
    cat_monthly: dict[str, Counter] = defaultdict(Counter)
    all_cats: Counter = Counter()
    for r in rows:
        month = r["month"]
        try:
            cats = json.loads(r["categories"]) if r["categories"] else []
        except (json.JSONDecodeError, TypeError):
            cats = []
        for c in cats:
            cat_monthly[month][c] += 1
            all_cats[c] += 1

    # Top 8 categories
    top_cats = [c for c, _ in all_cats.most_common(8)]
    months_sorted = sorted(cat_monthly.keys())
    monthly_categories = []
    for month in months_sorted:
        for cat in top_cats:
            monthly_categories.append({
                "month": month,
                "category": cat,
                "count": cat_monthly[month].get(cat, 0),
            })

    # 4. Safety ratio over time
    safety_ratio = []
    for month in months_sorted:
        safety = sum(cat_monthly[month].get(c, 0) for c in SAFETY_CATEGORIES)
        capability = sum(cat_monthly[month].get(c, 0) for c in CAPABILITY_CATEGORIES)
        ratio = round(safety / capability, 2) if capability > 0 else 0
        safety_ratio.append({
            "month": month,
            "safety": safety,
            "capability": capability,
            "ratio": ratio,
        })

    # 5. Cumulative idea count over time
    rows = conn.execute("""
        SELECT substr(d.time, 1, 7) AS month, COUNT(i.id) AS cnt
        FROM ideas i
        JOIN drafts d ON i.draft_name = d.name
        WHERE d.time IS NOT NULL AND d.time != ''
        GROUP BY month
        ORDER BY month
    """).fetchall()
    cumulative = 0
    cumulative_ideas = []
    for r in rows:
        cumulative += r["cnt"]
        cumulative_ideas.append({"month": r["month"], "total": cumulative})

    # 6. Monthly new author count (first-time contributors)
    rows = conn.execute("""
        SELECT da.person_id, MIN(substr(d.time, 1, 7)) AS first_month
        FROM draft_authors da
        JOIN drafts d ON da.draft_name = d.name
        WHERE d.time IS NOT NULL AND d.time != ''
        GROUP BY da.person_id
    """).fetchall()
    new_author_monthly: Counter = Counter()
    for r in rows:
        if r["first_month"]:
            new_author_monthly[r["first_month"]] += 1
    monthly_new_authors = [
        {"month": m, "count": new_author_monthly.get(m, 0)}
        for m in months_sorted
    ]

    # 7. Stats: fastest growing category, newest active category
    fastest_growing = ""
    newest_active = ""
    if len(months_sorted) >= 4:
        mid = len(months_sorted) // 2
        early_months = months_sorted[:mid]
        late_months = months_sorted[mid:]
        best_growth = -999
        for cat in top_cats:
            early = sum(cat_monthly[m].get(cat, 0) for m in early_months)
            late = sum(cat_monthly[m].get(cat, 0) for m in late_months)
            if early > 0:
                growth = (late - early) / early
            elif late > 0:
                growth = float("inf")
            else:
                growth = 0
            if growth > best_growth:
                best_growth = growth
                fastest_growing = cat

    # Newest active: category with latest first appearance
    cat_first_month: dict[str, str] = {}
    for month in months_sorted:
        for cat in all_cats:
            if cat not in cat_first_month and cat_monthly[month].get(cat, 0) > 0:
                cat_first_month[cat] = month
    if cat_first_month:
        newest_active = max(cat_first_month, key=lambda c: cat_first_month[c])

    # 8. Monthly breakdown table
    monthly_table = []
    for month in months_sorted:
        # Get per-source counts
        sources: dict[str, int] = {}
        total = 0
        for s in monthly_submissions:
            if s["month"] == month:
                sources[s["source"]] = s["count"]
                total += s["count"]
        # Get avg score
        avg_row = conn.execute("""
            SELECT AVG((r.novelty + r.maturity + r.overlap + r.momentum + r.relevance) / 5.0) AS avg_score
            FROM drafts d JOIN ratings r ON d.name = r.draft_name
            WHERE substr(d.time, 1, 7) = ? AND r.false_positive = 0
        """, (month,)).fetchone()
        avg_score = round(avg_row["avg_score"], 2) if avg_row and avg_row["avg_score"] else 0
        monthly_table.append({
            "month": month,
            "total": total,
            "sources": sources,
            "avg_score": avg_score,
        })

    return {
        "monthly_submissions": monthly_submissions,
        "monthly_ratings": monthly_ratings,
        "monthly_categories": monthly_categories,
        "safety_ratio": safety_ratio,
        "cumulative_ideas": cumulative_ideas,
        "monthly_new_authors": monthly_new_authors,
        "top_categories": top_cats,
        "months": months_sorted,
        "stats": {
            "fastest_growing": fastest_growing,
            "newest_active": newest_active,
        },
        "monthly_table": monthly_table,
    }

def get_complexity_data(db: Database) -> dict:
    """Return draft complexity analysis data for the /complexity page.

    For each rated draft, compute structural complexity metrics and
    correlate with rating dimensions.

    Returns dict with:
      - drafts: [{name, title, pages, author_count, citation_count, idea_count,
                   category_count, novelty, maturity, overlap, momentum, relevance,
                   score, composite_complexity}, ...]
      - correlations: {metric: {dimension: r_value}}
      - top_complex: top 10 most complex drafts
      - top_efficient: top 10 high-rating low-complexity drafts
      - stats: {avg_pages, avg_authors, avg_citations, pages_coverage_pct}
      - category_complexity: [{category, avg_pages, avg_authors, avg_citations, count}, ...]
      - source_complexity: [{source, avg_pages, avg_authors, avg_citations, count}, ...]
    """
    conn = db.conn

    # Build per-draft complexity data
    rows = conn.execute("""
        SELECT d.name, d.title, d.pages, d.source,
               r.novelty, r.maturity, r.overlap, r.momentum, r.relevance,
               r.categories,
               (r.novelty + r.maturity + r.overlap + r.momentum + r.relevance) / 5.0 AS score
        FROM drafts d
        JOIN ratings r ON d.name = r.draft_name
        WHERE r.false_positive = 0
    """).fetchall()

    # Author counts
    author_counts = db.draft_author_count_map()

    # Citation counts (outgoing refs)
    citation_counts = {}
    for row in conn.execute("""
        SELECT draft_name, COUNT(*) AS cnt FROM draft_refs GROUP BY draft_name
    """).fetchall():
        citation_counts[row["draft_name"]] = row["cnt"]

    # Idea counts
    idea_counts = {}
    for row in conn.execute("""
        SELECT draft_name, COUNT(*) AS cnt FROM ideas GROUP BY draft_name
    """).fetchall():
        idea_counts[row["draft_name"]] = row["cnt"]

    drafts_data = []
    total_with_pages = 0
    total_drafts = 0
    for r in rows:
        total_drafts += 1
        pages = r["pages"]
        if pages is not None:
            total_with_pages += 1
        try:
            cats = json.loads(r["categories"]) if r["categories"] else []
        except (json.JSONDecodeError, TypeError):
            cats = []
        ac = author_counts.get(r["name"], 0)
        cc = citation_counts.get(r["name"], 0)
        ic = idea_counts.get(r["name"], 0)
        cat_count = len(cats)
        # Composite complexity: normalize each metric to 0-1 scale and average
        # (raw values stored; composite calculated after we know max values)
        drafts_data.append({
            "name": r["name"],
            "title": r["title"],
            "pages": pages,
            "source": r["source"] or "ietf",
            "author_count": ac,
            "citation_count": cc,
            "idea_count": ic,
            "category_count": cat_count,
            "categories": cats,
            "novelty": r["novelty"],
            "maturity": r["maturity"],
            "overlap": r["overlap"],
            "momentum": r["momentum"],
            "relevance": r["relevance"],
            "score": round(r["score"], 2),
        })

    # Compute composite complexity score (normalized 0-1 each, then averaged)
    max_pages = max((d["pages"] for d in drafts_data if d["pages"] is not None), default=1) or 1
    max_authors = max((d["author_count"] for d in drafts_data), default=1) or 1
    max_citations = max((d["citation_count"] for d in drafts_data), default=1) or 1
    max_ideas = max((d["idea_count"] for d in drafts_data), default=1) or 1

    for d in drafts_data:
        p = (d["pages"] / max_pages) if d["pages"] is not None else 0.3  # default to median-ish
        a = d["author_count"] / max_authors
        c = d["citation_count"] / max_citations
        i = d["idea_count"] / max_ideas
        d["composite_complexity"] = round((p + a + c + i) / 4, 3)

    # Correlation matrix: complexity metrics vs rating dimensions
    metrics = ["pages", "author_count", "citation_count", "idea_count", "category_count"]
    dimensions = ["novelty", "maturity", "overlap", "momentum", "relevance"]

    def _pearson(xs: list[float], ys: list[float]) -> float:
        """Compute Pearson correlation coefficient."""
        n = len(xs)
        if n < 3:
            return 0.0
        mean_x = sum(xs) / n
        mean_y = sum(ys) / n
        cov = sum((x - mean_x) * (y - mean_y) for x, y in zip(xs, ys))
        std_x = (sum((x - mean_x) ** 2 for x in xs)) ** 0.5
        std_y = (sum((y - mean_y) ** 2 for y in ys)) ** 0.5
        if std_x == 0 or std_y == 0:
            return 0.0
        return round(cov / (std_x * std_y), 3)

    correlations: dict[str, dict[str, float]] = {}
    for metric in metrics:
        correlations[metric] = {}
        for dim in dimensions:
            if metric == "pages":
                # Filter to drafts with pages data
                pairs = [(d[metric], d[dim]) for d in drafts_data if d[metric] is not None]
            else:
                pairs = [(d[metric], d[dim]) for d in drafts_data]
            if len(pairs) >= 3:
                xs, ys = zip(*pairs)
                correlations[metric][dim] = _pearson(list(xs), list(ys))
            else:
                correlations[metric][dim] = 0.0

    # Top 10 most complex
    sorted_by_complexity = sorted(drafts_data, key=lambda d: d["composite_complexity"], reverse=True)
    top_complex = sorted_by_complexity[:10]

    # Top 10 efficient: high score but low complexity
    # Efficiency = score / (composite_complexity + 0.1)  (avoid div by zero)
    for d in drafts_data:
        d["efficiency"] = round(d["score"] / (d["composite_complexity"] + 0.1), 2)
    sorted_by_efficiency = sorted(drafts_data, key=lambda d: d["efficiency"], reverse=True)
    top_efficient = sorted_by_efficiency[:10]

    # Stats
    pages_vals = [d["pages"] for d in drafts_data if d["pages"] is not None]
    avg_pages = round(sum(pages_vals) / len(pages_vals), 1) if pages_vals else 0
    avg_authors = round(sum(d["author_count"] for d in drafts_data) / len(drafts_data), 1) if drafts_data else 0
    avg_citations = round(sum(d["citation_count"] for d in drafts_data) / len(drafts_data), 1) if drafts_data else 0
    pages_coverage = round(total_with_pages / total_drafts * 100, 1) if total_drafts else 0

    # Category complexity averages
    cat_data: dict[str, list[dict]] = defaultdict(list)
    for d in drafts_data:
        for cat in d.get("categories", []):
            cat_data[cat].append(d)

    category_complexity = []
    for cat, ds in sorted(cat_data.items(), key=lambda x: -len(x[1])):
        p_vals = [d["pages"] for d in ds if d["pages"] is not None]
        category_complexity.append({
            "category": cat,
            "avg_pages": round(sum(p_vals) / len(p_vals), 1) if p_vals else 0,
            "avg_authors": round(sum(d["author_count"] for d in ds) / len(ds), 1),
            "avg_citations": round(sum(d["citation_count"] for d in ds) / len(ds), 1),
            "avg_score": round(sum(d["score"] for d in ds) / len(ds), 2),
            "count": len(ds),
        })

    # Source complexity
    source_data: dict[str, list[dict]] = defaultdict(list)
    for d in drafts_data:
        source_data[d["source"]].append(d)

    source_complexity = []
    for src, ds in sorted(source_data.items(), key=lambda x: -len(x[1])):
        p_vals = [d["pages"] for d in ds if d["pages"] is not None]
        source_complexity.append({
            "source": src,
            "avg_pages": round(sum(p_vals) / len(p_vals), 1) if p_vals else 0,
            "avg_authors": round(sum(d["author_count"] for d in ds) / len(ds), 1),
            "avg_citations": round(sum(d["citation_count"] for d in ds) / len(ds), 1),
            "avg_score": round(sum(d["score"] for d in ds) / len(ds), 2),
            "count": len(ds),
        })

    return {
        "drafts": drafts_data,
        "correlations": correlations,
        "metrics": metrics,
        "dimensions": dimensions,
        "top_complex": top_complex,
        "top_efficient": top_efficient,
        "stats": {
            "avg_pages": avg_pages,
            "avg_authors": avg_authors,
            "avg_citations": avg_citations,
            "pages_coverage_pct": pages_coverage,
            "total_drafts": total_drafts,
        },
        "category_complexity": category_complexity,
        "source_complexity": source_complexity,
    }

def get_source_comparison(db: Database) -> dict:
    """Cross-source comparison: ratings, categories, counts by standards body."""
    pairs_all = db.drafts_with_ratings(limit=2000)
    # Also include false positives for completeness of source counts
    pairs_fp = db.drafts_with_ratings(limit=2000, include_false_positives=True)

    # Build per-source data
    source_stats: dict[str, dict] = {}
    source_categories: dict[str, Counter] = defaultdict(Counter)
    source_ratings: dict[str, dict[str, list]] = defaultdict(lambda: {
        "novelty": [], "maturity": [], "overlap": [], "momentum": [], "relevance": [], "scores": [],
    })
    # Collect author counts per source
    all_authors_by_source: dict[str, set] = defaultdict(set)

    for draft, rating in pairs_all:
        src = getattr(draft, "source", "ietf") or "ietf"
        source_ratings[src]["novelty"].append(rating.novelty)
        source_ratings[src]["maturity"].append(rating.maturity)
        source_ratings[src]["overlap"].append(rating.overlap)
        source_ratings[src]["momentum"].append(rating.momentum)
        source_ratings[src]["relevance"].append(rating.relevance)
        source_ratings[src]["scores"].append(round(rating.composite_score, 2))
        for cat in rating.categories:
            source_categories[src][cat] += 1

    # Get all drafts (including unrated) for draft counts
    all_drafts = db.list_drafts(limit=5000)
    source_draft_counts: Counter = Counter()
    for d in all_drafts:
        src = getattr(d, "source", "ietf") or "ietf"
        source_draft_counts[src] += 1

    # Author counts by source
    try:
        rows = db.conn.execute(
            """SELECT d.source, COUNT(DISTINCT da.person_id) as author_count
            FROM drafts d
            JOIN draft_authors da ON d.name = da.draft_name
            GROUP BY d.source"""
        ).fetchall()
        for r in rows:
            src = r["source"] or "ietf"
            all_authors_by_source[src] = r["author_count"]
    except Exception:
        pass

    # Idea counts by source
    source_idea_counts: Counter = Counter()
    try:
        rows = db.conn.execute(
            """SELECT d.source, COUNT(*) as idea_count
            FROM ideas i
            JOIN drafts d ON i.draft_name = d.name
            GROUP BY d.source"""
        ).fetchall()
        for r in rows:
            src = r["source"] or "ietf"
            source_idea_counts[src] = r["idea_count"]
    except Exception:
        pass

    # Build summary table
    all_sources = sorted(set(source_draft_counts.keys()) | set(source_ratings.keys()))
    summary = []
    for src in all_sources:
        rats = source_ratings.get(src, {"scores": []})
        cats = source_categories.get(src, Counter())
        top_cat = cats.most_common(1)[0][0] if cats else "N/A"
        avg_score = round(sum(rats["scores"]) / len(rats["scores"]), 2) if rats["scores"] else 0.0
        summary.append({
            "source": src,
            "drafts": source_draft_counts.get(src, 0),
            "rated": len(rats["scores"]),
            "authors": all_authors_by_source.get(src, 0),
            "ideas": source_idea_counts.get(src, 0),
            "avg_score": avg_score,
            "top_category": top_cat,
        })

    # Radar data: average of each dimension per source
    radar = {}
    for src, rats in source_ratings.items():
        if not rats["scores"]:
            continue
        n = len(rats["scores"])
        radar[src] = {
            "novelty": round(sum(rats["novelty"]) / n, 2),
            "maturity": round(sum(rats["maturity"]) / n, 2),
            "overlap": round(sum(rats["overlap"]) / n, 2),
            "momentum": round(sum(rats["momentum"]) / n, 2),
            "relevance": round(sum(rats["relevance"]) / n, 2),
            "count": n,
        }

    # Category distribution by source (for stacked bar / heatmap)
    all_cats = sorted({cat for cats in source_categories.values() for cat in cats})
    heatmap = {
        "sources": list(source_categories.keys()),
        "categories": all_cats,
        "values": [],
    }
    for src in heatmap["sources"]:
        row = [source_categories[src].get(cat, 0) for cat in all_cats]
        heatmap["values"].append(row)

    # Unique/shared categories analysis
    source_cat_sets = {src: set(cats.keys()) for src, cats in source_categories.items()}
    unique_cats = {}
    for src, cats in source_cat_sets.items():
        others = set()
        for s2, c2 in source_cat_sets.items():
            if s2 != src:
                others |= c2
        unique_cats[src] = sorted(cats - others)

    shared_cats = set()
    for src, cats in source_cat_sets.items():
        for s2, c2 in source_cat_sets.items():
            if s2 != src:
                shared_cats |= (cats & c2)
    shared_cats = sorted(shared_cats)

    return {
        "summary": summary,
        "radar": radar,
        "heatmap": heatmap,
        "unique_categories": unique_cats,
        "shared_categories": shared_cats,
    }

def get_citation_influence(db: Database) -> dict:
    """Return citation influence analysis data (cached for 5 min)."""
    return _cached("citation_influence", lambda: _compute_citation_influence(db))

def _compute_citation_influence(db: Database) -> dict:
    """Compute citation influence metrics from the draft_refs table.

    Returns dict with:
    - top_cited_rfcs: top 20 most-cited RFCs with citation counts and citing drafts
    - top_citing_drafts: top 20 drafts that cite the most references
    - citations_by_category: average citations per category
    - stats: total citations, unique RFCs, avg refs per draft
    - draft_network: draft-to-draft citation edges for visualization
    """
    # Get all references
    rows = db.conn.execute(
        "SELECT draft_name, ref_type, ref_id FROM draft_refs"
    ).fetchall()

    # Get draft titles and categories
    draft_rows = db.conn.execute("SELECT name, title FROM drafts").fetchall()
    draft_titles = {r["name"]: r["title"] for r in draft_rows}

    rating_rows = db.conn.execute("SELECT draft_name, categories FROM ratings").fetchall()
    draft_cats: dict[str, str] = {}
    for r in rating_rows:
        try:
            cats = json.loads(r["categories"]) if r["categories"] else []
            draft_cats[r["draft_name"]] = cats[0] if cats else "Other"
        except Exception:
            draft_cats[r["draft_name"]] = "Other"

    # Well-known RFC names
    rfc_names = {
        "2119": "Key words (MUST/SHALL/MAY)", "8174": "Key words update",
        "8259": "JSON", "7519": "JWT", "6749": "OAuth 2.0",
        "7540": "HTTP/2", "9110": "HTTP Semantics", "7525": "TLS Recommendations",
        "8446": "TLS 1.3", "3986": "URIs", "7230": "HTTP/1.1 Syntax",
        "7231": "HTTP/1.1 Semantics", "8288": "Web Linking", "6125": "TLS Server Identity",
        "7515": "JWS", "7516": "JWE", "7517": "JWK", "7518": "JWA",
        "9449": "DPoP", "6750": "OAuth Bearer", "8725": "JWT Best Practices",
        "9396": "Rich Authorization Requests", "9101": "JAR",
        "8414": "OAuth Server Metadata", "7591": "Dynamic Client Registration",
        "8705": "mTLS for OAuth", "9068": "JWT Access Tokens",
        "6819": "OAuth Threat Model", "9200": "ACE-OAuth", "9052": "COSE",
        "8392": "CWT", "7252": "CoAP",
    }

    # In-degree: how many times each RFC is cited
    rfc_citations: dict[str, list[str]] = defaultdict(list)
    draft_out_count: dict[str, int] = Counter()
    draft_to_draft_edges = []
    total_citations = 0

    for r in rows:
        draft_name = r["draft_name"]
        ref_type = r["ref_type"]
        ref_id = r["ref_id"]
        total_citations += 1
        draft_out_count[draft_name] += 1

        if ref_type == "rfc":
            rfc_citations[ref_id].append(draft_name)
        elif ref_type == "draft":
            draft_to_draft_edges.append({
                "source": draft_name,
                "target": ref_id,
                "source_title": draft_titles.get(draft_name, draft_name),
                "target_title": draft_titles.get(ref_id, ref_id),
            })

    # Top 20 most-cited RFCs
    rfc_sorted = sorted(rfc_citations.items(), key=lambda x: len(x[1]), reverse=True)
    top_cited_rfcs = []
    for ref_id, citing_drafts in rfc_sorted[:20]:
        top_cited_rfcs.append({
            "rfc_id": ref_id,
            "name": rfc_names.get(ref_id, ""),
            "count": len(citing_drafts),
            "drafts": citing_drafts[:10],  # Limit to first 10 for display
            "total_drafts": len(citing_drafts),
        })

    # Top 20 most-citing drafts (out-degree)
    draft_sorted = sorted(draft_out_count.items(), key=lambda x: x[1], reverse=True)
    top_citing_drafts = []
    for draft_name, count in draft_sorted[:20]:
        top_citing_drafts.append({
            "name": draft_name,
            "title": draft_titles.get(draft_name, draft_name),
            "count": count,
            "category": draft_cats.get(draft_name, "Other"),
        })

    # Citation density by category
    cat_totals: dict[str, int] = Counter()
    cat_counts: dict[str, int] = Counter()
    for draft_name, count in draft_out_count.items():
        cat = draft_cats.get(draft_name, "Other")
        cat_totals[cat] += count
        cat_counts[cat] += 1

    citations_by_category = []
    for cat in sorted(cat_totals.keys()):
        avg = cat_totals[cat] / cat_counts[cat] if cat_counts[cat] > 0 else 0
        citations_by_category.append({
            "category": cat,
            "total_citations": cat_totals[cat],
            "draft_count": cat_counts[cat],
            "avg_citations": round(avg, 1),
        })
    citations_by_category.sort(key=lambda x: x["avg_citations"], reverse=True)

    # PageRank-style influence: drafts that cite highly-cited RFCs
    # Simple approximation: sum of (1 / citation_count) for each RFC cited
    rfc_influence = {rid: len(drafts) for rid, drafts in rfc_citations.items()}
    draft_pagerank: dict[str, float] = Counter()
    for r in rows:
        if r["ref_type"] == "rfc" and r["ref_id"] in rfc_influence:
            # Higher score for citing highly-cited RFCs
            draft_pagerank[r["draft_name"]] += rfc_influence[r["ref_id"]]

    pagerank_sorted = sorted(draft_pagerank.items(), key=lambda x: x[1], reverse=True)
    top_pagerank = []
    for draft_name, score in pagerank_sorted[:20]:
        top_pagerank.append({
            "name": draft_name,
            "title": draft_titles.get(draft_name, draft_name),
            "score": round(score, 1),
            "category": draft_cats.get(draft_name, "Other"),
            "out_degree": draft_out_count.get(draft_name, 0),
        })

    # Stats
    unique_rfcs = len(rfc_citations)
    drafts_with_refs = len(draft_out_count)
    avg_refs = total_citations / drafts_with_refs if drafts_with_refs > 0 else 0

    return {
        "top_cited_rfcs": top_cited_rfcs,
        "top_citing_drafts": top_citing_drafts,
        "top_pagerank": top_pagerank,
        "citations_by_category": citations_by_category,
        "draft_network": draft_to_draft_edges[:200],  # Limit for perf
        "stats": {
            "total_citations": total_citations,
            "unique_rfcs": unique_rfcs,
            "drafts_with_refs": drafts_with_refs,
            "avg_refs_per_draft": round(avg_refs, 1),
        },
    }

def get_bcp_analysis(db: Database) -> dict:
    """Return BCP dependency analysis data (cached for 5 min)."""
    return _cached("bcp_analysis", lambda: _compute_bcp_analysis(db))

def _compute_bcp_analysis(db: Database) -> dict:
    """Compute BCP dependency analysis.

    Returns dict with:
    - bcps: all BCPs with citation counts and citing drafts
    - co_citation: which BCPs tend to be co-cited
    - by_category: BCP citation patterns by category
    - coverage: what % of drafts cite at least one BCP
    """
    # Get all BCP references
    bcp_rows = db.conn.execute(
        "SELECT draft_name, ref_id FROM draft_refs WHERE ref_type = 'bcp'"
    ).fetchall()

    # Get draft titles and categories
    draft_rows = db.conn.execute("SELECT name, title FROM drafts").fetchall()
    draft_titles = {r["name"]: r["title"] for r in draft_rows}
    total_drafts = len(draft_titles)

    rating_rows = db.conn.execute("SELECT draft_name, categories FROM ratings").fetchall()
    draft_cats: dict[str, str] = {}
    for r in rating_rows:
        try:
            cats = json.loads(r["categories"]) if r["categories"] else []
            draft_cats[r["draft_name"]] = cats[0] if cats else "Other"
        except Exception:
            draft_cats[r["draft_name"]] = "Other"

    # BCP citation counts
    bcp_citations: dict[str, list[str]] = defaultdict(list)
    draft_bcps: dict[str, list[str]] = defaultdict(list)

    for r in bcp_rows:
        bcp_citations[r["ref_id"]].append(r["draft_name"])
        draft_bcps[r["draft_name"]].append(r["ref_id"])

    # All BCPs with counts
    bcps = []
    for bcp_id, citing_drafts in sorted(bcp_citations.items(),
                                         key=lambda x: len(x[1]), reverse=True):
        bcps.append({
            "bcp_id": bcp_id,
            "count": len(citing_drafts),
            "drafts": citing_drafts[:10],
            "total_drafts": len(citing_drafts),
        })

    # Co-citation matrix: which BCPs appear together in the same draft
    bcp_ids = sorted(bcp_citations.keys())
    co_citation = []
    for i, bcp_a in enumerate(bcp_ids):
        drafts_a = set(bcp_citations[bcp_a])
        for j, bcp_b in enumerate(bcp_ids):
            if j <= i:
                continue
            drafts_b = set(bcp_citations[bcp_b])
            shared = len(drafts_a & drafts_b)
            if shared > 0:
                co_citation.append({
                    "bcp_a": bcp_a,
                    "bcp_b": bcp_b,
                    "count": shared,
                })

    # Heatmap data: full matrix for all BCPs (top 20 by citation count)
    top_bcp_ids = [b["bcp_id"] for b in bcps[:20]]
    heatmap_matrix = []
    for bcp_a in top_bcp_ids:
        row = []
        drafts_a = set(bcp_citations.get(bcp_a, []))
        for bcp_b in top_bcp_ids:
            drafts_b = set(bcp_citations.get(bcp_b, []))
            shared = len(drafts_a & drafts_b)
            row.append(shared)
        heatmap_matrix.append(row)

    # BCP citations by category
    cat_bcp_count: dict[str, Counter] = defaultdict(Counter)
    for draft_name, bcp_list in draft_bcps.items():
        cat = draft_cats.get(draft_name, "Other")
        for bcp_id in bcp_list:
            cat_bcp_count[cat][bcp_id] += 1

    by_category = []
    for cat in sorted(cat_bcp_count.keys()):
        top_bcps = cat_bcp_count[cat].most_common(5)
        by_category.append({
            "category": cat,
            "total_bcp_refs": sum(cat_bcp_count[cat].values()),
            "unique_bcps": len(cat_bcp_count[cat]),
            "top_bcps": [{"bcp_id": bid, "count": c} for bid, c in top_bcps],
        })
    by_category.sort(key=lambda x: x["total_bcp_refs"], reverse=True)

    # Coverage
    drafts_with_bcp = len(draft_bcps)
    coverage_pct = (drafts_with_bcp / total_drafts * 100) if total_drafts > 0 else 0

    return {
        "bcps": bcps,
        "co_citation": co_citation,
        "heatmap_labels": top_bcp_ids,
        "heatmap_matrix": heatmap_matrix,
        "by_category": by_category,
        "coverage": {
            "total_drafts": total_drafts,
            "drafts_with_bcp": drafts_with_bcp,
            "coverage_pct": round(coverage_pct, 1),
            "unique_bcps": len(bcp_citations),
            "total_bcp_refs": len(bcp_rows),
        },
    }
