# %% [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)
pd.set_option("future.no_silent_downcasting", True)

def _connect_with_retry(max_retries=3):
    import time
    for attempt in range(max_retries):
        try:
            c = init()
            # Test with a simple query
            with c.cursor() as cur:
                cur.execute("SELECT 1")
            return c
        except Exception as e:
            if attempt < max_retries - 1:
                time.sleep(3)
            else:
                raise

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

def _query(sql, params=()):
    """query_df with auto-reconnect on SSL errors."""
    global conn
    import time
    for attempt in range(3):
        try:
            return _query(sql, params)
        except Exception:
            if attempt < 2:
                time.sleep(3)
                try: conn.close()
                except Exception: pass
                conn = _connect_with_retry()
            else:
                raise

# %% [markdown]
# ## Filters

# %%
# --- CONFIGURE ---
ORG_FILTER  = None            # e.g. "pratilipi-platform"
REPO_PREFIX = None            # 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)

# Pre-declare TARGET_EMAILS here so we can filter data loading (defined again below for report loop)
_TARGET_EMAILS = [
    "sagarsarkale.work@gmail.com",
    "54902986+zzjjaayy@users.noreply.github.com",
    "ajaysingh07032003@gmail.com",
    "patil.vaibhav2147.vp@gmail.com",
]
if _TARGET_EMAILS and not USER_FILTER:
    clauses.append("s.user_email = ANY(%s)"); params.append(_TARGET_EMAILS)

where = f"WHERE {' AND '.join(clauses)}" if clauses else ""

sess = _query(f"""
    SELECT s.*,
           DATE_TRUNC('week', COALESCE(msg_ts.min_ts, s.first_seen))::date AS week
    FROM sessions s
    LEFT JOIN (
        SELECT m.session_id, MIN(m.timestamp) AS min_ts
        FROM messages m GROUP BY m.session_id
    ) msg_ts ON msg_ts.session_id = s.id
    {where}
    ORDER BY s.first_seen
""", tuple(params))

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

sids = sess["id"].tolist()
print(f"[data] {len(sess)} sessions loaded", flush=True)

print("[data] Loading messages...", flush=True)
msgs = _query("""
    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,))
print(f"[data] {len(msgs)} messages loaded", flush=True)

print("[data] Loading tool calls...", flush=True)
tc = _query("""
    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, s.repo_name
    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,))
print(f"[data] {len(tc)} tool calls loaded", flush=True)

