# %% [markdown]
# # Developer Analytics — HTML Report Generator
# Generates a self-contained HTML report per developer.
# All charts are base64-embedded PNGs. No external dependencies to view.

# %%
import sys, os, re, json, base64, io, textwrap, html as html_mod
from datetime import datetime
from collections import Counter
from itertools import combinations

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))

import pandas as pd
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import seaborn as sns
from wordcloud import WordCloud
from utils.connection import init, query_df
from IPython.display import display, Markdown

pd.set_option("display.max_columns", 30)
pd.set_option("display.max_rows", 200)

conn = init()
display(Markdown("**Connected.**"))

# %% [markdown]
# ## Filters

# %%
# --- CONFIGURE ---
ORG_FILTER  = None            # e.g. "pratilipi-platform"
REPO_PREFIX = "Pratilipi/"    # only repos with this prefix, None for all
USER_FILTER = None            # None = generate report for ALL users matching filters
OUTPUT_DIR  = os.path.join(os.path.dirname(__file__), "reports")
# -----------------

os.makedirs(OUTPUT_DIR, exist_ok=True)

# %% [markdown]
# ## Seaborn theme (consistent across all charts)

# %%
PALETTE = sns.color_palette("muted")
sns.set_theme(style="whitegrid", palette=PALETTE, font_scale=0.95,
              rc={"figure.figsize": (8, 3.8), "axes.titlesize": 13,
                  "axes.titleweight": "bold", "axes.labelsize": 11})

C_PRIMARY   = PALETTE[0]   # blue
C_SECONDARY = PALETTE[1]   # orange
C_SUCCESS   = PALETTE[2]   # green
C_DANGER    = PALETTE[3]   # red
C_PURPLE    = PALETTE[4]
C_BROWN     = PALETTE[5]
C_PINK      = PALETTE[6]
C_GRAY      = PALETTE[7]

# %% [markdown]
# ## Data loading

# %%
clauses, params = [], []
if ORG_FILTER:
    clauses.append("s.org = %s"); params.append(ORG_FILTER)
if REPO_PREFIX:
    clauses.append("s.repo_name LIKE %s"); params.append(f"{REPO_PREFIX}%")
if USER_FILTER:
    clauses.append("s.user_email = %s"); params.append(USER_FILTER)
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""

sess = query_df(conn, f"""
    SELECT s.*,
           EXTRACT(EPOCH FROM (s.last_updated - s.first_seen))/60.0 AS duration_min,
           DATE_TRUNC('week', s.first_seen)::date AS week
    FROM sessions s {where}
    ORDER BY s.first_seen
""", tuple(params))

if sess.empty:
    raise SystemExit("No sessions match filters.")

sids = sess["id"].tolist()
display(Markdown(f"**{len(sess)} sessions loaded.**"))

msgs = query_df(conn, """
    SELECT m.id, m.session_id, m.msg_type, m.content, m.timestamp,
           s.user_email, s.source, s.repo_name, s.cwd
    FROM messages m JOIN sessions s ON s.id = m.session_id
    WHERE m.session_id = ANY(%s)
    ORDER BY m.timestamp
""", (sids,))

tc = query_df(conn, """
    SELECT tc.tool_name, tc.tool_input, tc.tool_id,
           m.id AS message_id, m.session_id, m.timestamp,
           s.user_email, s.cwd
    FROM tool_calls tc
    JOIN messages m ON m.id = tc.message_id
    JOIN sessions s ON s.id = m.session_id
    WHERE m.session_id = ANY(%s)
    ORDER BY m.timestamp
""", (sids,))

tr = query_df(conn, """
    SELECT tr.status, tr.call_id, m.session_id, s.user_email
    FROM tool_results tr
    JOIN messages m ON m.id = tr.message_id
    JOIN sessions s ON s.id = m.session_id
    WHERE m.session_id = ANY(%s)
""", (sids,))

# %% [markdown]
# ## Helpers

# %%
SHELL   = {"Bash", "shell_command", "exec_command"}
EDIT    = {"Edit", "Write", "MultiEdit", "MultiEditTool"}
EXPLORE = {"Read", "Grep", "Glob", "View", "ReadFile", "read_file",
           "grep_search", "codebase_search", "list_dir", "file_search"}

# Extended stopwords — English + code/agent jargon that pollutes word clouds
STOPWORDS = set("""
a an the is are was were be been being have has had do does did will would
shall should may might can could and but or nor for yet so at by from in into
of on to with as it its i me my we our you your he him his she her they them
their this that these those which what who whom how when where why all each
every both few more most other some such no not only own same than too very
just because if then also about up out there here after before between
through during above below over under again further once please let get
make sure use using used need like want file code add change set run
should must needs right now don that this these them been well still
going look see way things something try doesn already been thing done
going take give take got any will one two first last next new old good
yes okay know work working works think back help put keep want also
current currently following based find instead without type called
line lines return returns true false none null value values string
data list dict function method class import module path name
""".split())


def strip_system_prompts(text):
    """Remove ALL injected system prompt content from user messages.

    Handles:
    1. Codex: # AGENTS.md instructions for ...\n\n<INSTRUCTIONS>...</INSTRUCTIONS>
    2. Generic <INSTRUCTION(S)>...</INSTRUCTION(S)> blocks
    """
    if not text:
        return ""
    # Full Codex AGENTS.md block (header + instructions tag)
    text = re.sub(
        r"^#\s*AGENTS\.md\s+instructions\s+for\s+.+?\n\n<INSTRUCTIONS?>[\s\S]*?</INSTRUCTIONS?>\s*",
        "", text, flags=re.IGNORECASE
    )
    # Any remaining <INSTRUCTION(S)> blocks
    text = re.sub(r"<INSTRUCTIONS?>[\s\S]*?</INSTRUCTIONS?>", "", text, flags=re.IGNORECASE)
    # Strip non-ASCII
    text = text.encode("ascii", errors="ignore").decode("ascii")
    return text.strip()


def extract_path(ti):
    if isinstance(ti, dict):
        return ti.get("file_path") or ti.get("path") or ti.get("target_file")
    return None

def make_rel(fp, cwd):
    if fp and cwd and fp.startswith(cwd):
        return fp[len(cwd):].lstrip("/") or fp
    return fp

def fig_to_b64(fig):
    buf = io.BytesIO()
    fig.savefig(buf, format="png", dpi=130, bbox_inches="tight", facecolor="white")
    plt.close(fig)
    buf.seek(0)
    return base64.b64encode(buf.read()).decode()

def tokenize(text):
    """Extract clean lowercase words, strip non-ASCII and stopwords."""
    text = text.encode("ascii", errors="ignore").decode("ascii").lower()
    words = re.findall(r"\b[a-z]{3,}\b", text)
    return [w for w in words if w not in STOPWORDS]


