"""Interactive and static visualizations for IETF draft analysis."""

from __future__ import annotations

import json
from collections import Counter, defaultdict
from pathlib import Path

import numpy as np

from .config import Config
from .db import Database
from .models import Draft, Rating


class Visualizer:
    """Generate publication-ready figures from the IETF draft database."""

    def __init__(self, config: Config | None = None, db: Database | None = None):
        self.config = config or Config.load()
        self.db = db or Database(self.config)
        self.output_dir = Path(self.config.data_dir) / "figures"
        self.output_dir.mkdir(parents=True, exist_ok=True)

    def _load_rated(self) -> list[tuple[Draft, Rating]]:
        return self.db.drafts_with_ratings(limit=500)

    def _rating_map(self) -> dict[str, Rating]:
        return {d.name: r for d, r in self._load_rated()}

    # ── 1. UMAP / t-SNE Scatter ────────────────────────────────────────────

    def landscape_scatter(self, method: str = "umap") -> str:
        """2D projection of draft embeddings, colored by primary category.

        Args:
            method: "umap" or "tsne"

        Returns:
            Path to saved HTML file.
        """
        import plotly.express as px

        embeddings = self.db.all_embeddings()
        if len(embeddings) < 10:
            raise RuntimeError("Need at least 10 embeddings — run `ietf embed` first.")

        names = sorted(embeddings.keys())
        matrix = np.stack([embeddings[n] for n in names])

        # Dimensionality reduction
        if method == "umap":
            try:
                from umap import UMAP
                reducer = UMAP(n_components=2, random_state=42, n_neighbors=15, min_dist=0.1)
            except ImportError:
                from sklearn.manifold import TSNE
                reducer = TSNE(n_components=2, random_state=42, perplexity=30)
                method = "tsne"
        else:
            from sklearn.manifold import TSNE
            reducer = TSNE(n_components=2, random_state=42, perplexity=30)

        coords = reducer.fit_transform(matrix)

        rating_map = self._rating_map()
        categories = []
        scores = []
        titles = []
        for n in names:
            r = rating_map.get(n)
            if r:
                categories.append(r.categories[0] if r.categories else "Other")
                scores.append(r.composite_score)
                titles.append(r.summary[:80] if r.summary else "")
            else:
                categories.append("Unrated")
                scores.append(0)
                titles.append("")

        fig = px.scatter(
            x=coords[:, 0], y=coords[:, 1],
            color=categories,
            size=[max(s, 2.5) for s in scores],
            hover_name=names,
            hover_data={"Score": scores, "Category": categories},
            custom_data=[titles],
            labels={"x": f"{method.upper()} 1", "y": f"{method.upper()} 2"},
            title=f"IETF AI/Agent Draft Landscape ({len(names)} drafts)",
            color_discrete_sequence=px.colors.qualitative.Set3,
        )
        fig.update_traces(
            hovertemplate="<b>%{hovertext}</b><br>"
            "Score: %{customdata[0]}<br>"
            "Category: %{marker.color}<br>"
            "<extra></extra>",
            marker=dict(opacity=0.8, line=dict(width=0.5, color="white")),
        )
        fig.update_layout(
            template="plotly_white",
            width=1200, height=800,
            legend=dict(title="Primary Category", font=dict(size=10)),
            font=dict(family="Inter, Arial, sans-serif"),
        )

        path = self.output_dir / f"landscape-{method}.html"
        fig.write_html(str(path), include_plotlyjs="cdn")
        return str(path)

    # ── 2. Similarity Heatmap ──────────────────────────────────────────────

    def similarity_heatmap(self) -> str:
        """Clustered heatmap of pairwise cosine similarities.

        Returns:
            Path to saved PNG file.
        """
        import matplotlib
        matplotlib.use("Agg")
        import matplotlib.pyplot as plt
        import seaborn as sns
        from scipy.cluster.hierarchy import linkage, leaves_list

        embeddings = self.db.all_embeddings()
        names = sorted(embeddings.keys())
        n = len(names)
        if n < 5:
            raise RuntimeError("Need at least 5 embeddings.")

        matrix = np.zeros((n, n), dtype=np.float32)
        vecs = [embeddings[nm] for nm in names]
        for i in range(n):
            for j in range(i, n):
                dot = np.dot(vecs[i], vecs[j])
                norm = np.linalg.norm(vecs[i]) * np.linalg.norm(vecs[j])
                sim = float(dot / norm) if norm > 0 else 0.0
                matrix[i, j] = sim
                matrix[j, i] = sim

        # Hierarchical clustering to reorder
        dist = 1.0 - matrix
        np.fill_diagonal(dist, 0)
        link = linkage(dist[np.triu_indices(n, k=1)], method="ward")
        order = leaves_list(link)
        matrix = matrix[order][:, order]
        ordered_names = [names[i] for i in order]

        # Add category color bar
        rating_map = self._rating_map()
        cats = [rating_map[nm].categories[0] if nm in rating_map and rating_map[nm].categories else "Other"
                for nm in ordered_names]
        unique_cats = sorted(set(cats))
        palette = sns.color_palette("Set3", len(unique_cats))
        cat_colors = {c: palette[i] for i, c in enumerate(unique_cats)}
        row_colors = [cat_colors[c] for c in cats]

        fig, ax = plt.subplots(figsize=(16, 14))
        g = sns.heatmap(
            matrix, ax=ax,
            cmap="YlOrRd", vmin=0.5, vmax=1.0,
            xticklabels=False, yticklabels=False,
            cbar_kws={"label": "Cosine Similarity", "shrink": 0.6},
        )
        ax.set_title(f"Pairwise Similarity — {n} IETF AI/Agent Drafts (clustered)", fontsize=14, pad=15)

        # Category color strip on left
        for i, color in enumerate(row_colors):
            ax.add_patch(plt.Rectangle((-2, i), 1.5, 1, color=color, clip_on=False))

        # Legend for categories
        from matplotlib.patches import Patch
        legend_handles = [Patch(facecolor=cat_colors[c], label=c) for c in unique_cats]
        ax.legend(handles=legend_handles, loc="upper left", bbox_to_anchor=(1.15, 1.0),
                  fontsize=7, title="Category", title_fontsize=8, frameon=True)

        plt.tight_layout()
        path = self.output_dir / "similarity-heatmap.png"
        fig.savefig(str(path), dpi=200, bbox_inches="tight")
        plt.close(fig)
        return str(path)

    # ── 3. Rating Distributions ────────────────────────────────────────────

    def score_distributions(self) -> str:
        """Violin + strip plots for each rating dimension, split by category.

        Returns:
            Path to saved PNG file.
        """
        import matplotlib
        matplotlib.use("Agg")
        import matplotlib.pyplot as plt
        import seaborn as sns

        pairs = self._load_rated()
        rows = []
        for d, r in pairs:
            cat = r.categories[0] if r.categories else "Other"
            rows.append({"Draft": d.name, "Category": cat,
                         "Novelty": r.novelty, "Maturity": r.maturity,
                         "Overlap": r.overlap, "Momentum": r.momentum,
                         "Relevance": r.relevance, "Score": r.composite_score})

        # Get top 8 categories by count for cleaner plot
        cat_counts = Counter(row["Category"] for row in rows)
        top_cats = [c for c, _ in cat_counts.most_common(8)]
        filtered = [row for row in rows if row["Category"] in top_cats]

        dims = ["Novelty", "Maturity", "Overlap", "Momentum", "Relevance"]

        fig, axes = plt.subplots(1, 5, figsize=(22, 7), sharey=True)
        palette = sns.color_palette("Set2", len(top_cats))

        for ax, dim in zip(axes, dims):
            vals_by_cat = {cat: [r[dim] for r in filtered if r["Category"] == cat] for cat in top_cats}
            positions = list(range(len(top_cats)))
            parts = ax.violinplot(
                [vals_by_cat[c] for c in top_cats],
                positions=positions, showmeans=True, showmedians=True,
            )
            for i, pc in enumerate(parts["bodies"]):
                pc.set_facecolor(palette[i])
                pc.set_alpha(0.6)
            ax.set_xticks(positions)
            ax.set_xticklabels([c[:15] for c in top_cats], rotation=45, ha="right", fontsize=7)
            ax.set_title(dim, fontsize=12, fontweight="bold")
            ax.set_ylim(0.5, 5.5)
            ax.set_yticks([1, 2, 3, 4, 5])
            ax.grid(axis="y", alpha=0.3)

        axes[0].set_ylabel("Score (1-5)", fontsize=11)
        fig.suptitle("Rating Distributions by Category (top 8 categories)", fontsize=14, y=1.02)
        plt.tight_layout()

        path = self.output_dir / "score-distributions.png"
        fig.savefig(str(path), dpi=200, bbox_inches="tight")
        plt.close(fig)
        return str(path)

    # ── 4. Timeline (stacked area) ────────────────────────────────────────

    def timeline_chart(self) -> str:
        """Stacked area chart of monthly submissions by category.

        Returns:
            Path to saved HTML file.
        """
        import plotly.graph_objects as go

        pairs = self._load_rated()
        all_drafts = self.db.list_drafts(limit=500, order_by="time ASC")

        rating_map = {d.name: r for d, r in pairs}

        # Count per month per category
        month_cat: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
        for d in all_drafts:
            month = d.time[:7] if d.time else "unknown"
            r = rating_map.get(d.name)
            if r:
                for c in r.categories:
                    month_cat[month][c] += 1

        months = sorted(month_cat.keys())
        all_cats: set[str] = set()
        for mc in month_cat.values():
            all_cats.update(mc.keys())

        # Keep top 10 categories
        cat_totals = 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)]

        fig = go.Figure()
        colors = [
            "#636EFA", "#EF553B", "#00CC96", "#AB63FA", "#FFA15A",
            "#19D3F3", "#FF6692", "#B6E880", "#FF97FF", "#FECB52",
        ]

        for i, cat in enumerate(top_cats):
            values = [month_cat[m].get(cat, 0) for m in months]
            fig.add_trace(go.Scatter(
                x=months, y=values, name=cat,
                mode="lines", stackgroup="one",
                line=dict(width=0.5, color=colors[i % len(colors)]),
                fillcolor=colors[i % len(colors)],
            ))

        # Total submissions line
        totals = [sum(month_cat[m].values()) for m in months]
        fig.add_trace(go.Scatter(
            x=months, y=totals, name="Total (all cats)",
            mode="lines+markers",
            line=dict(color="black", width=2, dash="dot"),
            marker=dict(size=6),
        ))

        fig.update_layout(
            title="IETF AI/Agent Draft Submissions by Month and Category",
            xaxis_title="Month",
            yaxis_title="Number of Drafts",
            template="plotly_white",
            width=1100, height=600,
            legend=dict(font=dict(size=10)),
            font=dict(family="Inter, Arial, sans-serif"),
        )

        path = self.output_dir / "timeline.html"
        fig.write_html(str(path), include_plotlyjs="cdn")
        return str(path)

    # ── 5. Bubble Chart (interactive explorer) ─────────────────────────────

    def bubble_explorer(self) -> str:
        """Interactive bubble chart: x=novelty, y=maturity, size=relevance, color=category.

        Returns:
            Path to saved HTML file.
        """
        import plotly.express as px

        pairs = self._load_rated()
        data = []
        for d, r in pairs:
            data.append({
                "Draft": d.name,
                "Novelty": r.novelty,
                "Maturity": r.maturity,
                "Relevance": r.relevance,
                "Overlap": r.overlap,
                "Momentum": r.momentum,
                "Score": round(r.composite_score, 2),
                "Category": r.categories[0] if r.categories else "Other",
                "Summary": r.summary[:100] if r.summary else "",
                "Date": d.date,
            })

        fig = px.scatter(
            data, x="Novelty", y="Maturity",
            size="Relevance", color="Category",
            hover_name="Draft",
            hover_data=["Score", "Overlap", "Momentum", "Date", "Summary"],
            title=f"Draft Explorer: Novelty vs Maturity ({len(data)} drafts)",
            size_max=25,
            color_discrete_sequence=px.colors.qualitative.Set3,
        )

        # Add jitter to discrete scores so points don't stack
        for trace in fig.data:
            if hasattr(trace, "x") and trace.x is not None:
                trace.x = tuple(v + np.random.uniform(-0.2, 0.2) for v in trace.x)
                trace.y = tuple(v + np.random.uniform(-0.2, 0.2) for v in trace.y)

        fig.update_layout(
            template="plotly_white",
            width=1100, height=750,
            xaxis=dict(dtick=1, range=[0.5, 5.5]),
            yaxis=dict(dtick=1, range=[0.5, 5.5]),
            font=dict(family="Inter, Arial, sans-serif"),
        )

        path = self.output_dir / "bubble-explorer.html"
        fig.write_html(str(path), include_plotlyjs="cdn")
        return str(path)

    # ── 6. Radar Charts (per-category profiles) ───────────────────────────

    def category_radar(self) -> str:
        """Radar chart showing average rating profile per category.

        Returns:
            Path to saved HTML file.
        """
        import plotly.graph_objects as go

        pairs = self._load_rated()
        cat_data: dict[str, list[Rating]] = defaultdict(list)
        for _, r in pairs:
            for c in r.categories:
                cat_data[c].append(r)

        # Top 8 categories
        top_cats = sorted(cat_data.keys(), key=lambda c: len(cat_data[c]), reverse=True)[:8]
        dims = ["Novelty", "Maturity", "Relevance", "Momentum", "Low Overlap"]

        fig = go.Figure()
        colors = [
            "#636EFA", "#EF553B", "#00CC96", "#AB63FA",
            "#FFA15A", "#19D3F3", "#FF6692", "#B6E880",
        ]

        for i, cat in enumerate(top_cats):
            ratings = cat_data[cat]
            avg = [
                np.mean([r.novelty for r in ratings]),
                np.mean([r.maturity for r in ratings]),
                np.mean([r.relevance for r in ratings]),
                np.mean([r.momentum for r in ratings]),
                np.mean([6 - r.overlap for r in ratings]),  # Invert overlap
            ]
            fig.add_trace(go.Scatterpolar(
                r=avg + [avg[0]],  # Close the polygon
                theta=dims + [dims[0]],
                name=f"{cat} ({len(ratings)})",
                line=dict(color=colors[i % len(colors)]),
                fill="toself",
                opacity=0.3,
            ))

        fig.update_layout(
            polar=dict(radialaxis=dict(visible=True, range=[0, 5])),
            title="Category Rating Profiles (top 8 categories)",
            template="plotly_white",
            width=900, height=700,
            font=dict(family="Inter, Arial, sans-serif"),
        )

        path = self.output_dir / "category-radar.html"
        fig.write_html(str(path), include_plotlyjs="cdn")
        return str(path)

    # ── 7. Author Collaboration Network ───────────────────────────────────

    def author_network(self, min_shared: int = 2) -> str:
        """Interactive force-directed graph of co-authorship.

        Args:
            min_shared: Minimum shared drafts to draw an edge.

        Returns:
            Path to saved HTML file.
        """
        try:
            import networkx as nx
        except ImportError:
            raise RuntimeError("Install networkx: pip install networkx")

        coauthor_pairs = self.db.coauthor_pairs()
        if not coauthor_pairs:
            raise RuntimeError("No author data. Run `ietf authors --fetch` first.")

        G = nx.Graph()
        for a, b, shared in coauthor_pairs:
            if shared >= min_shared:
                G.add_edge(a, b, weight=shared)

        if len(G.nodes) == 0:
            raise RuntimeError(f"No edges with min_shared={min_shared}.")

        # Get affiliations for coloring (normalized)
        from .orgs import normalize_org
        top_authors = self.db.top_authors(limit=200)
        author_aff = {name: normalize_org(aff) for name, aff, _, _, _pid in top_authors}

        # Node sizing by degree
        degrees = dict(G.degree())
        max_deg = max(degrees.values()) if degrees else 1

        # Use plotly for interactive network viz
        import plotly.graph_objects as go

        pos = nx.spring_layout(G, k=2.0 / (len(G.nodes) ** 0.5), iterations=80, seed=42)

        # Edges
        edge_x, edge_y = [], []
        edge_weights = []
        for u, v, data in G.edges(data=True):
            x0, y0 = pos[u]
            x1, y1 = pos[v]
            edge_x.extend([x0, x1, None])
            edge_y.extend([y0, y1, None])
            edge_weights.append(data["weight"])

        edge_trace = go.Scatter(
            x=edge_x, y=edge_y,
            line=dict(width=0.5, color="#888"),
            hoverinfo="none", mode="lines",
        )

        # Nodes
        node_x = [pos[n][0] for n in G.nodes]
        node_y = [pos[n][1] for n in G.nodes]
        node_text = []
        node_size = []
        node_color = []

        # Color by org
        unique_orgs = sorted(set(author_aff.get(n, "Unknown") for n in G.nodes))
        org_to_idx = {org: i for i, org in enumerate(unique_orgs)}

        for node in G.nodes:
            deg = degrees[node]
            aff = author_aff.get(node, "Unknown")
            neighbors = list(G.neighbors(node))
            node_text.append(f"{node}<br>Org: {aff}<br>Connections: {deg}<br>Co-authors: {', '.join(neighbors[:5])}")
            node_size.append(8 + (deg / max_deg) * 30)
            node_color.append(org_to_idx.get(aff, 0))

        node_trace = go.Scatter(
            x=node_x, y=node_y,
            mode="markers+text",
            hoverinfo="text", text=[n.split()[-1] for n in G.nodes],  # Last name only
            textposition="top center", textfont=dict(size=7),
            hovertext=node_text,
            marker=dict(
                size=node_size, color=node_color,
                colorscale="turbo", showscale=False,
                line=dict(width=1, color="white"),
            ),
        )

        fig = go.Figure(data=[edge_trace, node_trace])
        fig.update_layout(
            title=f"Author Collaboration Network ({len(G.nodes)} authors, {len(G.edges)} links, min {min_shared} shared drafts)",
            showlegend=False,
            xaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
            yaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
            template="plotly_white",
            width=1200, height=900,
            font=dict(family="Inter, Arial, sans-serif"),
        )

        path = self.output_dir / "author-network.html"
        fig.write_html(str(path), include_plotlyjs="cdn")
        return str(path)

    # ── 8. Category Composition (treemap) ──────────────────────────────────

    def category_treemap(self) -> str:
        """Treemap of category sizes, colored by average composite score.

        Returns:
            Path to saved HTML file.
        """
        import plotly.express as px

        pairs = self._load_rated()
        cat_data: dict[str, list[float]] = defaultdict(list)
        for _, r in pairs:
            for c in r.categories:
                cat_data[c].append(r.composite_score)

        data = []
        for cat, scores in cat_data.items():
            data.append({
                "Category": cat,
                "Count": len(scores),
                "Avg Score": round(np.mean(scores), 2),
                "Max Score": round(max(scores), 2),
                "Min Score": round(min(scores), 2),
            })

        fig = px.treemap(
            data, path=["Category"], values="Count", color="Avg Score",
            color_continuous_scale="RdYlGn",
            hover_data=["Count", "Avg Score", "Max Score", "Min Score"],
            title=f"Category Composition — {sum(d['Count'] for d in data)} category assignments",
        )
        fig.update_layout(
            width=1000, height=700,
            font=dict(family="Inter, Arial, sans-serif"),
        )

        path = self.output_dir / "category-treemap.html"
        fig.write_html(str(path), include_plotlyjs="cdn")
        return str(path)

    # ── 9. Score vs. Overlap scatter ───────────────────────────────────────

    def score_vs_overlap(self) -> str:
        """Scatter of composite score vs max similarity to any other draft.

        Shows which high-scoring drafts are unique vs redundant.

        Returns:
            Path to saved HTML file.
        """
        import plotly.express as px

        embeddings = self.db.all_embeddings()
        rating_map = self._rating_map()

        names = sorted(set(embeddings.keys()) & set(rating_map.keys()))
        if len(names) < 5:
            raise RuntimeError("Need both embeddings and ratings.")

        # Compute max similarity for each draft
        vecs = {n: embeddings[n] for n in names}
        data = []
        for n in names:
            max_sim = 0.0
            best_match = ""
            for other in names:
                if other == n:
                    continue
                dot = np.dot(vecs[n], vecs[other])
                norm = np.linalg.norm(vecs[n]) * np.linalg.norm(vecs[other])
                sim = float(dot / norm) if norm > 0 else 0.0
                if sim > max_sim:
                    max_sim = sim
                    best_match = other
            r = rating_map[n]
            data.append({
                "Draft": n,
                "Composite Score": r.composite_score,
                "Max Similarity": round(max_sim, 3),
                "Most Similar To": best_match,
                "Category": r.categories[0] if r.categories else "Other",
                "Novelty": r.novelty,
            })

        fig = px.scatter(
            data, x="Max Similarity", y="Composite Score",
            color="Category", hover_name="Draft",
            hover_data=["Most Similar To", "Novelty"],
            title="Draft Quality vs Uniqueness",
            color_discrete_sequence=px.colors.qualitative.Set3,
        )

        # Quadrant annotations
        fig.add_hline(y=3.5, line_dash="dash", line_color="gray", opacity=0.4)
        fig.add_vline(x=0.85, line_dash="dash", line_color="gray", opacity=0.4)
        fig.add_annotation(x=0.65, y=4.5, text="High quality, unique",
                           showarrow=False, font=dict(size=10, color="green"))
        fig.add_annotation(x=0.95, y=4.5, text="High quality, redundant",
                           showarrow=False, font=dict(size=10, color="orange"))
        fig.add_annotation(x=0.65, y=2.5, text="Low quality, unique",
                           showarrow=False, font=dict(size=10, color="gray"))
        fig.add_annotation(x=0.95, y=2.5, text="Low quality, redundant",
                           showarrow=False, font=dict(size=10, color="red"))

        fig.update_layout(
            template="plotly_white",
            width=1100, height=700,
            font=dict(family="Inter, Arial, sans-serif"),
        )

        path = self.output_dir / "score-vs-overlap.html"
        fig.write_html(str(path), include_plotlyjs="cdn")
        return str(path)

    # ── 10. Organization Contribution Bar Chart ────────────────────────────

    def org_contributions(self) -> str:
        """Horizontal bar chart of organizational draft contributions.

        Returns:
            Path to saved HTML file.
        """
        import plotly.express as px

        from .orgs import top_orgs_normalized
        orgs = top_orgs_normalized(self.db, limit=20)
        if not orgs:
            raise RuntimeError("No author data. Run `ietf authors --fetch` first.")

        data = [{"Organization": org, "Drafts": drafts, "Authors": authors}
                for org, authors, drafts in orgs]

        fig = px.bar(
            data, x="Drafts", y="Organization", orientation="h",
            color="Authors", color_continuous_scale="Viridis",
            hover_data=["Authors"],
            title=f"Top {len(data)} Organizations by Draft Contributions",
        )
        fig.update_layout(
            template="plotly_white",
            width=900, height=600,
            yaxis=dict(autorange="reversed"),
            font=dict(family="Inter, Arial, sans-serif"),
        )

        path = self.output_dir / "org-contributions.html"
        fig.write_html(str(path), include_plotlyjs="cdn")
        return str(path)

    # ── 11. Ideas Frequency (bar + treemap) ────────────────────────────────

    def ideas_chart(self) -> str:
        """Treemap of idea types and frequency across drafts.

        Returns:
            Path to saved HTML file.
        """
        import plotly.express as px

        all_ideas = self.db.all_ideas()
        if not all_ideas:
            raise RuntimeError("No ideas extracted. Run `ietf ideas --all` first.")

        # Group by type
        type_counts = Counter(i.get("type", "other") for i in all_ideas)
        data = [{"Type": t or "untyped", "Count": c} for t, c in type_counts.most_common()]

        fig = px.bar(
            data, x="Type", y="Count",
            color="Count", color_continuous_scale="Blues",
            title=f"Technical Ideas by Type ({len(all_ideas)} total ideas)",
        )
        fig.update_layout(
            template="plotly_white",
            width=800, height=500,
            font=dict(family="Inter, Arial, sans-serif"),
        )

        path = self.output_dir / "ideas-by-type.html"
        fig.write_html(str(path), include_plotlyjs="cdn")
        return str(path)

    # ── 12. Interactive Draft Browser ───────────────────────────────────────

    def draft_browser(self) -> str:
        """Generate a standalone interactive HTML page for browsing/filtering drafts.

        Features: category filter, text search, score sliders, sortable table,
        expandable detail rows, Datatracker links.

        Returns:
            Path to saved HTML file.
        """
        import json as _json

        pairs = self._load_rated()
        if not pairs:
            raise RuntimeError("No rated drafts. Run `ietf analyze --all` first.")

        # Collect all categories
        all_cats = sorted({c for _, r in pairs for c in r.categories})

        # Build JSON data
        drafts_json = []
        for d, r in pairs:
            drafts_json.append({
                "name": d.name,
                "title": d.title,
                "date": d.date,
                "url": d.datatracker_url,
                "pages": d.pages or 0,
                "group": d.group or "individual",
                "score": round(r.composite_score, 2),
                "novelty": r.novelty,
                "maturity": r.maturity,
                "overlap": r.overlap,
                "momentum": r.momentum,
                "relevance": r.relevance,
                "categories": r.categories,
                "summary": r.summary,
                "novelty_note": r.novelty_note,
                "maturity_note": r.maturity_note,
                "overlap_note": r.overlap_note,
                "momentum_note": r.momentum_note,
                "relevance_note": r.relevance_note,
            })

        data_js = _json.dumps(drafts_json)
        cats_js = _json.dumps(all_cats)

        html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IETF AI/Agent Draft Browser</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f7fa; color: #1a1a2e; padding: 20px; }}