print("[data] Loading tool results...", flush=True)
tr = _query("""
    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,))
print(f"[data] {len(tr)} tool results loaded", flush=True)

# %% [markdown]
# ## Helpers

# %%
SHELL   = {"Bash", "shell_command"}
EDIT    = {"Edit", "Write", "MultiEdit", "MultiEditTool"}
EXPLORE = {"Read", "Grep", "Glob", "View", "ReadFile", "read_file",
           "grep_search", "codebase_search", "list_dir", "file_search"}
# exec_command (Codex CLI) mixes reads, edits, and shell — sub-classify by command content
_EXEC_EXPLORE_RE = re.compile(
    r"^\s*(?:cat|nl|sed\s+-n|head|tail|less|bat)\s|"     # file viewers
    r"^\s*(?:rg|grep|ag|find|fd|ls|tree|wc|du)\s|"       # search / list
    r"^\s*(?:nl\s+-ba)\s",                                # numbered listing
    re.IGNORECASE)
_EXEC_EDIT_RE = re.compile(
    r"cat\s*>|cat\s*<<|tee\s|sed\s+-i|"                  # file writes
    r">\s*\S+|>>\s*\S+|"                                  # redirects to files
    r"patch\s|mv\s|cp\s",                                 # file manipulation
    re.IGNORECASE)


def classify_exec_command(tool_input):
    """Sub-classify Codex exec_command into explore/edit/shell."""
    if not isinstance(tool_input, dict):
        return "shell"
    cmd = (tool_input.get("cmd") or "").strip()
    if not cmd:
        return "shell"
    # echo used for status updates, not real edits — classify as shell
    if cmd.lower().startswith("echo ") and ">" not in cmd:
        return "shell"
    if _EXEC_EDIT_RE.search(cmd):
        return "edit"
    if _EXEC_EXPLORE_RE.search(cmd):
        return "explore"
    return "shell"

# 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
src app usr com org main java kotlin xml gradle resources assets
lib bin etc var tmp home users desktop documents downloads
node_modules dist build target debug release proto buf
git github gitlab bitbucket noreply reply mailto
http https www ftp ssh
json yaml yml toml cfg ini env log pid lock
png jpg jpeg gif svg ico bmp webp
html css scss less tsx jsx vue svelte
android ios linux darwin windows macos ubuntu
api server client config util utils helper helpers
impl spec test tests mock mocks fixture fixtures
""".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 isinstance(fp, str) and isinstance(cwd, str) and fp.startswith(cwd):
        return fp[len(cwd):].lstrip("/") or fp
    return fp


def extract_files_from_command(cmd):
    """Extract file paths from a raw shell command string.

    Looks for tokens that look like file paths — contain a dot with extension
    or contain a slash. Filters out glob patterns like *.py.
    """
    if not cmd:
        return []
    files = []
    # Match absolute/relative paths with file extensions
    for m in re.findall(r'(?:^|\s)((?:/[\w./-]+|[\w./-]*/)[\w.-]+\.[\w]{1,8})', cmd):
        files.append(m)
    # Match quoted paths
    for m in re.findall(r'["\']([^"\']+\.[\w]{1,8})["\']', cmd):
        if m not in files:
            files.append(m)
    # Filter out glob patterns and very short noise
    files = [f for f in files if not f.startswith("*") and len(f) > 3]
    return files


def shorten_path(fp):
    """Keep last 3 path components for readability."""
    parts = fp.strip().split("/")
    # Remove empty parts from leading /
    parts = [p for p in parts if p]
    if len(parts) <= 3:
        return "/".join(parts)
    return ".../" + "/".join(parts[-3:])

def fig_to_b64(fig):
    buf = io.BytesIO()
    fig.savefig(buf, format="png", dpi=300, 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, file paths, code artifacts, and stopwords."""
    text = text.encode("ascii", errors="ignore").decode("ascii")
    # Remove file paths: /foo/bar, ./foo/bar, src/components/Foo.tsx, @path/file
    text = re.sub(r"@?(?:\.?/[\w./@-]+|[\w.-]+/[\w./@-]+)", " ", text)
    # Remove dot-qualified names: com.pratilipi.comics, java.util.List
    text = re.sub(r"\b\w+(?:\.\w+){2,}\b", " ", text)
    # Remove URLs
    text = re.sub(r"https?://\S+", " ", text)
    # Remove email addresses
    text = re.sub(r"\S+@\S+\.\S+", " ", text)
    # Remove hex strings (hashes, keys, etc.)
    text = re.sub(r"\b[0-9a-fA-F]{8,}\b", " ", text)
    # Remove camelCase/PascalCase tokens and split them (e.g. getUserName -> get user name)
    text = re.sub(r"\b[a-z]+(?:[A-Z][a-z]+)+\b", lambda m: re.sub(r"([a-z])([A-Z])", r"\1 \2", m.group()), text)
    # Remove snake_case tokens that look like code identifiers (3+ segments)
    text = re.sub(r"\b\w+(?:_\w+){2,}\b", " ", text)
    # Remove anything that looks like a file extension reference: .py, .tsx, .json etc.
    text = re.sub(r"\.\w{1,5}\b", " ", text)
    text = text.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
        ec = "white" if len(cols_colors) > 1 else "none"
        ax.bar(x + offset, week_df[col], width=width, color=color, label=label, edgecolor=ec)
    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 ──
    def _tool_group(row):
        t = row["tool_name"]
        if t == "exec_command":
            return classify_exec_command(row["tool_input"])
        if t in SHELL:   return "shell"
        if t in EDIT:    return "edit"
        if t in EXPLORE: return "explore"
        return "other"
    u_tc["group"] = u_tc.apply(_tool_group, axis=1)

    # ── Compute active session duration from message timestamps ──
    # Only count gaps < 30 min as active time. Longer gaps = user walked away.
    IDLE_THRESHOLD_SEC = 30 * 60  # 30 minutes

    def _active_minutes(ts_series):
        ts = ts_series.sort_values()
        if len(ts) < 2:
            return 0.0
        gaps = ts.diff().dropna().dt.total_seconds()
        active_secs = gaps[gaps < IDLE_THRESHOLD_SEC].sum()
        return active_secs / 60.0

    msg_duration = (
        u_msgs.groupby("session_id")["timestamp"]
        .apply(_active_minutes)
        .rename("duration_min")
    )

    # ── 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")
            .join(msg_duration)
            .fillna({"duration_min": 0})
            .infer_objects(copy=False)
            .fillna(0))

    # ── Git commit detection (check shell + exec_command) ──
    commit_sids = set()
    for _, r in u_tc[u_tc["tool_name"].isin(SHELL | {"exec_command"})].iterrows():
        ti = r["tool_input"]
        if isinstance(ti, dict):
            ti = ti.get("command") or ti.get("cmd") or 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
        # Standard Edit/Write tools
        old = ti.get("old_string", "") or ""
        new = ti.get("new_string", "") or ti.get("content", "") or ""
        # Codex exec_command edits: extract content from heredoc or the whole cmd
        if r["tool_name"] == "exec_command" and not new:
            cmd = ti.get("cmd", "") or ""
            # Extract heredoc: cat > file <<'EOF'\ncontent\nEOF
            heredoc_m = re.search(r"<<'?(\w+)'?\n([\s\S]*?)\n\1", cmd)
            if heredoc_m:
                new = heredoc_m.group(2)
            else:
                # Count lines in the command itself as a rough proxy
                new = cmd
        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_msgs['timestamp'].min():%Y-%m-%d} to {u_msgs['timestamp'].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))

    # AI assistant distribution pie chart
    source_counts = u_sess["source"].value_counts()
    if len(source_counts) >= 1:
        fig, ax = plt.subplots(figsize=(4, 3))
        colors = sns.color_palette("muted", len(source_counts))
        wedges, texts, autotexts = ax.pie(
            source_counts.values, labels=source_counts.index, colors=colors,
            autopct=lambda p: f"{p:.0f}%\n({int(round(p * n_sess / 100))})",
            startangle=140, pctdistance=0.75,
            wedgeprops={"edgecolor": "white", "linewidth": 1.5})
        for t in texts:
            t.set_fontsize(11)
        for t in autotexts:
            t.set_fontsize(9); t.set_fontweight("bold")
        ax.set_title("Which AI assistants does this developer use?", fontsize=13, fontweight="bold")
        report["charts"].insert(0, ("AI assistant usage", fig_to_b64(fig)))

    # ═══════════════════════════════════════════════════════
    # 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: Context reconstruction cost (enhanced warmup)
    # ═══════════════════════════════════════════════════════
    warmup_records = []
    for sid, grp in u_msgs.groupby("session_id"):
        sid_tc = u_tc[u_tc["session_id"] == sid].sort_values("timestamp")
        edit_tc_first = sid_tc[sid_tc["group"] == "edit"]
        explore_tc = sid_tc[sid_tc["group"] == "explore"]
        if edit_tc_first.empty:
            continue
        first_edit_ts = edit_tc_first["timestamp"].iloc[0]
        explore_before = explore_tc[explore_tc["timestamp"] < first_edit_ts]
        # Time from session start to first edit
        session_start = grp["timestamp"].min()
        warmup_sec = (first_edit_ts - session_start).total_seconds()
        warmup_records.append({
            "session_id": sid,
            "explore_before_edit": len(explore_before),
            "warmup_sec": warmup_sec,
            "warmup_min": warmup_sec / 60.0,
        })

    if warmup_records:
        warmup_df = pd.DataFrame(warmup_records)
        med_explore = warmup_df["explore_before_edit"].median()
        med_warmup = warmup_df["warmup_min"].median()
        total_warmup_min = warmup_df["warmup_min"].sum()
        total_explore_before = int(warmup_df["explore_before_edit"].sum())
        report["sections"].append(("Context reconstruction cost", [
            ("Explore calls before first edit",
                f"Median {med_explore:.0f} read/search calls before the AI writes any code"),
            ("Time to first edit",
                f"Median {med_warmup:.1f} minutes from session start to first code change"),
            ("Total warmup time across all sessions",
                f"{total_warmup_min:.0f} minutes spent on exploration before editing "
                f"({total_explore_before:,} explore calls)"),
            ("What this means",
                f"The AI spends its first ~{med_warmup:.1f} minutes of every session "
                f"just figuring out where it is. Pre-loading {min(10, int(med_explore))} "
                f"key files could cut this warmup significantly."),
        ]))

    # ═══════════════════════════════════════════════════════
    # SECTION: Redundancy — repeated file reads across sessions
    # ═══════════════════════════════════════════════════════
    read_tc_all = u_tc[u_tc["tool_name"].isin({"Read", "ReadFile", "read_file", "View"})].copy()
    read_tc_all["fp"] = read_tc_all["tool_input"].apply(
        lambda ti: ti.get("file_path") or ti.get("path") if isinstance(ti, dict) else None)
    read_tc_all["rel"] = read_tc_all.apply(lambda r: make_rel(r["fp"], r["cwd"]), axis=1)
    read_tc_all = read_tc_all[read_tc_all["rel"].notna() & read_tc_all["rel"].str.contains(r"\.\w{1,6}$", na=False)]

    if not read_tc_all.empty:
        file_session_reads = (
            read_tc_all.groupby("rel", as_index=False)
            .agg(total_reads=("message_id", "count"), sessions=("session_id", "nunique"))
            .sort_values("total_reads", ascending=False)
        )
        # Files read in 3+ sessions = the AI keeps re-discovering them
        redundant = file_session_reads[file_session_reads["sessions"] >= 3].copy()
        if not redundant.empty:
            redundant_reads = int(redundant["total_reads"].sum() - redundant["sessions"].sum())
            top_redundant = redundant.head(10)
            top_files_list = ", ".join(
                f"{os.path.basename(r['rel'])} ({r['total_reads']}× across {r['sessions']} sessions)"
                for _, r in top_redundant.head(5).iterrows()
            )
            report["sections"].append(("Redundant exploration — files the AI keeps re-reading", [
                ("Files read in 3+ sessions",
                    f"{len(redundant)} files are read again and again across multiple sessions"),
                ("Redundant read operations",
                    f"~{redundant_reads:,} reads could be eliminated if these files were pre-loaded as context"),
                ("Top repeat offenders", top_files_list),
                ("Recommendation",
                    f"If the top {min(10, len(redundant))} files were pre-loaded into every session, "
                    f"the AI would skip ~{redundant_reads:,} redundant reads, saving exploration time."),
            ]))

            # Table of redundant files
            redundant_table = (
                redundant.head(15)
                .rename(columns={"rel": "File", "total_reads": "Total reads", "sessions": "Sessions"})
            )
            report["tables"].append(("Files the AI keeps re-reading across sessions", redundant_table))

    # ═══════════════════════════════════════════════════════
    # SECTION: Self-correction detection (AI undoing its own work)
    # ═══════════════════════════════════════════════════════
    edit_calls = u_tc[u_tc["group"] == "edit"].sort_values(["session_id", "timestamp"]).copy()
    edit_calls["fp"] = edit_calls["tool_input"].apply(extract_path)

    revert_records = []
    for sid, grp in edit_calls.groupby("session_id"):
        file_edits_by_file = grp.groupby("fp")
        for fp, fgrp in file_edits_by_file:
            if fp is None or len(fgrp) < 2:
                continue
            prev_new = None
            n_reverts = 0
            for _, row in fgrp.iterrows():
                ti = row["tool_input"]
                if not isinstance(ti, dict):
                    prev_new = None
                    continue
                old_str = ti.get("old_string", "") or ""
                new_str = ti.get("new_string", "") or ti.get("content", "") or ""
                # Check if this edit undoes the previous one:
                # old_string of this edit contains or matches new_string of previous edit
                if prev_new and old_str and prev_new.strip() and old_str.strip():
                    # Fuzzy match: if >60% of prev new_string appears in current old_string
                    prev_clean = prev_new.strip()
                    old_clean = old_str.strip()
                    if (prev_clean in old_clean or old_clean in prev_clean) and len(prev_clean) > 10:
                        n_reverts += 1
                prev_new = new_str

            if n_reverts > 0:
                revert_records.append({
                    "session_id": sid,
                    "file": fp,
                    "total_edits": len(fgrp),
                    "self_corrections": n_reverts,
                })

    if revert_records:
        revert_df = pd.DataFrame(revert_records)
        total_reverts = int(revert_df["self_corrections"].sum())
        total_edits_with_reverts = int(revert_df["total_edits"].sum())
        sessions_with_reverts = revert_df["session_id"].nunique()
        revert_rate = total_reverts / max(total_edits_with_reverts, 1) * 100

        top_reverts = revert_df.sort_values("self_corrections", ascending=False).head(10)
        worst = top_reverts.iloc[0] if not top_reverts.empty else None

        section_items = [
            ("Self-corrections detected",
                f"{total_reverts} times the AI undid its own previous edit across {sessions_with_reverts} sessions"),
            ("Self-correction rate",
                f"{revert_rate:.0f}% of edits in affected files were corrections of the AI's own prior work"),
        ]
        if worst is not None:
            section_items.append(("Worst case",
                f"{os.path.basename(str(worst['file']))} — {int(worst['self_corrections'])} "
                f"self-corrections out of {int(worst['total_edits'])} edits in one session"))
        section_items.append(("What this means",
            "Self-corrections happen when the AI writes code, realizes it's wrong, and then edits "
            "the same lines again. High self-correction rates suggest unclear requirements or "
            "the AI guessing rather than understanding the intent."))

        report["sections"].append(("Self-correction waste — the AI undoing its own work", section_items))

        if len(revert_df) >= 3:
            revert_table = (
                top_reverts
                .assign(file=lambda df: df["file"].apply(lambda x: os.path.basename(str(x)) if x else ""))
                .rename(columns={
                    "session_id": "Session", "file": "File",
                    "total_edits": "Total edits", "self_corrections": "Self-corrections"
                })
            )
            report["tables"].append(("Sessions with most AI self-corrections", revert_table))

    # ═══════════════════════════════════════════════════════
    # SECTION: Commit gap analysis — why is commit rate low?
    # ═══════════════════════════════════════════════════════
    # Detect end-of-session git behavior
    git_review_sids = set()  # sessions ending with git diff/status/log (manual review)
    git_add_sids = set()     # sessions with git add but no commit
    for sid, grp in u_tc[u_tc["group"] == "shell"].groupby("session_id"):
        cmds = grp.sort_values("timestamp")
        for _, r in cmds.iterrows():
            ti = r["tool_input"]
            if isinstance(ti, dict):
                ti = json.dumps(ti)
            if not isinstance(ti, str):
                continue
            ti_lower = ti.lower()
            if any(x in ti_lower for x in ["git diff", "git status", "git log", "git show"]):
                git_review_sids.add(sid)
            if "git add" in ti_lower and sid not in commit_sids:
                git_add_sids.add(sid)

    # Sessions with edits but no shell commands at all (no git workflow)
    sessions_with_edits = set(u_tc[u_tc["group"] == "edit"]["session_id"].unique())
    sessions_with_shell = set(u_tc[u_tc["group"] == "shell"]["session_id"].unique())
    edit_no_shell = sessions_with_edits - sessions_with_shell

    n_review = len(git_review_sids - commit_sids)  # reviewed but didn't commit through AI
    n_staged = len(git_add_sids)
    n_edit_no_shell = len(edit_no_shell)

    if n_sess > 5:
        commit_gap_items = [
            ("Sessions ending with git review (diff/status/log) but no commit",
                f"{n_review} sessions — developer likely reviewed and committed manually outside the AI"),
            ("Sessions with git add but no commit",
                f"{n_staged} sessions — code was staged but not committed through the AI"),
            ("Sessions with edits but no shell commands at all",
                f"{n_edit_no_shell} sessions — developer may have committed outside the AI session entirely"),
        ]
        if commit_rate < 10 and n_review > 3:
            commit_gap_items.append(("Pattern detected",
                "This developer uses the AI for code generation but handles git workflow separately. "
                "The low commit rate doesn't mean abandoned work — it means the commit happens outside the AI."))
        elif commit_rate < 10 and n_edit_no_shell > len(sessions_with_edits) * 0.5:
            commit_gap_items.append(("Pattern detected",
                "Most edit sessions have no shell commands at all. The developer likely uses a "
                "separate terminal or IDE for git operations."))

        report["sections"].append(("Commit gap analysis — why isn't the AI committing?", commit_gap_items))

    # ═══════════════════════════════════════════════════════
    # SECTION: Workflow clusters — grouping sessions by file footprint
    # ═══════════════════════════════════════════════════════
    # Collect files per session from tool calls
    session_files = {}
    for sid, grp in u_tc[u_tc["tool_name"].isin(
            {"Read","ReadFile","read_file","View","Edit","Write","MultiEdit","MultiEditTool"})].groupby("session_id"):
        files = set()
        for _, r in grp.iterrows():
            fp = extract_path(r["tool_input"])
            if fp:
                rel = make_rel(fp, r.get("cwd") or "")
                if rel:
                    files.add(rel)
        if len(files) >= 2:
            session_files[sid] = files

    if len(session_files) >= 5:
        # Simple clustering: compute Jaccard similarity between all session pairs,
        # then group sessions that share >40% of files
        from collections import defaultdict
        file_to_sessions = defaultdict(set)
        for sid, files in session_files.items():
            for f in files:
                file_to_sessions[f].add(sid)

        # Find "core file sets" — files that appear together in 3+ sessions
        freq_files = {f: sids for f, sids in file_to_sessions.items() if len(sids) >= 3}
        if freq_files:
            # Group sessions by which frequent files they share
            # Use a greedy approach: find the largest cluster of sessions sharing 3+ files
            clusters = []
            used_sessions = set()
            # Sort files by frequency
            sorted_files = sorted(freq_files.items(), key=lambda x: -len(x[1]))

            for anchor_file, anchor_sids in sorted_files:
                # Find co-occurring files (files that appear in >50% of these sessions)
                cluster_files = {anchor_file}
                cluster_sids = anchor_sids - used_sessions
                if len(cluster_sids) < 3:
                    continue

                for other_file, other_sids in freq_files.items():
                    if other_file == anchor_file:
                        continue
                    overlap = len(cluster_sids & other_sids)
                    if overlap >= len(cluster_sids) * 0.4:
                        cluster_files.add(other_file)

                if len(cluster_files) >= 2:
                    clusters.append({
                        "files": sorted(cluster_files),
                        "sessions": len(cluster_sids),
                        "label": ", ".join(os.path.basename(f) for f in sorted(cluster_files)[:5])
                                 + (f" +{len(cluster_files)-5} more" if len(cluster_files) > 5 else ""),
                    })
                    used_sessions |= cluster_sids

                if len(clusters) >= 5:
                    break

            if clusters:
                clustered_count = sum(c["sessions"] for c in clusters)
                coverage = clustered_count / len(session_files) * 100

                cluster_items = [
                    ("Workflow clusters detected",
                        f"{len(clusters)} distinct work patterns covering {coverage:.0f}% of sessions"),
                ]
                for i, c in enumerate(clusters[:5], 1):
                    cluster_items.append(
                        (f"Cluster {i} ({c['sessions']} sessions)", c["label"]))

                cluster_items.append(("What this means",
                    "These clusters represent repeating work patterns. If the developer starts "
                    "a session touching one file from a cluster, pre-loading the rest of the cluster "
                    "would eliminate most exploration overhead."))

                report["sections"].append(("Workflow clusters — repeating work patterns", cluster_items))

    # ═══════════════════════════════════════════════════════
    # 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
    # ═══════════════════════════════════════════════════════

    # Combined session overview — 3 panels in one figure
    fig, axes = plt.subplots(1, 3, figsize=(14, 4))
    for ax, data, title, xlabel, color in [
        (axes[0], s_df["duration_min"].tolist(), "Session Duration (active min)", "Minutes", C_PRIMARY),
        (axes[1], s_df["user_msgs"].tolist(), "Prompts per Session", "Prompts", C_SECONDARY),
        (axes[2], s_df["tool_calls"].tolist(), "Tool Calls per Session", "Tool calls", C_SUCCESS),
    ]:
        clean = [x for x in data if pd.notna(x)]
        if clean:
            sns.histplot(clean, bins=min(25, max(5, len(set(clean)))), color=color,
                         edgecolor="white", ax=ax, kde=False)
            med = np.median(clean)
            ax.axvline(med, color=C_DANGER, linestyle="--", linewidth=1.2, label=f"Median: {med:.0f}")
            ax.legend(fontsize=8)
        ax.set_title(title, fontsize=11); ax.set_xlabel(xlabel); ax.set_ylabel("")
    fig.suptitle("Session patterns at a glance", fontsize=13, fontweight="bold", y=1.02)
    plt.tight_layout()
    report["charts"].append(("Session patterns at a glance", fig_to_b64(fig)))

    # 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))))

    # Weekly code churn (lines added vs removed)
    if not lines_df.empty:
        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 bar chart
    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))))

    # ── Week-on-week ──
    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:
        # Single combined weekly chart: sessions + tool breakdown
        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 — has all the numbers
        wow_table = wow.rename(columns={
            "week": "Week", "sessions": "Sessions",
            "median_duration": "Active min (median)",
            "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 cloud only (word frequency bar is redundant with this)
        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)))

    # Intent classification — pie chart
    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:
        fig, ax = plt.subplots(figsize=(7, 5))
        labels = list(intent_counts.keys())
        sizes = list(intent_counts.values())
        colors = sns.color_palette("muted", len(labels))
        wedges, texts, autotexts = ax.pie(
            sizes, labels=labels, colors=colors, autopct="%1.0f%%",
            startangle=140, pctdistance=0.75,
            wedgeprops={"edgecolor": "white", "linewidth": 1.5})
        for t in texts:
            t.set_fontsize(10)
        for t in autotexts:
            t.set_fontsize(9); t.set_fontweight("bold")
        ax.set_title("What kind of work is the developer doing?", fontsize=13, fontweight="bold")
        def _clean_pat(p):
            return re.sub(r"\\b\(\?:|\\b|\)", "", p).replace("|", ", ")
        keyword_note = "Keywords: " + " | ".join(
            f"{k}: {_clean_pat(p)}" for k, p in INTENTS.items() if k in intent_counts)
        fig.text(0.5, -0.02, keyword_note, ha="center", fontsize=7, color="#888", wrap=True)
        report["charts"].append(("What kind of work is the developer doing?", fig_to_b64(fig)))

    # First prompt length per session (how much context does the developer front-load?)
    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 (per repo) ──
    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)
    edit_tc_data["repo"] = edit_tc_data["repo_name"]
    edit_tc_data = edit_tc_data[edit_tc_data["repo"].notna()]
    file_edits = edit_tc_data[edit_tc_data["rel_path"].notna()]
    if not file_edits.empty:
        top_files = (
            file_edits.groupby(["repo", "rel_path"], as_index=False)
            .agg(edits=("message_id", "count"), sessions=("session_id", "nunique"))
            .sort_values(["repo", "edits"], ascending=[True, False])
            .groupby("repo", as_index=False).head(10)
            .rename(columns={"repo": "Repo", "rel_path": "File", "edits": "Total edits", "sessions": "Sessions touched"})
        )
        report["tables"].append(("Most frequently edited files (by repo)", top_files))

        struggle = (
            file_edits.groupby(["repo", "session_id", "rel_path"], as_index=False).size()
            .rename(columns={"size": "edits"}).query("edits >= 5")
            .sort_values("edits", ascending=False).head(15)
            .rename(columns={"repo": "Repo", "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 (per repo)
    # ═══════════════════════════════════════════════════════
    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_tc["repo"] = read_tc["repo_name"]
    read_tc = read_tc[read_tc["repo"].notna()]
    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(["repo", "rel_path"], as_index=False)
            .agg(reads=("message_id", "count"), sessions=("session_id", "nunique"))
            .sort_values(["repo", "reads"], ascending=[True, False])
            .groupby("repo", as_index=False).head(10)
            .rename(columns={"repo": "Repo", "rel_path": "File", "reads": "Times read", "sessions": "Sessions"})
        )
        report["tables"].append(("Most frequently read files (by repo)", top_reads))

        # Chart: top 12 overall for a quick visual
        top_reads_overall = (
            read_files.groupby("rel_path", as_index=False)
            .agg(reads=("message_id", "count"))
            .sort_values("reads", ascending=False).head(12)
        )
        report["charts"].append(("Which files does the AI read most?",
            fig_to_b64(chart_barh(
                top_reads_overall["rel_path"].tolist(),
                top_reads_overall["reads"].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 "repo_name" in shell_tc.columns:
        shell_tc["repo"] = shell_tc["repo_name"]
        shell_tc = shell_tc[shell_tc["repo"].notna()]
    else:
        shell_tc["repo"] = None

    if not shell_tc.empty:
        # Overall command frequency (for chart)
        cmd_freq_overall = (
            shell_tc.groupby("cmd_category", as_index=False)
            .agg(runs=("message_id", "count"), sessions=("session_id", "nunique"))
            .sort_values("runs", ascending=False).head(15)
        )
        report["charts"].append(("What commands does the AI run?",
            fig_to_b64(chart_barh(
                cmd_freq_overall["cmd_category"].head(12).tolist(),
                cmd_freq_overall["runs"].head(12).tolist(),
                "Most Common Shell Commands", "Times run", C_SECONDARY))))

        # Per-repo command frequency table
        cmd_freq = (
            shell_tc.groupby(["repo", "cmd_category"], as_index=False)
            .agg(runs=("message_id", "count"), sessions=("session_id", "nunique"))
            .sort_values(["repo", "runs"], ascending=[True, False])
            .groupby("repo", as_index=False).head(10)
            .rename(columns={"repo": "Repo", "cmd_category": "Command", "runs": "Times run", "sessions": "Sessions"})
        )
        report["tables"].append(("Most common shell commands (by repo)", cmd_freq))

        # ── Command → File breakdown (per repo) ──
        cmd_file_records = []
        for _, r in shell_tc.iterrows():
            raw_cmd = r["command"]
            cat = r["cmd_category"]
            cwd_val = r.get("cwd", "") or ""
            repo_val = r.get("repo")
            if not repo_val:
                continue
            for fp in extract_files_from_command(raw_cmd):
                rel = make_rel(fp, cwd_val) if cwd_val else fp
                cmd_file_records.append({
                    "repo": repo_val, "command": cat, "file": rel,
                    "short_file": shorten_path(rel),
                    "session_id": r["session_id"],
                })

        if cmd_file_records:
            cf_df = pd.DataFrame(cmd_file_records)

            # Top command+file pairs per repo
            cmd_file_freq = (
                cf_df.groupby(["repo", "command", "short_file", "file"], as_index=False)
                .agg(runs=("session_id", "count"), sessions=("session_id", "nunique"))
                .sort_values(["repo", "runs"], ascending=[True, False])
                .groupby("repo", as_index=False).head(10)
                .rename(columns={
                    "repo": "Repo", "command": "Command", "file": "Full path",
                    "short_file": "File", "runs": "Times", "sessions": "Sessions",
                })
            )
            report["tables"].append((
                "Shell commands — which files do they target? (by repo)",
                cmd_file_freq[["Repo", "Command", "File", "Times", "Sessions"]]))

            # Per top command: which files (overall chart)
            for top_cmd in cmd_freq_overall["cmd_category"].head(5).tolist():
                cmd_slice = cf_df[cf_df["command"] == top_cmd]
                if cmd_slice.empty:
                    continue
                file_freq = (
                    cmd_slice.groupby("short_file", as_index=False)
                    .agg(runs=("session_id", "count"))
                    .sort_values("runs", ascending=False).head(10)
                )
                if len(file_freq) >= 2:
                    report["charts"].append((
                        f"Files targeted by '{top_cmd}'",
                        fig_to_b64(chart_barh(
                            file_freq["short_file"].tolist(),
                            file_freq["runs"].tolist(),
                            f"Files Operated on by '{top_cmd}'",
                            "Times", 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)
        repo_val = row.get("repo_name")
        if not repo_val:
            continue
        for m in mentions:
            at_mentions.append({"file": m, "session_id": row["session_id"], "repo": repo_val})

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

        # Overall chart
        at_overall = (
            at_df.groupby("file", as_index=False)
            .agg(mentions=("session_id", "count"))
            .sort_values("mentions", ascending=False).head(10)
        )
        report["charts"].append(("Which files does the developer explicitly reference?",
            fig_to_b64(chart_barh(
                at_overall["file"].tolist(),
                at_overall["mentions"].tolist(),
                "Most @-mentioned Files in Prompts",
                "Times mentioned", C_PURPLE))))

    # ═══════════════════════════════════════════════════════
    # TABLES: File dependency / co-occurrence (per repo, toggleable)
    # ═══════════════════════════════════════════════════════
    # Source 1: files from tool_calls (Read, Edit, Grep, etc.)
    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)]

    # Source 2: file paths mentioned in user & assistant messages
    msg_file_records = []
    for _, row in u_msgs[u_msgs["msg_type"].isin(["user", "assistant"])].iterrows():
        content = row.get("content")
        if not isinstance(content, str) or len(content) < 5:
            continue
        found = re.findall(r'(?:^|\s|@|`)((?:/[\w./-]+|[\w./-]*/)[\w.-]+\.[\w]{1,8})', content)
        for fp in found:
            rel = make_rel(fp, row.get("cwd") or "")
            if rel and len(rel) > 3 and not rel.startswith("*"):
                msg_file_records.append({
                    "session_id": row["session_id"], "rel": rel,
                    "repo_name": row.get("repo_name"),
                })

    # Combine both sources
    tc_file_df = all_file_tc[["session_id", "rel", "repo_name"]].copy()
    msg_file_df = pd.DataFrame(msg_file_records) if msg_file_records else pd.DataFrame(columns=["session_id", "rel", "repo_name"])
    combined_files = pd.concat([tc_file_df, msg_file_df], ignore_index=True)
    combined_files = combined_files[combined_files["rel"].notna()]

    # Build co-occurrence graph data per repo
    all_repo_graphs = {}  # repo_label -> {nodes: [...], edges: [...], maxW, maxDeg}
    repos_with_data = sorted(r for r in combined_files["repo_name"].dropna().unique())

    for repo in repos_with_data:
        repo_files = combined_files[combined_files["repo_name"] == repo]
        session_file_sets = repo_files.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
        repo_pairs = [(p, w) for p, w in pair_counter.most_common(30) if w >= 2]
        if not repo_pairs:
            continue
        if len(repo_pairs) > 20:
            repo_pairs = repo_pairs[:20]

        node_set = set()
        for (a, b), _ in repo_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)}
        node_degree = Counter()
        for (a, b), w in repo_pairs:
            node_degree[a] += w; node_degree[b] += w
        max_deg = max(node_degree.values())
        max_w = max(w for _, w in repo_pairs)

        repo_label = repo.split("/")[-1] if "/" in repo else repo
        all_repo_graphs[repo_label] = {
            "nodes": [{"id": i, "full": f, "label": os.path.basename(f),
                       "degree": node_degree.get(f, 1)}
                      for i, f in enumerate(nodes_list)],
            "edges": [{"source": node_idx[a], "target": node_idx[b], "weight": w}
                      for (a, b), w in repo_pairs],
            "maxW": max_w,
            "maxDeg": max_deg,
        }

    if all_repo_graphs:
        all_graphs_json = json.dumps(all_repo_graphs)
        repo_names = list(all_repo_graphs.keys())
        first_repo = repo_names[0]

        # Build toggle buttons HTML
        buttons_html = ""
        for rn in repo_names:
            buttons_html += (
                f'<button class="repo-btn" data-repo="{html_mod.escape(rn)}" '
                f'style="padding:6px 14px;margin:0 4px 8px 0;border:2px solid #3498DB;'
                f'border-radius:6px;background:white;color:#3498DB;font-size:12px;'
                f'font-weight:600;cursor:pointer;transition:all 0.15s;">'
                f'{html_mod.escape(rn)}</button>')

        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 touched together in the same session (from tool calls + file paths in messages).
          Bigger nodes = more connections. Thicker lines = more co-occurrences. Hover for full path. Drag to rearrange.</p>
        <div style="margin-bottom:10px;">{buttons_html}</div>
        <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 allGraphs = {all_graphs_json};
          const canvas = document.getElementById('depGraph');
          const ctx = canvas.getContext('2d');

          // HiDPI / Retina support
          const dpr = window.devicePixelRatio || 1;
          const cRect = canvas.getBoundingClientRect();
          const W = Math.round(cRect.width);
          const H = Math.round(cRect.height);
          canvas.width = W * dpr;
          canvas.height = H * dpr;
          ctx.scale(dpr, dpr);

          const PAD = 80;
          let nodes = [], edges = [], maxW = 1, maxDeg = 1, N = 0;
          let adj = new Map();
          let dragNode = null, hoverNode = null;
          let settled = false, frameCount = 0;
          let activeRepo = '{first_repo}';

          function loadRepo(repoName) {{
            const g = allGraphs[repoName];
            if (!g) return;
            activeRepo = repoName;
            nodes = JSON.parse(JSON.stringify(g.nodes));
            edges = JSON.parse(JSON.stringify(g.edges));
            maxW = g.maxW; maxDeg = g.maxDeg;
            N = nodes.length;
            // Circular layout
            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;
            }});
            // Adjacency
            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);
            }});
            dragNode = null; hoverNode = null;
            settled = false; frameCount = 0;
            // Update button styles
            document.querySelectorAll('.repo-btn').forEach(btn => {{
              if (btn.dataset.repo === repoName) {{
                btn.style.background = '#3498DB';
                btn.style.color = 'white';
              }} else {{
                btn.style.background = 'white';
                btn.style.color = '#3498DB';
              }}
            }});
          }}

          // Button click handlers
          document.querySelectorAll('.repo-btn').forEach(btn => {{
            btn.addEventListener('click', () => loadRepo(btn.dataset.repo));
          }});

          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);
            nodes.forEach(n => {{ n.fx = 0; n.fy = 0; }});
            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;
              }}
            }}
            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;
            }});
            nodes.forEach(n => {{
              n.fx += (W / 2 - n.x) * centerK;
              n.fy += (H / 2 - n.y) * centerK;
            }});
            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);
            if (N === 0) {{
              ctx.font = '14px sans-serif'; ctx.fillStyle = '#999'; ctx.textAlign = 'center';
              ctx.fillText('No file co-occurrence data for this repo', W / 2, H / 2);
              return;
            }}
            const hoverId = hoverNode ? hoverNode.id : -1;
            const hoverAdj = hoverNode ? adj.get(hoverNode.id) : new Set();

            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();
              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.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();
              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);
              }}
            }});

            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';
              let cr = canvas.getBoundingClientRect();
              let cssX = hoverNode.x * cr.width / W;
              let cssY = hoverNode.y * cr.height / H + nodeRadius(hoverNode) + 12;
              tooltip.style.left = Math.max(4, Math.min(cssX - 100, cr.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);
          }}
          loadRepo('{first_repo}');
          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

# %%
TARGET_EMAILS = [                  # explicit list; set to None to generate for all
    "sagarsarkale.work@gmail.com",
    "54902986+zzjjaayy@users.noreply.github.com",
    "ajaysingh07032003@gmail.com",
    "patil.vaibhav2147.vp@gmail.com",
]

if USER_FILTER:
    target_emails = [USER_FILTER]
elif TARGET_EMAILS:
    target_emails = [e for e in TARGET_EMAILS if e in sess["user_email"].values]
else:
    target_emails = sorted(e for e in sess["user_email"].unique() if e)

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.*