# ── Seaborn chart helpers ──

def chart_histogram(data, title, xlabel, color=C_PRIMARY):
    data = [x for x in data if pd.notna(x)]
    fig, ax = plt.subplots()
    if not data:
        ax.text(0.5, 0.5, "No data", ha="center", va="center", transform=ax.transAxes)
    else:
        sns.histplot(data, bins=min(25, max(5, len(set(data)))), color=color,
                     edgecolor="white", ax=ax, kde=False)
        med = np.median(data)
        ax.axvline(med, color=C_DANGER, linestyle="--", linewidth=1.2, label=f"Median: {med:.0f}")
        ax.legend(fontsize=9)
    ax.set_title(title); ax.set_xlabel(xlabel); ax.set_ylabel("Number of sessions")
    return fig

def chart_barh(labels, values, title, xlabel="", color=C_PRIMARY):
    fig, ax = plt.subplots(figsize=(8, max(3, len(labels) * 0.38)))
    sns.barplot(x=values, y=labels, color=color, edgecolor="white", ax=ax, orient="h")
    ax.set_title(title); ax.set_xlabel(xlabel)
    return fig

def chart_weekly(week_df, cols_colors, title, ylabel):
    """Multi-series weekly bar/line chart."""
    fig, ax = plt.subplots()
    weeks = week_df["week"].astype(str).tolist()
    x = np.arange(len(weeks))
    width = 0.8 / len(cols_colors)
    for i, (col, color, label) in enumerate(cols_colors):
        offset = (i - len(cols_colors)/2 + 0.5) * width
        ax.bar(x + offset, week_df[col], width=width, color=color, label=label, edgecolor="white")
    ax.set_xticks(x); ax.set_xticklabels(weeks, rotation=45, ha="right")
    ax.set_title(title); ax.set_ylabel(ylabel); ax.set_xlabel("Week")
    ax.legend(fontsize=9)
    return fig


# %% [markdown]
# ## Compute report per developer