h1 {{ font-size: 1.5rem; margin-bottom: 4px; }}
.subtitle {{ color: #666; font-size: 0.85rem; margin-bottom: 16px; }}
.controls {{ background: #fff; border-radius: 10px; padding: 16px 20px; margin-bottom: 16px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); }}
.controls-row {{ display: flex; gap: 16px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }}
.controls-row:last-child {{ margin-bottom: 0; }}
.search-box {{ flex: 1; min-width: 250px; padding: 8px 14px; border: 1px solid #ddd; border-radius: 6px; font-size: 0.9rem; outline: none; }}
.search-box:focus {{ border-color: #4a6cf7; box-shadow: 0 0 0 2px rgba(74,108,247,0.15); }}
.slider-group {{ display: flex; align-items: center; gap: 6px; font-size: 0.8rem; color: #555; }}
.slider-group input[type=range] {{ width: 100px; cursor: pointer; }}
.slider-val {{ font-weight: 600; min-width: 24px; text-align: center; }}
.cat-chips {{ display: flex; flex-wrap: wrap; gap: 6px; }}
.chip {{ display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 0.75rem; cursor: pointer; border: 1px solid #ddd; background: #fff; transition: all 0.15s; user-select: none; }}
.chip.active {{ background: #4a6cf7; color: #fff; border-color: #4a6cf7; }}
.chip:hover {{ border-color: #4a6cf7; }}
.chip-count {{ font-size: 0.65rem; opacity: 0.7; margin-left: 2px; }}
.result-count {{ font-size: 0.85rem; color: #666; margin: 10px 0 8px; }}
table {{ width: 100%; border-collapse: collapse; background: #fff; border-radius: 10px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,0.08); }}
th {{ background: #f8f9fb; padding: 10px 12px; text-align: left; font-size: 0.78rem; color: #555; cursor: pointer; user-select: none; white-space: nowrap; border-bottom: 2px solid #eee; }}
th:hover {{ color: #4a6cf7; }}
th .sort-arrow {{ font-size: 0.65rem; margin-left: 3px; }}
td {{ padding: 10px 12px; border-bottom: 1px solid #f0f0f0; font-size: 0.83rem; vertical-align: top; }}
tr:hover {{ background: #fafbff; }}
tr.expanded {{ background: #f0f4ff; }}
.draft-link {{ color: #4a6cf7; text-decoration: none; font-weight: 500; }}
.draft-link:hover {{ text-decoration: underline; }}
.title-cell {{ max-width: 300px; }}
.score-badge {{ display: inline-block; padding: 2px 8px; border-radius: 10px; font-weight: 600; font-size: 0.8rem; }}
.score-high {{ background: #d4edda; color: #155724; }}
.score-mid {{ background: #fff3cd; color: #856404; }}
.score-low {{ background: #f8d7da; color: #721c24; }}
.cat-badge {{ display: inline-block; padding: 1px 7px; border-radius: 8px; font-size: 0.68rem; margin: 1px 2px; background: #e8eaf6; color: #3949ab; }}
.dim {{ font-size: 0.75rem; color: #888; }}
.detail-row td {{ padding: 12px 20px; background: #f8faff; }}
.detail-grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 10px; max-width: 800px; }}
.detail-item {{ font-size: 0.82rem; }}
.detail-item strong {{ color: #333; }}
.detail-item .note {{ color: #666; font-size: 0.78rem; }}
.bar {{ display: inline-block; height: 10px; border-radius: 3px; background: #4a6cf7; vertical-align: middle; }}
.summary-text {{ font-size: 0.82rem; color: #444; margin-top: 6px; line-height: 1.4; }}
.clickable {{ cursor: pointer; }}
.reset-btn {{ padding: 4px 12px; border: 1px solid #ddd; border-radius: 6px; background: #fff; cursor: pointer; font-size: 0.78rem; color: #666; }}
.reset-btn:hover {{ border-color: #4a6cf7; color: #4a6cf7; }}
</style>
</head>
<body>

<h1>IETF AI/Agent Draft Browser</h1>
<p class="subtitle">{len(pairs)} rated drafts &mdash; click any row to expand details</p>

<div class="controls">
  <div class="controls-row">
    <input type="text" class="search-box" id="searchBox" placeholder="Search by name, title, summary, or keyword...">
    <div class="slider-group">
      Min score: <input type="range" id="minScore" min="1" max="5" step="0.1" value="1">
      <span class="slider-val" id="minScoreVal">1.0</span>
    </div>
    <div class="slider-group">
      Min novelty: <input type="range" id="minNovelty" min="1" max="5" step="1" value="1">
      <span class="slider-val" id="minNoveltyVal">1</span>
    </div>
    <div class="slider-group">
      Max overlap: <input type="range" id="maxOverlap" min="1" max="5" step="1" value="5">
      <span class="slider-val" id="maxOverlapVal">5</span>
    </div>
    <button class="reset-btn" onclick="resetFilters()">Reset</button>
  </div>
  <div class="controls-row">
    <div class="cat-chips" id="catChips"></div>
  </div>
</div>

<div class="result-count" id="resultCount"></div>

<table>
  <thead>
    <tr>
      <th onclick="sortBy('score')" width="60">Score <span class="sort-arrow" id="sort-score"></span></th>
      <th onclick="sortBy('name')">Draft <span class="sort-arrow" id="sort-name"></span></th>
      <th onclick="sortBy('date')" width="90">Date <span class="sort-arrow" id="sort-date"></span></th>
      <th onclick="sortBy('novelty')" width="30">N <span class="sort-arrow" id="sort-novelty"></span></th>
      <th onclick="sortBy('maturity')" width="30">M <span class="sort-arrow" id="sort-maturity"></span></th>
      <th onclick="sortBy('overlap')" width="30">O <span class="sort-arrow" id="sort-overlap"></span></th>
      <th onclick="sortBy('momentum')" width="30">Mom <span class="sort-arrow" id="sort-momentum"></span></th>
      <th onclick="sortBy('relevance')" width="30">R <span class="sort-arrow" id="sort-relevance"></span></th>
      <th>Categories</th>
    </tr>
  </thead>
  <tbody id="tableBody"></tbody>
</table>

<script>
const DRAFTS = {data_js};
const ALL_CATS = {cats_js};

let activeCats = new Set();
let sortField = 'score';
let sortAsc = false;
let expandedRow = null;

// ── Category chips ──
const chipBox = document.getElementById('catChips');
ALL_CATS.forEach(cat => {{
  const el = document.createElement('span');
  el.className = 'chip';
  const count = DRAFTS.filter(d => d.categories.includes(cat)).length;
  el.innerHTML = cat + '<span class="chip-count">(' + count + ')</span>';
  el.onclick = () => {{
    if (activeCats.has(cat)) {{ activeCats.delete(cat); el.classList.remove('active'); }}
    else {{ activeCats.add(cat); el.classList.add('active'); }}
    render();
  }};
  chipBox.appendChild(el);
}});

// ── Sliders ──
const searchBox = document.getElementById('searchBox');
const minScore = document.getElementById('minScore');
const minNovelty = document.getElementById('minNovelty');
const maxOverlap = document.getElementById('maxOverlap');

searchBox.oninput = render;
minScore.oninput = () => {{ document.getElementById('minScoreVal').textContent = parseFloat(minScore.value).toFixed(1); render(); }};
minNovelty.oninput = () => {{ document.getElementById('minNoveltyVal').textContent = minNovelty.value; render(); }};
maxOverlap.oninput = () => {{ document.getElementById('maxOverlapVal').textContent = maxOverlap.value; render(); }};

function resetFilters() {{
  searchBox.value = '';
  minScore.value = 1; document.getElementById('minScoreVal').textContent = '1.0';
  minNovelty.value = 1; document.getElementById('minNoveltyVal').textContent = '1';
  maxOverlap.value = 5; document.getElementById('maxOverlapVal').textContent = '5';
  activeCats.clear();
  document.querySelectorAll('.chip').forEach(c => c.classList.remove('active'));
  sortField = 'score'; sortAsc = false;
  render();
}}

// ── Sorting ──
function sortBy(field) {{
  if (sortField === field) sortAsc = !sortAsc;
  else {{ sortField = field; sortAsc = field === 'name' || field === 'date'; }}
  render();
}}

function cmp(a, b) {{
  let va = a[sortField], vb = b[sortField];
  if (typeof va === 'string') return sortAsc ? va.localeCompare(vb) : vb.localeCompare(va);
  return sortAsc ? va - vb : vb - va;
}}

// ── Score badge ──
function scoreBadge(s) {{
  const cls = s >= 4.0 ? 'score-high' : s >= 3.0 ? 'score-mid' : 'score-low';
  return '<span class="score-badge ' + cls + '">' + s.toFixed(1) + '</span>';
}}

function dimBar(v) {{
  return '<span class="bar" style="width:' + (v * 12) + 'px"></span> ' + v;
}}

// ── Render ──
function render() {{
  const q = searchBox.value.toLowerCase().trim();
  const ms = parseFloat(minScore.value);
  const mn = parseInt(minNovelty.value);
  const mo = parseInt(maxOverlap.value);

  let filtered = DRAFTS.filter(d => {{
    if (d.score < ms) return false;
    if (d.novelty < mn) return false;
    if (d.overlap > mo) return false;
    if (activeCats.size > 0 && !d.categories.some(c => activeCats.has(c))) return false;
    if (q) {{
      const hay = (d.name + ' ' + d.title + ' ' + d.summary + ' ' + d.categories.join(' ')).toLowerCase();
      const words = q.split(/\\s+/);
      if (!words.every(w => hay.includes(w))) return false;
    }}
    return true;
  }});

  filtered.sort(cmp);

  // Update sort arrows
  document.querySelectorAll('.sort-arrow').forEach(el => el.textContent = '');
  const arrow = document.getElementById('sort-' + sortField);
  if (arrow) arrow.textContent = sortAsc ? '\\u25B2' : '\\u25BC';

  const tbody = document.getElementById('tableBody');
  tbody.innerHTML = '';
  expandedRow = null;

  filtered.forEach((d, idx) => {{
    const tr = document.createElement('tr');
    tr.className = 'clickable';
    tr.innerHTML =
      '<td>' + scoreBadge(d.score) + '</td>' +
      '<td class="title-cell"><a class="draft-link" href="' + d.url + '" target="_blank" onclick="event.stopPropagation()">' + d.name + '</a>' +
        '<br><span class="dim">' + escHtml(d.title.substring(0, 80)) + '</span></td>' +
      '<td class="dim">' + d.date + '</td>' +
      '<td>' + dimBar(d.novelty) + '</td>' +
      '<td>' + dimBar(d.maturity) + '</td>' +
      '<td>' + dimBar(d.overlap) + '</td>' +
      '<td>' + dimBar(d.momentum) + '</td>' +
      '<td>' + dimBar(d.relevance) + '</td>' +
      '<td>' + d.categories.map(c => '<span class="cat-badge">' + c + '</span>').join('') + '</td>';

    tr.onclick = () => toggleDetail(tr, d);
    tbody.appendChild(tr);
  }});

  document.getElementById('resultCount').textContent =
    'Showing ' + filtered.length + ' of ' + DRAFTS.length + ' drafts';
}}

function toggleDetail(tr, d) {{
  // Remove existing detail row
  if (expandedRow) {{
    expandedRow.previousElementSibling?.classList.remove('expanded');
    expandedRow.remove();
    if (expandedRow._draftName === d.name) {{ expandedRow = null; return; }}
  }}

  tr.classList.add('expanded');
  const detail = document.createElement('tr');
  detail.className = 'detail-row';
  detail._draftName = d.name;
  detail.innerHTML = '<td colspan="9">' +
    '<div class="summary-text"><strong>Summary:</strong> ' + escHtml(d.summary) + '</div>' +
    '<div class="detail-grid" style="margin-top:10px">' +
      detailItem('Novelty', d.novelty, d.novelty_note) +
      detailItem('Maturity', d.maturity, d.maturity_note) +
      detailItem('Overlap', d.overlap, d.overlap_note) +
      detailItem('Momentum', d.momentum, d.momentum_note) +
      detailItem('Relevance', d.relevance, d.relevance_note) +
      '<div class="detail-item"><strong>Group:</strong> ' + d.group + ' &middot; <strong>Pages:</strong> ' + d.pages + '</div>' +
    '</div>' +
    '<div style="margin-top:8px"><a class="draft-link" href="' + d.url + '" target="_blank">Open on Datatracker \\u2192</a></div>' +
  '</td>';
  tr.after(detail);
  expandedRow = detail;
}}

function detailItem(label, score, note) {{
  const pct = (score / 5 * 100);
  return '<div class="detail-item"><strong>' + label + ':</strong> ' + score + '/5 ' +
    '<span class="bar" style="width:' + (score * 16) + 'px"></span>' +
    (note ? '<div class="note">' + escHtml(note) + '</div>' : '') + '</div>';
}}

function escHtml(s) {{
  const d = document.createElement('div');
  d.textContent = s || '';
  return d.innerHTML;
}}

// Initial render
render();
</script>
</body>
</html>"""

        path = self.output_dir / "browser.html"
        path.write_text(html)
        return str(path)

    # ── Generate All ───────────────────────────────────────────────────────

    def generate_all(self) -> list[str]:
        """Generate all visualizations that have sufficient data.

        Returns:
            List of paths to generated files.
        """
        from rich.console import Console
        from rich.progress import Progress, SpinnerColumn, TextColumn

        console = Console()
        generated = []

        charts = [
            ("Landscape scatter (UMAP/t-SNE)", self.landscape_scatter),
            ("Similarity heatmap", self.similarity_heatmap),
            ("Score distributions", self.score_distributions),
            ("Timeline (stacked area)", self.timeline_chart),
            ("Bubble explorer", self.bubble_explorer),
            ("Category radar", self.category_radar),
            ("Author network", self.author_network),
            ("Category treemap", self.category_treemap),
            ("Score vs overlap", self.score_vs_overlap),
            ("Organization contributions", self.org_contributions),
            ("Ideas chart", self.ideas_chart),
            ("Draft browser", self.draft_browser),
        ]

        with Progress(SpinnerColumn(), TextColumn("{task.description}"), console=console) as progress:
            for label, fn in charts:
                task = progress.add_task(f"Generating: {label}...", total=1)
                try:
                    path = fn()
                    generated.append(path)
                    console.print(f"  [green]OK[/] {label} → {path}")
                except Exception as e:
                    console.print(f"  [yellow]SKIP[/] {label}: {e}")
                progress.advance(task)

        return generated