# %%
def compute_dev_report(email):
    report = {"email": email, "sections": [], "charts": [], "tables": []}

    u_sess = sess[sess["user_email"] == email].copy()
    u_msgs = msgs[msgs["user_email"] == email].copy()
    u_tc   = tc[tc["user_email"] == email].copy()
    u_tr   = tr[tr["user_email"] == email].copy()

    n_sess = len(u_sess)
    if n_sess == 0:
        return report

    # ── Tool groups ──
    u_tc["group"] = u_tc["tool_name"].apply(
        lambda t: "shell" if t in SHELL else "edit" if t in EDIT
        else "explore" if t in EXPLORE else "other")

    # ── Per-session aggregates ──
    s_msg_types = u_msgs.groupby(["session_id", "msg_type"]).size().unstack(fill_value=0)
    for col in ["user", "assistant", "tool_call", "tool_result"]:
        if col not in s_msg_types.columns:
            s_msg_types[col] = 0
    s_msg_types = s_msg_types.rename(columns={"user": "user_msgs", "assistant": "assistant_msgs",
                                               "tool_call": "tool_call_msgs", "tool_result": "tool_result_msgs"})
    s_msg_types["total_msgs"] = s_msg_types.sum(axis=1)

    s_tc_groups = u_tc.groupby(["session_id", "group"]).size().unstack(fill_value=0)
    for col in ["explore", "edit", "shell", "other"]:
        if col not in s_tc_groups.columns:
            s_tc_groups[col] = 0
    s_tc_groups["tool_calls"] = s_tc_groups.sum(axis=1)

    s_df = u_sess.set_index("id").join(s_msg_types).join(s_tc_groups, rsuffix="_tc").fillna(0)

    # ── Git commit detection ──
    commit_sids = set()
    for _, r in u_tc[u_tc["group"] == "shell"].iterrows():
        ti = r["tool_input"]
        if isinstance(ti, dict): ti = json.dumps(ti)
        if ti and isinstance(ti, str) and ("git commit" in ti.lower() or "git push" in ti.lower()):
            commit_sids.add(r["session_id"])
    s_df["has_commit"] = s_df.index.isin(commit_sids)

    # ── Lines added/removed ──
    line_records = []
    for _, r in u_tc[u_tc["group"] == "edit"].iterrows():
        ti = r["tool_input"]
        if not isinstance(ti, dict): continue
        old = ti.get("old_string", "") or ""
        new = ti.get("new_string", "") or ti.get("content", "") or ""
        old_lines = old.count("\n") + (1 if old else 0) if old else 0
        new_lines = new.count("\n") + (1 if new else 0) if new else 0
        line_records.append({"session_id": r["session_id"], "timestamp": r["timestamp"],
                             "lines_added": new_lines, "lines_removed": old_lines,
                             "net_delta": new_lines - old_lines})
    lines_df = pd.DataFrame(line_records) if line_records else pd.DataFrame()
    if not lines_df.empty:
        lines_df = lines_df.merge(u_sess[["id","week"]].rename(columns={"id":"session_id"}),
                                  on="session_id", how="left")

    # ── NLP prep ──
    user_text = u_msgs[u_msgs["msg_type"] == "user"].copy()
    user_text["clean"] = user_text["content"].apply(strip_system_prompts)
    user_text = user_text[user_text["clean"].str.len() > 0]
    user_text["word_count"] = user_text["clean"].str.split().str.len().fillna(0).astype(int)

    # Tool counts
    n_explore = int((u_tc["group"] == "explore").sum())
    n_edit    = int((u_tc["group"] == "edit").sum())
    n_shell   = int((u_tc["group"] == "shell").sum())
    total_tc  = len(u_tc)

    # ═══════════════════════════════════════════════════════
    # SECTION: Overview
    # ═══════════════════════════════════════════════════════
    commit_rate = s_df["has_commit"].mean() * 100
    explore_edit = n_explore / max(n_edit, 1)

    overview = [
        ("Total sessions", str(n_sess)),
        ("Active period", f"{u_sess['first_seen'].min():%Y-%m-%d} to {u_sess['last_updated'].max():%Y-%m-%d}"),
        ("AI tools used", ", ".join(sorted(u_sess["source"].unique()))),
        ("Repositories", ", ".join(sorted(u_sess["repo_name"].dropna().unique()))),
        ("Typical session length",
            f"{s_df['duration_min'].median():.0f} minutes (half the sessions are shorter than this)"),
        ("Messages per session",
            f"Typically {s_df['user_msgs'].median():.0f} prompts from the developer "
            f"and {s_df['assistant_msgs'].median():.0f} responses from the AI"),
        ("Tool calls per session",
            f"The AI uses {s_df['tool_calls'].median():.0f} tools per session on average "
            f"(reading files, editing code, running commands)"),
        ("Commit rate",
            f"{commit_rate:.0f}% of sessions end with a git commit "
            f"({int(s_df['has_commit'].sum())} out of {n_sess} sessions)"),
    ]
    report["sections"].append(("Overview", overview))

    # ═══════════════════════════════════════════════════════
    # SECTION: How the AI spends its time
    # ═══════════════════════════════════════════════════════
    tools_section = [
        ("Reading & searching code", f"{n_explore:,} calls ({n_explore/max(total_tc,1)*100:.0f}% of all tool use)"),
        ("Editing & writing code", f"{n_edit:,} calls ({n_edit/max(total_tc,1)*100:.0f}% of all tool use)"),
        ("Running commands (shell)", f"{n_shell:,} calls ({n_shell/max(total_tc,1)*100:.0f}% of all tool use)"),
        ("Discovery overhead",
            f"For every file edited, the AI reads {explore_edit:.1f} files first "
            + ("— this is high, suggesting the AI spends a lot of time exploring before acting"
               if explore_edit > 2.5 else
               "— this is moderate, the AI knows where to look"
               if explore_edit > 1.5 else
               "— this is low, the AI edits efficiently with minimal exploration")),
    ]
    if not u_tr.empty:
        n_success = int((u_tr["status"] == "success").sum())
        n_fail = int((u_tr["status"] == "failure").sum())
        rate = n_success / max(n_success + n_fail, 1) * 100
        tools_section.append(("Tool reliability",
            f"{rate:.0f}% of tool calls succeed ({n_fail} failures out of {n_success+n_fail})"))
    report["sections"].append(("How the AI spends its time", tools_section))

    # ═══════════════════════════════════════════════════════
    # SECTION: Code changes
    # ═══════════════════════════════════════════════════════
    if not lines_df.empty:
        sess_lines = lines_df.groupby("session_id").agg(
            added=("lines_added", "sum"), removed=("lines_removed", "sum"),
            net=("net_delta", "sum"))
        total_added = int(sess_lines["added"].sum())
        total_removed = int(sess_lines["removed"].sum())
        total_net = int(sess_lines["net"].sum())
        report["sections"].append(("Code changes", [
            ("Lines added (total)", f"{total_added:,} lines of code written across all sessions"),
            ("Lines removed (total)", f"{total_removed:,} lines removed or replaced"),
            ("Net change", f"{'+' if total_net >= 0 else ''}{total_net:,} lines "
                + ("(codebase grew)" if total_net > 0 else "(codebase shrank)" if total_net < 0 else "(no net change)")),
            ("Per session", f"Typically {sess_lines['added'].median():.0f} lines added and "
                            f"{sess_lines['removed'].median():.0f} lines removed per session"),
        ]))

    # ═══════════════════════════════════════════════════════
    # SECTION: Prompts before first edit
    # ═══════════════════════════════════════════════════════
    pre_edit = []
    for sid, grp in u_msgs.groupby("session_id"):
        user_turns = grp[grp["msg_type"] == "user"].sort_values("timestamp")
        sid_tc = u_tc[u_tc["session_id"] == sid].sort_values("timestamp")
        edit_tc = sid_tc[sid_tc["group"] == "edit"]
        if edit_tc.empty or user_turns.empty: continue
        prompts_before = int((user_turns["timestamp"] < edit_tc["timestamp"].iloc[0]).sum())
        pre_edit.append(prompts_before)

    if pre_edit:
        med_pre = np.median(pre_edit)
        report["sections"].append(("Warmup time (prompts before AI starts editing)", [
            ("Median prompts before first edit", f"{med_pre:.0f} "
                + ("— the AI needs several rounds of context before writing code"
                   if med_pre > 3 else "— the AI gets to editing quickly")),
            ("Sessions where AI edited code", f"{len(pre_edit)} out of {n_sess}"),
        ]))

    # ═══════════════════════════════════════════════════════
    # SECTION: Session continuity
    # ═══════════════════════════════════════════════════════
    multi_day = s_df[s_df["duration_min"] > 24 * 60]
    gap_records = []
    for sid, grp in u_msgs.groupby("session_id"):
        ts = grp["timestamp"].sort_values()
        if len(ts) < 2: continue
        gap_records.append({"session_id": sid, "max_gap_min": ts.diff().dropna().dt.total_seconds().max() / 60})
    gap_df = pd.DataFrame(gap_records) if gap_records else pd.DataFrame()
    resumed = gap_df[gap_df["max_gap_min"] > 60] if not gap_df.empty else pd.DataFrame()

    report["sections"].append(("Session continuity", [
        ("Multi-day sessions", f"{len(multi_day)} sessions span more than 24 hours "
            "(developer left and came back to the same session)"),
        ("Resumed sessions", f"{len(resumed)} sessions had a gap of over 1 hour between messages "
            "(likely paused and resumed to preserve context)"),
    ]))

    # ═══════════════════════════════════════════════════════
    # SECTION: Developer persona
    # ═══════════════════════════════════════════════════════
    med_user_msgs = s_df["user_msgs"].median()
    med_tool_calls = s_df["tool_calls"].median()

    personas = []
    if med_user_msgs <= 3 and med_tool_calls > 10:
        personas.append(("Delegator",
            "Tends to give one detailed prompt and lets the AI do the work. "
            "Few back-and-forth messages but lots of automated tool use. "
            "Tip: Front-load as much context as possible in the first message."))
    if med_user_msgs > 8:
        personas.append(("Collaborator",
            "Works interactively with the AI through many back-and-forth messages. "
            "Refines and steers the AI's work through conversation. "
            "Tip: The AI should remember corrections from earlier in the session."))
    if explore_edit > 2.5:
        personas.append(("Explorer",
            "Uses the AI heavily for reading and understanding code — "
            "much more exploration than editing. "
            "Tip: Pre-loading a codebase map or file index would reduce discovery time."))
    if n_edit > n_explore and commit_rate > 30:
        personas.append(("Builder",
            "Focuses on producing code — high edit rate and frequent commits. "
            "Uses the AI as a code-writing partner rather than a research tool. "
            "Tip: Pre-load frequently edited files to skip the exploration phase."))
    if not personas:
        personas.append(("Balanced",
            "No single dominant pattern — uses the AI across different modes "
            "(exploring, editing, running commands). Adapts style to the task at hand."))
    report["personas"] = personas

    # ═══════════════════════════════════════════════════════
    # CHARTS
    # ═══════════════════════════════════════════════════════

    # Session histograms
    report["charts"].append(("How long do sessions last?",
        fig_to_b64(chart_histogram(s_df["duration_min"].tolist(),
            "Session Duration", "Minutes", C_PRIMARY))))

    report["charts"].append(("How many prompts does the developer send per session?",
        fig_to_b64(chart_histogram(s_df["user_msgs"].tolist(),
            "User Prompts per Session", "Prompts", C_SECONDARY))))

    report["charts"].append(("How many tools does the AI use per session?",
        fig_to_b64(chart_histogram(s_df["tool_calls"].tolist(),
            "AI Tool Calls per Session", "Tool calls", C_SUCCESS))))

    # Prompts before first edit
    if pre_edit:
        report["charts"].append(("How many prompts before the AI starts editing code?",
            fig_to_b64(chart_histogram(pre_edit,
                "Prompts Before First Code Edit", "Prompts", C_PURPLE))))

    # Lines added/removed
    if not lines_df.empty:
        report["charts"].append(("Lines of code added per session",
            fig_to_b64(chart_histogram(sess_lines["added"].tolist(),
                "Lines Added per Session", "Lines", C_SUCCESS))))

        report["charts"].append(("Lines of code removed per session",
            fig_to_b64(chart_histogram(sess_lines["removed"].tolist(),
                "Lines Removed per Session", "Lines", C_DANGER))))

        # Weekly line churn
        wk_lines = lines_df.groupby("week").agg(
            added=("lines_added","sum"), removed=("lines_removed","sum"),
        ).reset_index().sort_values("week")
        if len(wk_lines) >= 1:
            fig, ax = plt.subplots()
            w = wk_lines["week"].astype(str)
            ax.bar(w, wk_lines["added"], color=C_SUCCESS, alpha=0.85, label="Lines added")
            ax.bar(w, -wk_lines["removed"], color=C_DANGER, alpha=0.85, label="Lines removed")
            ax.axhline(0, color="black", linewidth=0.5)
            ax.set_title("Lines Added vs Removed — Week by Week")
            ax.set_ylabel("Lines"); ax.set_xlabel("Week"); ax.legend(fontsize=9)
            plt.xticks(rotation=45, ha="right")
            report["charts"].append(("Weekly code churn", fig_to_b64(fig)))

    # Top tools
    tool_freq = u_tc["tool_name"].value_counts().head(12)
    if not tool_freq.empty:
        report["charts"].append(("Which tools does the AI use most?",
            fig_to_b64(chart_barh(
                tool_freq.index.tolist(), tool_freq.values.tolist(),
                "Most Used AI Tools", "Number of calls", C_PRIMARY))))

    # Session gap histogram
    if not gap_df.empty:
        report["charts"].append(("Longest pause within each session",
            fig_to_b64(chart_histogram(
                gap_df["max_gap_min"].clip(upper=300).tolist(),
                "Longest Gap Between Messages in a Session (capped at 5h)",
                "Minutes", C_BROWN))))

    # ── Week-on-week charts ──
    wow = s_df.reset_index().groupby("week").agg(
        sessions=("id", "count"),
        median_duration=("duration_min", "median"),
        median_user_msgs=("user_msgs", "median"),
        median_tool_calls=("tool_calls", "median"),
        commits=("has_commit", "sum"),
        total_explore=("explore", "sum"),
        total_edits=("edit", "sum"),
        total_shell=("shell", "sum"),
    ).reset_index().sort_values("week")
    wow["commit_rate"] = (wow["commits"] / wow["sessions"] * 100).round(0)

    if len(wow) >= 1:
        report["charts"].append(("Sessions per week",
            fig_to_b64(chart_weekly(wow,
                [("sessions", C_PRIMARY, "Sessions")],
                "Sessions per Week", "Sessions"))))

        report["charts"].append(("Commit rate by week",
            fig_to_b64(chart_weekly(wow,
                [("commit_rate", C_SUCCESS, "Commit rate %")],
                "Commit Rate by Week (% of sessions with a git commit)", "% of sessions"))))

        report["charts"].append(("AI tool usage by week",
            fig_to_b64(chart_weekly(wow,
                [("total_explore", C_PRIMARY, "Explore (read/grep/glob)"),
                 ("total_edits", C_SUCCESS, "Edit (edit/write)"),
                 ("total_shell", C_SECONDARY, "Shell (bash/commands)")],
                "AI Tool Usage by Week", "Tool calls"))))

        # WoW table
        wow_table = wow.rename(columns={
            "week": "Week", "sessions": "Sessions",
            "median_duration": "Typical duration (min)",
            "median_user_msgs": "Prompts / session",
            "median_tool_calls": "Tool calls / session",
            "commits": "Commits", "commit_rate": "Commit rate %",
            "total_explore": "Explore calls", "total_edits": "Edit calls",
            "total_shell": "Shell calls",
        })
        report["tables"].append(("Week-on-week trends", wow_table))

    # ── NLP ──
    all_words = []
    for _, row in user_text.iterrows():
        all_words.extend(tokenize(row["clean"]))

    if all_words:
        # Word frequency
        wf = Counter(all_words).most_common(20)
        report["charts"].append(("Most common words in developer prompts",
            fig_to_b64(chart_barh(
                [w for w, _ in wf], [c for _, c in wf],
                "Most Common Words in Developer Prompts",
                "Frequency", C_PURPLE))))

        # Word cloud
        freq_dict = dict(Counter(all_words))
        wc = WordCloud(width=900, height=380, background_color="white",
                       colormap="viridis", max_words=60)
        wc.generate_from_frequencies(freq_dict)
        fig, ax = plt.subplots(figsize=(9, 4))
        ax.imshow(wc, interpolation="bilinear"); ax.axis("off")
        ax.set_title("What does this developer talk about?", fontsize=13, fontweight="bold")
        report["charts"].append(("Word cloud of developer prompts", fig_to_b64(fig)))

        # Bigrams
        bigrams = Counter(zip(all_words[:-1], all_words[1:])).most_common(12)
        if bigrams:
            report["charts"].append(("Common two-word phrases in prompts",
                fig_to_b64(chart_barh(
                    [f"{a} {b}" for (a, b), _ in bigrams],
                    [c for _, c in bigrams],
                    "Common Two-Word Phrases in Prompts",
                    "Frequency", PALETTE[8] if len(PALETTE) > 8 else C_BROWN))))

    # Intent classification
    INTENTS = {
        "Fixing bugs":     r"\b(fix|bug|error|debug|broken|fail|crash|traceback|exception|issue)\b",
        "Building features": r"\b(create|add|implement|build|new|generate|write|feature)\b",
        "Refactoring":     r"\b(refactor|clean|rename|move|extract|simplify|reorganize)\b",
        "Understanding code": r"\b(explain|understand|what does|how does|why|describe|walk through)\b",
        "Testing":         r"\b(test|spec|assert|coverage|pytest|jest|unittest)\b",
        "Deploying":       r"\b(deploy|release|publish|ship|merge|pull request)\b",
        "Configuration":   r"\b(config|setup|install|env|environment|dependency|package)\b",
    }
    intent_counts = {}
    for intent, pat in INTENTS.items():
        cnt = int(user_text["clean"].str.contains(pat, case=False, na=False).sum())
        if cnt > 0:
            intent_counts[intent] = cnt
    intent_counts = dict(sorted(intent_counts.items(), key=lambda x: -x[1]))

    if intent_counts:
        report["charts"].append(("What kind of work is the developer doing?",
            fig_to_b64(chart_barh(
                list(intent_counts.keys()), list(intent_counts.values()),
                "Type of Work (based on prompt keywords)",
                "Number of messages", C_SECONDARY))))

    # Message length
    report["charts"].append(("How long are the developer's prompts?",
        fig_to_b64(chart_histogram(
            user_text["word_count"].clip(upper=500).tolist(),
            "Developer Prompt Length", "Words", C_PRIMARY))))

    # First message
    first = user_text.sort_values("timestamp").groupby("session_id").first().reset_index()
    first["word_count"] = first["clean"].str.split().str.len().fillna(0).astype(int)
    report["charts"].append(("How detailed is the first prompt in each session?",
        fig_to_b64(chart_histogram(
            first["word_count"].clip(upper=500).tolist(),
            "First Prompt Length per Session", "Words", C_SUCCESS))))

    # Sample first messages
    samples = []
    for _, r in first.nlargest(5, "word_count").iterrows():
        text = r["clean"][:400] + ("..." if len(r["clean"]) > 400 else "")
        samples.append(f"[{r['word_count']} words] {text}")
    report["first_msg_samples"] = samples

    # ── Top edited files table ──
    edit_tc_data = u_tc[u_tc["group"] == "edit"].copy()
    edit_tc_data["file_path"] = edit_tc_data["tool_input"].apply(extract_path)
    edit_tc_data["rel_path"] = edit_tc_data.apply(lambda r: make_rel(r["file_path"], r["cwd"]), axis=1)
    file_edits = edit_tc_data[edit_tc_data["rel_path"].notna()]
    if not file_edits.empty:
        top_files = (
            file_edits.groupby("rel_path")
            .agg(edits=("message_id", "count"), sessions=("session_id", "nunique"))
            .sort_values("sessions", ascending=False).head(15)
            .reset_index()
            .rename(columns={"rel_path": "File", "edits": "Total edits", "sessions": "Sessions touched"})
        )
        report["tables"].append(("Most frequently edited files", top_files))

        struggle = (
            file_edits.groupby(["session_id", "rel_path"]).size()
            .reset_index(name="edits").query("edits >= 5")
            .sort_values("edits", ascending=False).head(15)
            .rename(columns={"session_id": "Session", "rel_path": "File", "edits": "Edit attempts"})
        )
        if not struggle.empty:
            report["tables"].append((
                "Struggle signals — files edited 5+ times in a single session (AI had difficulty)",
                struggle))

    # ═══════════════════════════════════════════════════════
    # TABLES: Most-read files
    # ═══════════════════════════════════════════════════════
    read_tc = u_tc[u_tc["tool_name"].isin({"Read", "ReadFile", "read_file", "View"})].copy()
    read_tc["file_path"] = read_tc["tool_input"].apply(
        lambda ti: ti.get("file_path") or ti.get("path") if isinstance(ti, dict) else None)
    read_tc["rel_path"] = read_tc.apply(lambda r: make_rel(r["file_path"], r["cwd"]), axis=1)
    read_files = read_tc[read_tc["rel_path"].notna() & read_tc["rel_path"].str.contains(r"\.\w{1,6}$", na=False)]
    if not read_files.empty:
        top_reads = (
            read_files.groupby("rel_path")
            .agg(reads=("message_id", "count"), sessions=("session_id", "nunique"))
            .sort_values("reads", ascending=False).head(15)
            .reset_index()
            .rename(columns={"rel_path": "File", "reads": "Times read", "sessions": "Sessions"})
        )
        report["tables"].append(("Most frequently read files", top_reads))

        report["charts"].append(("Which files does the AI read most?",
            fig_to_b64(chart_barh(
                top_reads["File"].head(12).tolist(),
                top_reads["Times read"].head(12).tolist(),
                "Most Read Files", "Times read", C_PRIMARY))))

    # ═══════════════════════════════════════════════════════
    # TABLES: Most common shell commands
    # ═══════════════════════════════════════════════════════
    shell_tc = u_tc[u_tc["group"] == "shell"].copy()

    def extract_command(ti):
        if isinstance(ti, dict):
            return ti.get("command") or ti.get("cmd") or ""
        return ""

    def classify_command(cmd):
        """Classify a shell command into a category."""
        if not cmd: return None
        cmd_lower = cmd.strip().lower()
        # Extract the base command (first word or first pipe segment)
        base = cmd_lower.split("|")[0].strip().split()[0] if cmd_lower.split() else ""
        if base in ("git",):
            # Sub-command level for git
            parts = cmd_lower.split()
            sub = parts[1] if len(parts) > 1 else ""
            return f"git {sub}"
        if base in ("cd", "ls", "pwd", "find", "cat", "head", "tail", "wc", "du", "df"):
            return base
        if base in ("npm", "npx", "yarn", "pnpm", "bun"):
            parts = cmd_lower.split()
            sub = parts[1] if len(parts) > 1 else ""
            return f"{base} {sub}"
        if base in ("python", "python3", "pip", "uv", "pip3"):
            return base
        if base in ("gradle", "gradlew", "./gradlew"):
            return "gradle"
        if base in ("docker", "docker-compose"):
            parts = cmd_lower.split()
            sub = parts[1] if len(parts) > 1 else ""
            return f"{base} {sub}"
        if base in ("rg", "grep", "ag"):
            return "grep/search"
        if base in ("curl", "wget"):
            return base
        if base in ("make",):
            return "make"
        if base in ("kubectl", "helm"):
            parts = cmd_lower.split()
            sub = parts[1] if len(parts) > 1 else ""
            return f"{base} {sub}"
        return base if base else None

    shell_tc["command"] = shell_tc["tool_input"].apply(extract_command)
    shell_tc["cmd_category"] = shell_tc["command"].apply(classify_command)
    shell_tc = shell_tc[shell_tc["cmd_category"].notna()]

    if not shell_tc.empty:
        cmd_freq = (
            shell_tc.groupby("cmd_category")
            .agg(runs=("message_id", "count"), sessions=("session_id", "nunique"))
            .sort_values("runs", ascending=False).head(15)
            .reset_index()
            .rename(columns={"cmd_category": "Command", "runs": "Times run", "sessions": "Sessions"})
        )
        report["tables"].append(("Most common shell commands", cmd_freq))

        report["charts"].append(("What commands does the AI run?",
            fig_to_b64(chart_barh(
                cmd_freq["Command"].head(12).tolist(),
                cmd_freq["Times run"].head(12).tolist(),
                "Most Common Shell Commands", "Times run", C_SECONDARY))))

    # ═══════════════════════════════════════════════════════
    # TABLES: Files @-mentioned in prompts
    # ═══════════════════════════════════════════════════════
    at_mentions = []
    for _, row in user_text.iterrows():
        clean = row["clean"]
        # Match @path/to/file.ext or @filename.ext patterns
        mentions = re.findall(r"@([\w./-]+\.\w{1,6})\b", clean)
        # Also match @path/to/dir/ patterns (trailing slash)
        mentions += re.findall(r"@([\w./-]+/)\b", clean)
        # Also match @packages/name or @components/name style
        mentions += re.findall(r"@([\w-]+/[\w./-]+)", clean)
        for m in mentions:
            at_mentions.append({"file": m, "session_id": row["session_id"]})

    if at_mentions:
        at_df = pd.DataFrame(at_mentions)
        at_freq = (
            at_df.groupby("file")
            .agg(mentions=("session_id", "count"), sessions=("session_id", "nunique"))
            .sort_values("mentions", ascending=False).head(15)
            .reset_index()
            .rename(columns={"file": "File / path", "mentions": "Times @-mentioned", "sessions": "Sessions"})
        )
        report["tables"].append(("Files and paths @-mentioned in prompts", at_freq))

        report["charts"].append(("Which files does the developer explicitly reference?",
            fig_to_b64(chart_barh(
                at_freq["File / path"].head(10).tolist(),
                at_freq["Times @-mentioned"].head(10).tolist(),
                "Most @-mentioned Files in Prompts",
                "Times mentioned", C_PURPLE))))

    # ═══════════════════════════════════════════════════════
    # TABLES: File dependency / co-occurrence
    # ═══════════════════════════════════════════════════════
    # Collect all files touched (read + edit) per session, find pairs
    all_file_tc = u_tc[u_tc["tool_name"].isin(
        {"Read","ReadFile","read_file","View","Edit","Write","MultiEdit","MultiEditTool","Grep","Glob"})].copy()
    all_file_tc["fp"] = all_file_tc["tool_input"].apply(
        lambda ti: (ti.get("file_path") or ti.get("path") or ti.get("target_file"))
        if isinstance(ti, dict) else None)
    all_file_tc["rel"] = all_file_tc.apply(lambda r: make_rel(r["fp"], r["cwd"]), axis=1)
    all_file_tc = all_file_tc[
        all_file_tc["rel"].notna() & all_file_tc["rel"].str.contains(r"\.\w{1,6}$", na=False)]

    session_file_sets = all_file_tc.groupby("session_id")["rel"].apply(lambda x: sorted(set(x)))
    pair_counter = Counter()
    for files in session_file_sets:
        if len(files) < 2: continue
        for a, b in combinations(files[:40], 2):
            pair_counter[tuple(sorted([a, b]))] += 1

    # Keep only top pairs with weight >= 2 to avoid noise
    min_weight = 2
    filtered_pairs = [(p, w) for p, w in pair_counter.most_common(50) if w >= min_weight]
    # Limit nodes to keep graph readable
    if len(filtered_pairs) > 20:
        filtered_pairs = filtered_pairs[:20]

    if filtered_pairs:
        node_set = set()
        for (a, b), _ in filtered_pairs:
            node_set.add(a); node_set.add(b)
        nodes_list = sorted(node_set)
        node_idx = {n: i for i, n in enumerate(nodes_list)}

        # Count how many edges each node has (for sizing)
        node_degree = Counter()
        for (a, b), w in filtered_pairs:
            node_degree[a] += w; node_degree[b] += w
        max_degree = max(node_degree.values())

        nodes_json = json.dumps([
            {"id": i, "full": f, "label": os.path.basename(f),
             "degree": node_degree.get(f, 1)}
            for i, f in enumerate(nodes_list)
        ])
        edges_json = json.dumps([
            {"source": node_idx[a], "target": node_idx[b], "weight": w}
            for (a, b), w in filtered_pairs
        ])
        max_w = max(w for _, w in filtered_pairs)


        graph_html = textwrap.dedent(f"""\
        <div class="card">
        <h2>File dependency graph</h2>
        <p style="color:#777;font-size:0.85em;margin-bottom:8px;">
          Files that are touched together in the same session. Bigger nodes = more connections.
          Thicker lines = appeared together in more sessions. Hover for full path. Drag to rearrange.</p>
        <div style="position:relative;">
        <canvas id="depGraph"
                style="width:100%;height:650px;border:1px solid #E0E4E8;border-radius:8px;
                       cursor:grab;background:#FAFBFC;"></canvas>
        <div id="depTooltip" style="display:none;position:absolute;pointer-events:none;
             background:rgba(44,62,80,0.95);color:#fff;padding:10px 14px;border-radius:6px;
             font:bold 13px/1.4 monospace;max-width:380px;word-wrap:break-word;
             overflow-wrap:break-word;white-space:normal;z-index:10;
             box-shadow:0 2px 8px rgba(0,0,0,0.25);">
          <div id="depTtPath"></div>
          <div id="depTtInfo" style="font:12px sans-serif;color:#BDC3C7;margin-top:4px;"></div>
        </div>
        </div>
        <script>
        (function() {{
          const nodes = {nodes_json};
          const edges = {edges_json};
          const maxW = {max_w};
          const maxDeg = {max_degree};
          const canvas = document.getElementById('depGraph');
          const ctx = canvas.getContext('2d');

          // HiDPI / Retina support — scale canvas backing store
          const dpr = window.devicePixelRatio || 1;
          const rect = canvas.getBoundingClientRect();
          const W = Math.round(rect.width);
          const H = Math.round(rect.height);
          canvas.width = W * dpr;
          canvas.height = H * dpr;
          ctx.scale(dpr, dpr);
          // All drawing now uses logical W x H coordinates
          const PAD = 80;
          const N = nodes.length;

          // Circular initial layout — much better starting point
          nodes.forEach((n, i) => {{
            const angle = (2 * Math.PI * i) / N;
            const rx = (W - PAD * 2) * 0.35;
            const ry = (H - PAD * 2) * 0.35;
            n.x = W / 2 + rx * Math.cos(angle);
            n.y = H / 2 + ry * Math.sin(angle);
            n.vx = 0; n.vy = 0;
          }});

          let dragNode = null, hoverNode = null;
          let settled = false, frameCount = 0;

          // Build adjacency for highlight
          const adj = new Map();
          nodes.forEach(n => adj.set(n.id, new Set()));
          edges.forEach(e => {{
            adj.get(e.source).add(e.target);
            adj.get(e.target).add(e.source);
          }});

          function tick() {{
            if (settled && !dragNode) return;
            const repulseK = 30000;
            const attractK = 0.0008;
            const centerK = 0.01;
            const damp = 0.7;
            const idealLen = Math.min(W, H) / (Math.sqrt(N) + 1);

            // Reset forces
            nodes.forEach(n => {{ n.fx = 0; n.fy = 0; }});

            // Repulsion (all pairs)
            for (let i = 0; i < N; i++) {{
              for (let j = i + 1; j < N; j++) {{
                let dx = nodes[j].x - nodes[i].x;
                let dy = nodes[j].y - nodes[i].y;
                let d = Math.sqrt(dx * dx + dy * dy) || 1;
                let f = repulseK / (d * d);
                let fx = (dx / d) * f, fy = (dy / d) * f;
                nodes[i].fx -= fx; nodes[i].fy -= fy;
                nodes[j].fx += fx; nodes[j].fy += fy;
              }}
            }}

            // Attraction (edges) — spring toward ideal length
            edges.forEach(e => {{
              let a = nodes[e.source], b = nodes[e.target];
              let dx = b.x - a.x, dy = b.y - a.y;
              let d = Math.sqrt(dx * dx + dy * dy) || 1;
              let f = attractK * (d - idealLen * 0.6) * (e.weight / maxW);
              let fx = (dx / d) * f, fy = (dy / d) * f;
              a.fx += fx; a.fy += fy;
              b.fx -= fx; b.fy -= fy;
            }});

            // Center gravity
            nodes.forEach(n => {{
              n.fx += (W / 2 - n.x) * centerK;
              n.fy += (H / 2 - n.y) * centerK;
            }});

            // Apply
            let totalMove = 0;
            nodes.forEach(n => {{
              if (n === dragNode) return;
              n.vx = (n.vx + n.fx) * damp;
              n.vy = (n.vy + n.fy) * damp;
              n.x += n.vx; n.y += n.vy;
              n.x = Math.max(PAD, Math.min(W - PAD, n.x));
              n.y = Math.max(PAD, Math.min(H - PAD, n.y));
              totalMove += Math.abs(n.vx) + Math.abs(n.vy);
            }});

            frameCount++;
            if (frameCount > 300 && totalMove < 0.5) settled = true;
          }}

          function nodeRadius(n) {{
            return 8 + (n.degree / maxDeg) * 14;
          }}

          function draw() {{
            ctx.clearRect(0, 0, W, H);
            const hoverId = hoverNode ? hoverNode.id : -1;
            const hoverAdj = hoverNode ? adj.get(hoverNode.id) : new Set();

            // Edges
            edges.forEach(e => {{
              let a = nodes[e.source], b = nodes[e.target];
              let highlight = (hoverId === e.source || hoverId === e.target);
              ctx.beginPath();
              ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y);
              ctx.strokeStyle = highlight ? 'rgba(231,76,60,0.6)' : 'rgba(52,73,94,0.15)';
              ctx.lineWidth = highlight
                ? 2 + (e.weight / maxW) * 4
                : 0.8 + (e.weight / maxW) * 3;
              ctx.stroke();
              // Weight label on highlighted edges
              if (highlight) {{
                let mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
                ctx.font = 'bold 12px sans-serif';
                ctx.fillStyle = '#E74C3C';
                ctx.textAlign = 'center';
                ctx.fillText(e.weight + 's', mx, my - 5);
              }}
            }});

            // Nodes
            nodes.forEach(n => {{
              let isHover = n === hoverNode;
              let isAdj = hoverNode && hoverAdj.has(n.id);
              let dimmed = hoverNode && !isHover && !isAdj;
              let r = nodeRadius(n);

              ctx.beginPath();
              ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
              ctx.fillStyle = isHover ? '#E74C3C'
                : isAdj ? '#E67E22'
                : dimmed ? 'rgba(52,152,219,0.3)' : '#3498DB';
              ctx.fill();
              ctx.strokeStyle = 'white'; ctx.lineWidth = 2.5; ctx.stroke();

              // Label — only show for hovered, its neighbors, or if not dimmed
              if (!dimmed || !hoverNode) {{
                ctx.font = (isHover || isAdj) ? 'bold 13px sans-serif' : '12px sans-serif';
                ctx.fillStyle = dimmed ? 'rgba(44,62,80,0.3)' : '#2C3E50';
                ctx.textAlign = 'center';
                ctx.fillText(n.label, n.x, n.y - r - 6);
              }}
            }});

            // Tooltip — position the HTML overlay
            const tooltip = document.getElementById('depTooltip');
            const ttPath = document.getElementById('depTtPath');
            const ttInfo = document.getElementById('depTtInfo');
            if (hoverNode) {{
              ttPath.textContent = hoverNode.full;
              ttInfo.textContent = hoverNode.degree + ' co-occurrences';
              tooltip.style.display = 'block';
              // Convert logical coords to CSS coords relative to container
              let cRect = canvas.getBoundingClientRect();
              let cssX = hoverNode.x * cRect.width / W;
              let cssY = hoverNode.y * cRect.height / H + nodeRadius(hoverNode) + 12;
              // Keep within bounds
              tooltip.style.left = Math.max(4, Math.min(cssX - 100, cRect.width - 200)) + 'px';
              tooltip.style.top = cssY + 'px';
            }} else {{
              tooltip.style.display = 'none';
            }}
          }}

          function findNode(x, y) {{
            for (let n of nodes) {{
              let r = nodeRadius(n) + 4;
              if ((n.x - x) ** 2 + (n.y - y) ** 2 < r * r) return n;
            }}
            return null;
          }}

          function getPos(e) {{
            let rect = canvas.getBoundingClientRect();
            return [(e.clientX - rect.left) * W / rect.width,
                    (e.clientY - rect.top) * H / rect.height];
          }}

          canvas.addEventListener('mousedown', e => {{
            let [x, y] = getPos(e);
            dragNode = findNode(x, y);
            if (dragNode) {{ canvas.style.cursor = 'grabbing'; settled = false; }}
          }});
          canvas.addEventListener('mousemove', e => {{
            let [x, y] = getPos(e);
            if (dragNode) {{
              dragNode.x = x; dragNode.y = y;
              dragNode.vx = 0; dragNode.vy = 0;
            }}
            hoverNode = findNode(x, y);
            canvas.style.cursor = hoverNode ? (dragNode ? 'grabbing' : 'pointer') : 'grab';
          }});
          canvas.addEventListener('mouseup', () => {{ dragNode = null; canvas.style.cursor = 'grab'; }});
          canvas.addEventListener('mouseleave', () => {{ dragNode = null; hoverNode = null; }});

          function animate() {{
            tick(); draw(); requestAnimationFrame(animate);
          }}
          animate();
        }})();
        </script>
        </div>
        """)
        report["raw_html"] = report.get("raw_html", [])
        report["raw_html"].append(graph_html)

    return report


# %% [markdown]
# ## HTML template

# %%
HTML_TEMPLATE = textwrap.dedent("""\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Developer Report — {email}</title>
<style>
  * {{ margin: 0; padding: 0; box-sizing: border-box; }}
  body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
         background: #F7F8FA; color: #2C3E50; padding: 24px; max-width: 920px; margin: 0 auto;
         line-height: 1.5; }}
  h1 {{ font-size: 1.5em; margin: 16px 0 8px; border-bottom: 3px solid #2C3E50; padding-bottom: 8px; }}
  h2 {{ font-size: 1.15em; margin: 16px 0 10px; color: #34495E; }}
  .subtitle {{ color: #7F8C8D; font-size: 0.85em; margin-bottom: 20px; }}
  .card {{ background: white; border-radius: 10px; padding: 18px 22px; margin: 14px 0;
           box-shadow: 0 1px 4px rgba(0,0,0,0.08); }}
  .kv {{ display: grid; grid-template-columns: 1fr; gap: 10px; }}
  .kv-row {{ display: flex; gap: 8px; }}
  .kv-row .label {{ font-weight: 600; color: #555; min-width: 200px; flex-shrink: 0; }}
  .kv-row .value {{ color: #222; }}
  .chart {{ margin: 10px 0; text-align: center; }}
  .chart img {{ max-width: 100%; border-radius: 6px; }}

  /* ── Table styling ── */
  .table-wrap {{ overflow-x: auto; -webkit-overflow-scrolling: touch; max-width: 100%; }}
  table {{ border-collapse: separate; border-spacing: 0; width: 100%; margin: 10px 0;
           font-size: 0.88em; border-radius: 8px; overflow: hidden;
           border: 1px solid #E0E4E8; }}
  thead th {{ background: #34495E; color: white; text-align: left; padding: 10px 14px;
              font-weight: 600; font-size: 0.85em; text-transform: uppercase; letter-spacing: 0.3px; }}
  tbody td {{ padding: 9px 14px; border-bottom: 1px solid #EEF0F2; color: #333;
              overflow-wrap: break-word; word-wrap: break-word;
              max-width: 500px; }}
  tbody tr:nth-child(even) td {{ background: #F8F9FB; }}
  tbody tr:hover td {{ background: #EBF0F5; }}
  tbody tr:last-child td {{ border-bottom: none; }}

  .persona {{ background: #EBF5FB; border-left: 4px solid #3498DB; padding: 12px 16px;
              margin: 8px 0; border-radius: 0 8px 8px 0; }}
  .persona strong {{ color: #2C3E50; font-size: 1.05em; }}
  .persona p {{ margin-top: 4px; color: #555; font-size: 0.92em; }}
  .sample {{ background: #F4F6F8; padding: 10px 14px; margin: 6px 0; border-radius: 6px;
             font-size: 0.83em; line-height: 1.5; font-family: "SF Mono", Menlo, monospace;
             white-space: pre-wrap; word-break: break-word; color: #444;
             border-left: 3px solid #BDC3C7; }}
  .footer {{ color: #AAA; font-size: 0.75em; margin-top: 40px; text-align: center;
             padding-top: 16px; border-top: 1px solid #E0E4E8; }}
</style>
</head>
<body>
<h1>Developer Report: {email}</h1>
<p class="subtitle">Generated {generated_at} &bull;
   Filters: org = {org}, repo prefix = {repo_prefix}</p>

{persona_html}

{sections_html}

{charts_html}

{tables_html}

{raw_html}

{samples_html}

<p class="footer">Generated by qc_trace developer analytics</p>
</body>
</html>
""")


def render_report(report):
    email = report["email"]

    # Sections
    sections_html = ""
    for title, rows in report.get("sections", []):
        inner = ""
        for label, value in rows:
            inner += f'<div class="kv-row"><span class="label">{label}</span><span class="value">{value}</span></div>\n'
        sections_html += f'<div class="card"><h2>{title}</h2><div class="kv">{inner}</div></div>\n'

    # Personas
    persona_html = ""
    if report.get("personas"):
        persona_html = '<div class="card"><h2>Developer Persona</h2>\n'
        for name, desc in report["personas"]:
            persona_html += f'<div class="persona"><strong>{name}</strong><p>{desc}</p></div>\n'
        persona_html += "</div>\n"

    # Charts
    charts_html = ""
    for title, b64 in report.get("charts", []):
        charts_html += (f'<div class="card"><h2>{title}</h2>'
                        f'<div class="chart"><img src="data:image/png;base64,{b64}" alt="{title}"></div></div>\n')

    # Tables
    tables_html = ""
    for title, df in report.get("tables", []):
        table_html = df.to_html(index=False, classes="", border=0)
        tables_html += f'<div class="card"><h2>{title}</h2><div class="table-wrap">{table_html}</div></div>\n'

    # Samples
    samples_html = ""
    if report.get("first_msg_samples"):
        samples_html = '<div class="card"><h2>Sample opening prompts (longest first)</h2>\n'
        for s in report["first_msg_samples"]:
            samples_html += f'<div class="sample">{html_mod.escape(s)}</div>\n'
        samples_html += "</div>\n"

    # Raw HTML blocks (interactive graphs, etc.)
    raw_html = "\n".join(report.get("raw_html", []))

    return HTML_TEMPLATE.format(
        email=email,
        generated_at=datetime.now().strftime("%Y-%m-%d %H:%M"),
        org=ORG_FILTER or "all",
        repo_prefix=REPO_PREFIX or "all",
        sections_html=sections_html,
        persona_html=persona_html,
        charts_html=charts_html,
        tables_html=tables_html,
        raw_html=raw_html,
        samples_html=samples_html,
    )


# %% [markdown]
# ## Run

# %%
if USER_FILTER:
    target_emails = [USER_FILTER]
else:
    target_emails = sorted(sess["user_email"].unique())

for email in target_emails:
    display(Markdown(f"**Generating report for `{email}`...**"))
    report = compute_dev_report(email)
    html = render_report(report)
    safe_name = re.sub(r"[^a-zA-Z0-9_.-]", "_", email)
    out_path = os.path.join(OUTPUT_DIR, f"{safe_name}.html")
    with open(out_path, "w") as f:
        f.write(html)
    display(Markdown(f"Saved: `{out_path}`"))

display(Markdown(f"**Done. {len(target_emails)} report(s) in `{OUTPUT_DIR}/`**"))

# %% [markdown]
# ---
# *End of notebook.*
