# %% [markdown]
# # Qualitative Session Analysis — Per-Developer Deep Dive

# %%
import json
import os
import sys
import re

import pandas as pd

# Setup paths
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) if "__file__" in dir() else os.path.abspath(".")
PARENT_DIR = os.path.dirname(SCRIPT_DIR)  # analysis-feb2026/
ANALYZERS_DIR = os.path.join(PARENT_DIR, "analyzers")
sys.path.insert(0, PARENT_DIR)

from analyzers.config import get_connection, query_df
from analyzers.base import load_session_data, BaseAnalyzer
from analyzers.helpers import (
    categorize_tool, clean_user_content, get_first_user_prompt,
    is_shell_error, get_tool_name_from_msg, get_tool_input_from_msg,
    get_tool_input_dict,
)
from analyzers.llm_cache import get_cached, set_cache

import openai
from dotenv import load_dotenv

# Config
SINCE = "2026-01-01"
UNTIL = "2026-02-28"
TOP_N = 50
DATE_RANGE = f"{SINCE}_to_{UNTIL}"

# Load API key
REPO_ROOT = os.path.dirname(PARENT_DIR)
load_dotenv(os.path.join(REPO_ROOT, ".prod.env"), override=True)
client = openai.OpenAI()

# Output dir
DUMP_DIR = os.path.join(SCRIPT_DIR, "dumps")
os.makedirs(DUMP_DIR, exist_ok=True)

# Discover users from output directory
OUTPUT_DIR = os.path.join(ANALYZERS_DIR, "output")
USERS = [d for d in os.listdir(OUTPUT_DIR) if os.path.isdir(os.path.join(OUTPUT_DIR, d))]
print(f"Users: {USERS}")

# DB connection — use DSN directly since utils/connection.py blocks prod
import psycopg
conn = psycopg.connect(os.environ["QC_TRACE_DSN"])

# Helper
analyzer = BaseAnalyzer()

# %%
# Cell 2: Load Analyzer JSONs & Score Sessions

def load_all_json(user_dir: str, date_range: str) -> dict:
    """Load all.json for a user dir / date range."""
    path = os.path.join(OUTPUT_DIR, user_dir, date_range, "all.json")
    if not os.path.exists(path):
        return {}
    with open(path) as f:
        return json.load(f)


def score_sessions(all_data: dict) -> dict[str, float]:
    """Build a trouble_score per session_id from multi-analyzer metrics."""
    scores: dict[str, float] = {}

    def add(sid: str, pts: float):
        scores[sid] = scores.get(sid, 0.0) + pts

    # a10_error_detection
    for rec in all_data.get("a10_error_detection", {}).get("per_session", []):
        sid = rec.get("session_id")
        if not sid:
            continue
        add(sid, rec.get("error_cascade_count", 0) * 3)
        add(sid, rec.get("shell_error_count", 0) * 1)

    # a01_interruptions
    for rec in all_data.get("a01_interruptions", {}).get("per_session", []):
        sid = rec.get("session_id")
        if not sid:
            continue
        ic = rec.get("interrupt_count", 0)
        add(sid, ic * 2)
        if ic > 0 and not rec.get("session_recovered", True):
            add(sid, 2)

    # a05_undo_revert
    for rec in all_data.get("a05_undo_revert", {}).get("per_session", []):
        sid = rec.get("session_id")
        if not sid:
            continue
        if rec.get("user_requested_undo", False):
            add(sid, 3)
        if rec.get("ai_self_corrected", False):
            add(sid, 1)

    # a04 edit index: sessions that have edits
    a04_has_edits: set[str] = set()
    for rec in all_data.get("a04_session_outcomes", {}).get("per_session", []):
        sid = rec.get("session_id")
        if sid and rec.get("has_edits", False):
            a04_has_edits.add(sid)

    # a07_conversation_dynamics: total_turns > 20 AND no edits
    for rec in all_data.get("a07_conversation_dynamics", {}).get("per_session", []):
        sid = rec.get("session_id")
        if not sid:
            continue
        if rec.get("total_turns", 0) > 20 and sid not in a04_has_edits:
            add(sid, 2)

    # a02_autonomy: user_intervention_rate > 0.5
    for rec in all_data.get("a02_autonomy", {}).get("per_session", []):
        sid = rec.get("session_id")
        if not sid:
            continue
        if rec.get("user_intervention_rate", 0) > 0.5:
            add(sid, 1)

    return scores


scored_sessions_by_user: dict[str, list[dict]] = {}

for user_dir in USERS:
    all_data = load_all_json(user_dir, DATE_RANGE)
    if not all_data:
        print(f"  [skip] {user_dir}: no all.json found")
        continue

    scores = score_sessions(all_data)

    # Sort descending, keep top N
    ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:TOP_N]
    top_sessions = [{"session_id": sid, "trouble_score": score} for sid, score in ranked]
    scored_sessions_by_user[user_dir] = top_sessions

    dump_path = os.path.join(DUMP_DIR, f"{user_dir}_scored_sessions.json")
    with open(dump_path, "w") as f:
        json.dump(top_sessions, f, indent=2)

    print(f"  {user_dir}: {len(top_sessions)} sessions scored, top score={ranked[0][1] if ranked else 0}")

print(f"\nDone. Scored sessions dumped to {DUMP_DIR}")

# %%
# Cell 3: Fetch Message Streams for Flagged Sessions

def email_to_dir_name(email: str) -> str:
    """Convert email to the dir-safe format used by the runner."""
    return email.replace("@", "_at_").replace(".", "_")


def build_email_to_dir_mapping(conn) -> dict[str, str]:
    """Query all distinct emails in date range, return {email: dir_name}."""
    rows = query_df(conn, """
        SELECT DISTINCT user_email FROM sessions
        WHERE first_seen >= %s AND first_seen < %s
    """, (SINCE, UNTIL))
    mapping = {}
    for email in rows["user_email"].tolist():
        if not isinstance(email, str) or not email:
            continue
        dir_name = email_to_dir_name(email)
        mapping[email] = dir_name
    return mapping


def build_compact_timeline(stream: list[dict]) -> list[dict]:
    """Build a compact, size-controlled timeline from a full message stream."""
    timeline = []
    for msg in stream:
        msg_type = msg.get("msg_type", "")
        entry: dict = {"role": msg_type}

        if msg_type == "user":
            content = clean_user_content(msg.get("content", ""), msg.get("source", ""))
            entry["content_preview"] = content
            entry["is_error"] = False

        elif msg_type == "assistant":
            raw = msg.get("content", "") or ""
            entry["content_preview"] = raw[:500]
            entry["is_error"] = False

        elif msg_type == "tool_call":
            tool_name = get_tool_name_from_msg(msg)
            ti_dict = get_tool_input_dict(msg)
            # Abbreviated tool input: command for shell, file_path for edit/read, else first value
            if tool_name in {"Bash", "shell_command", "exec_command", "run_shell_command", "run_terminal_cmd"}:
                abbreviated = ti_dict.get("command", get_tool_input_from_msg(msg))
            elif tool_name in {"Edit", "Write", "MultiEdit", "MultiEditTool", "replace", "Read", "Grep", "Glob",
                               "read_file", "grep_search", "codebase_search", "list_dir", "file_search"}:
                abbreviated = ti_dict.get("file_path", get_tool_input_from_msg(msg))
            else:
                abbreviated = get_tool_input_from_msg(msg)
            entry["tool"] = tool_name
            entry["content_preview"] = str(abbreviated)[:300]
            entry["is_error"] = False

        elif msg_type == "tool_result":
            output = msg.get("result_output") or msg.get("content") or ""
            error = is_shell_error(output)
            entry["is_error"] = error
            if error:
                entry["content_preview"] = output[:1000]
            else:
                entry["content_preview"] = None  # skip non-error tool results

        else:
            entry["content_preview"] = (msg.get("content") or "")[:200]
            entry["is_error"] = False

        timeline.append(entry)

    return timeline


# Build email -> dir_name mapping from DB
email_to_dir = build_email_to_dir_mapping(conn)
dir_to_email = {v: k for k, v in email_to_dir.items()}

print("Email -> dir mapping:")
for email, dirname in email_to_dir.items():
    print(f"  {email} -> {dirname}")

# Process each user
all_session_streams: dict[str, dict] = {}

for user_dir in USERS:
    # Resolve email from dir name
    email = dir_to_email.get(user_dir)
    if not email:
        # Try partial match
        for e, d in email_to_dir.items():
            if d == user_dir:
                email = e
                break
    if not email:
        print(f"  [skip] {user_dir}: cannot resolve email from DB")
        continue

    # Load scored sessions for this user
    dump_path = os.path.join(DUMP_DIR, f"{user_dir}_scored_sessions.json")
    if not os.path.exists(dump_path):
        print(f"  [skip] {user_dir}: no scored_sessions.json found")
        continue
    with open(dump_path) as f:
        scored = json.load(f)

    if not scored:
        print(f"  [skip] {user_dir}: no scored sessions")
        continue

    flagged_session_ids = [s["session_id"] for s in scored]
    score_lookup = {s["session_id"]: s["trouble_score"] for s in scored}

    print(f"\n  {user_dir} ({email}): loading data for {len(flagged_session_ids)} sessions...")

    # Load full session data for user (messages, tool_calls, tool_results)
    data = load_session_data(conn, email, SINCE, UNTIL)

    if data["messages"].empty:
        print(f"  [skip] {user_dir}: no messages found in DB")
        continue

    user_streams: dict[str, dict] = {}

    for session_id in flagged_session_ids:
        stream = analyzer.build_message_stream(
            data["messages"], data["tool_calls"], data["tool_results"], session_id
        )
        if not stream:
            continue

        compact = build_compact_timeline(stream)
        user_streams[session_id] = {
            "session_id": session_id,
            "trouble_score": score_lookup.get(session_id, 0),
            "timeline": compact,
        }

    all_session_streams[user_dir] = user_streams

    # Dump per-user streams
    streams_dump_path = os.path.join(DUMP_DIR, f"{user_dir}_session_streams.json")
    with open(streams_dump_path, "w") as f:
        json.dump(user_streams, f, indent=2, default=str)

    print(f"  Dumped {len(user_streams)} session streams to {streams_dump_path}")

print(f"\nDone. Session streams dumped to {DUMP_DIR}")

# %%
# Cell 4: Prompt Behavior Bucketing (Heuristic)
# Classify first user prompt of ALL sessions into behavior buckets

from collections import defaultdict

_STACK_TRACE_RE = re.compile(
    r"Traceback|at line \d+|Error:|Exception:|\.py.*line \d+", re.IGNORECASE
)
_FIX_KW_RE = re.compile(r"\b(fix|solve|help|debug|repair|resolve)\b", re.IGNORECASE)
_VAGUE_VERBS_RE = re.compile(
    r"^\s*(fix this|make it work|do it|update(?: this)?|change this|just do it|do this)\s*[.!?]?\s*$",
    re.IGNORECASE,
)
_IMPERATIVE_RE = re.compile(r"\b(fix|make|do|update|change|add|remove|delete)\b", re.IGNORECASE)
_EXPLORATION_RE = re.compile(
    r"\b(what does|how does|explain|understand|find|show me|where is)\b", re.IGNORECASE
)
_REVIEW_RE = re.compile(r"\b(review|check|look at|LGTM|PR)\b", re.IGNORECASE)
_CONTINUATION_RE = re.compile(
    r"^(continue|also|next\b)|as discussed|like before", re.IGNORECASE
)
_MULTI_TASK_RE = re.compile(
    r"\band also\b|\bthen\b|\bafter that\b", re.IGNORECASE
)
_STRUCTURED_RE = re.compile(r"(^\s*[-*]\s|\b\d+\.\s|```)", re.MULTILINE)


def _classify_prompt(prompt: str) -> str:
    if not prompt:
        return "general"
    p = prompt.strip()

    if _STACK_TRACE_RE.search(p) and _FIX_KW_RE.search(p):
        return "error_paste_and_fix"
    if _CONTINUATION_RE.search(p[:80]):
        return "continuation"
    if _REVIEW_RE.search(p):
        return "code_review"
    if _EXPLORATION_RE.search(p):
        return "exploration"
    if len(p) > 500 or _STRUCTURED_RE.search(p):
        return "detailed_spec"
    if len(p) < 100 and not re.search(r"[`\n]|Error|exception", p, re.IGNORECASE):
        if _VAGUE_VERBS_RE.search(p):
            return "vague_directive"
        if _IMPERATIVE_RE.search(p) and len(p.split()) <= 10:
            return "vague_directive"
    sentences = [s.strip() for s in re.split(r"[.!?]", p) if s.strip()]
    imperative_count = sum(1 for s in sentences if _IMPERATIVE_RE.match(s))
    if _MULTI_TASK_RE.search(p) or imperative_count >= 2:
        return "multi_task"
    return "general"


print("Cell 4: Prompt Behavior Bucketing")

for user_dir in USERS:
    email = dir_to_email.get(user_dir)
    if not email:
        print(f"  [skip] {user_dir}: cannot resolve email")
        continue

    data = load_session_data(conn, email, SINCE, UNTIL)
    sessions_df = data["sessions"]
    messages_df = data["messages"]
    tool_calls_df = data["tool_calls"]
    tool_results_df = data["tool_results"]

    if sessions_df.empty:
        print(f"  {email}: no sessions, skipping")
        continue

    bucket_sessions = []
    for session in analyzer.iter_sessions(sessions_df):
        sid = session["id"]
        stream = analyzer.build_message_stream(messages_df, tool_calls_df, tool_results_df, sid)
        if not stream:
            continue
        source = session.get("source", "")
        msg_list = [{"msg_type": m["msg_type"], "content": m.get("content", "")} for m in stream]
        prompt = get_first_user_prompt(msg_list, source)
        bucket = _classify_prompt(prompt)
        bucket_sessions.append({
            "session_id": sid,
            "bucket": bucket,
            "prompt_preview": prompt[:200],
        })

    distribution: dict[str, int] = defaultdict(int)
    for s in bucket_sessions:
        distribution[s["bucket"]] += 1

    out_path = os.path.join(DUMP_DIR, f"{user_dir}_prompt_buckets.json")
    with open(out_path, "w") as f:
        json.dump({"distribution": dict(distribution), "sessions": bucket_sessions}, f, indent=2)

    print(f"  {email}: {len(bucket_sessions)} sessions bucketed -> {dict(distribution)}")

print("Cell 4 done.")

# %%
# Cell 5: LLM-Powered Session Narratives (gpt-4o, cached)

_NARRATIVE_SYSTEM = """You are analyzing an AI coding session to identify what went wrong and why.
Given the session timeline below, provide:
1. task_summary: What was the developer trying to accomplish? (1 sentence)
2. what_went_wrong: What specifically failed? (1-2 sentences with actual error details)
3. root_cause: Why did it fail? Pick ONE: wrong_approach | missing_context | vague_prompt | tool_limitation | ai_hallucination | environment_issue | scope_creep
4. decision_point: The specific AI decision or user prompt that led to the failure (quote it)
5. prompt_issue: Was the user's prompt part of the problem? If yes, how? (1 sentence, or "N/A")
6. resolution: How did the session end? (user_rescued | ai_self_corrected | abandoned | partial_success)
7. recommendation: One actionable thing the developer could do differently next time (1 sentence)

Respond as JSON only."""


def _call_narrative(timeline: list[dict]) -> dict:
    user_msg = json.dumps(timeline, indent=2)
    full_prompt = _NARRATIVE_SYSTEM + "\n\n" + user_msg

    cached = get_cached(full_prompt)
    if cached is not None:
        return cached

    try:
        response = client.chat.completions.create(
            model="gpt-4o",
            messages=[
                {"role": "system", "content": _NARRATIVE_SYSTEM},
                {"role": "user", "content": user_msg},
            ],
            temperature=0.2,
            max_tokens=600,
        )
        raw = response.choices[0].message.content.strip()
        json_match = re.search(r"```(?:json)?\s*([\s\S]*?)```", raw)
        if json_match:
            raw = json_match.group(1).strip()
        result = json.loads(raw)
    except Exception as exc:
        result = {"error": str(exc)}

    set_cache(full_prompt, result)
    return result


print("Cell 5: LLM-Powered Session Narratives")

for user_dir in USERS:
    email = dir_to_email.get(user_dir, user_dir)

    streams_path = os.path.join(DUMP_DIR, f"{user_dir}_session_streams.json")
    if not os.path.exists(streams_path):
        print(f"  {user_dir}: no session_streams.json, skipping")
        continue

    with open(streams_path) as f:
        session_streams: dict = json.load(f)

    if not session_streams:
        print(f"  {user_dir}: empty session_streams, skipping")
        continue

    narratives: dict[str, dict] = {}
    total = len(session_streams)

    for idx, (sid, session_data) in enumerate(session_streams.items(), 1):
        timeline = session_data.get("timeline", [])
        if not timeline:
            narratives[sid] = {"error": "empty_timeline"}
            continue

        compact_timeline = timeline[:60]
        print(f"  {email} [{idx}/{total}] {sid[:16]}...", end=" ", flush=True)
        narrative = _call_narrative(compact_timeline)
        narratives[sid] = narrative
        status = "cached" if get_cached(_NARRATIVE_SYSTEM + "\n\n" + json.dumps(compact_timeline, indent=2)) and not narrative.get("error") else (
            "error" if narrative.get("error") else "ok"
        )
        print(status)

    out_path = os.path.join(DUMP_DIR, f"{user_dir}_session_narratives.json")
    with open(out_path, "w") as f:
        json.dump(narratives, f, indent=2)

    print(f"  {email}: {len(narratives)} narratives written")

print("Cell 5 done.")

# %%
# Cell 6: Recurrent Pattern Detection

print("Cell 6: Recurrent Pattern Detection")

for user_dir in USERS:
    narratives_path = os.path.join(DUMP_DIR, f"{user_dir}_session_narratives.json")
    scored_path = os.path.join(DUMP_DIR, f"{user_dir}_scored_sessions.json")
    buckets_path = os.path.join(DUMP_DIR, f"{user_dir}_prompt_buckets.json")

    if not os.path.exists(narratives_path) or not os.path.exists(scored_path):
        print(f"  [skip] {user_dir}: missing narratives or scored_sessions")
        continue

    with open(narratives_path) as f:
        narratives = json.load(f)
    with open(scored_path) as f:
        scored_sessions = json.load(f)

    buckets_data = {}
    if os.path.exists(buckets_path):
        with open(buckets_path) as f:
            buckets_data = json.load(f)

    score_lookup = {s["session_id"]: s for s in scored_sessions}
    total_flagged = len(scored_sessions)

    # Group by root_cause
    root_cause_groups: dict[str, list[str]] = defaultdict(list)
    for sid, narr in narratives.items():
        rc = (narr.get("root_cause") or "unknown").strip() or "unknown"
        root_cause_groups[rc].append(sid)
    root_cause_dist = {rc: len(sids) for rc, sids in root_cause_groups.items()}

    # Group by resolution
    resolution_groups: dict[str, list[str]] = defaultdict(list)
    for sid, narr in narratives.items():
        res = (narr.get("resolution") or "unknown").strip() or "unknown"
        resolution_groups[res].append(sid)
    resolution_dist = {res: len(sids) for res, sids in resolution_groups.items()}

    # Top 3 anti-patterns by root_cause frequency
    sorted_causes = sorted(root_cause_dist.items(), key=lambda x: x[1], reverse=True)
    top_anti_patterns = []
    for rc, count in sorted_causes[:3]:
        pct = round(count / max(total_flagged, 1) * 100, 1)
        example_sids = root_cause_groups[rc][:2]
        wwws = [
            narratives[sid].get("what_went_wrong", "")
            for sid in root_cause_groups[rc]
            if sid in narratives and narratives[sid].get("what_went_wrong")
        ]
        description = " | ".join(wwws[:3]) if wwws else "No description available."
        top_anti_patterns.append({
            "root_cause": rc,
            "count": count,
            "pct": pct,
            "example_sessions": example_sids,
            "description": description,
        })

    # Prompt bucket scores: avg trouble_score per bucket
    prompt_bucket_scores: dict[str, dict] = {}
    for bs in buckets_data.get("sessions", []):
        sid = bs.get("session_id", "")
        bucket = (bs.get("bucket") or "unknown").strip() or "unknown"
        ts = score_lookup.get(sid, {}).get("trouble_score", 0) if isinstance(score_lookup.get(sid), dict) else 0
        if bucket not in prompt_bucket_scores:
            prompt_bucket_scores[bucket] = {"count": 0, "scores": []}
        prompt_bucket_scores[bucket]["count"] += 1
        prompt_bucket_scores[bucket]["scores"].append(ts)

    for bucket, v in prompt_bucket_scores.items():
        scores_list = v.pop("scores")
        v["avg_trouble_score"] = round(sum(scores_list) / max(len(scores_list), 1), 2)

    patterns = {
        "root_cause_distribution": root_cause_dist,
        "resolution_distribution": resolution_dist,
        "top_anti_patterns": top_anti_patterns,
        "prompt_bucket_scores": prompt_bucket_scores,
    }

    out_path = os.path.join(DUMP_DIR, f"{user_dir}_patterns.json")
    with open(out_path, "w") as f:
        json.dump(patterns, f, indent=2)

    print(f"  {user_dir}: root_causes={root_cause_dist}")

print("Cell 6 done.")

# %%
# Cell 7: Generate Markdown Report

print("Cell 7: Generate Markdown Reports")

for user_dir in USERS:
    narratives_path = os.path.join(DUMP_DIR, f"{user_dir}_session_narratives.json")
    scored_path = os.path.join(DUMP_DIR, f"{user_dir}_scored_sessions.json")
    patterns_path = os.path.join(DUMP_DIR, f"{user_dir}_patterns.json")
    buckets_path = os.path.join(DUMP_DIR, f"{user_dir}_prompt_buckets.json")

    if not all(os.path.exists(p) for p in [scored_path, patterns_path]):
        print(f"  [skip] {user_dir}: missing scored_sessions or patterns")
        continue

    with open(scored_path) as f:
        scored_sessions = json.load(f)
    with open(patterns_path) as f:
        patterns = json.load(f)

    narratives: dict[str, dict] = {}
    if os.path.exists(narratives_path):
        with open(narratives_path) as f:
            narratives = json.load(f)

    bucket_lookup: dict[str, str] = {}
    bucket_prompt_lookup: dict[str, str] = {}
    buckets_data: dict = {}
    if os.path.exists(buckets_path):
        with open(buckets_path) as f:
            buckets_data = json.load(f)
        for bs in buckets_data.get("sessions", []):
            bucket_lookup[bs.get("session_id", "")] = bs.get("bucket", "")
            bucket_prompt_lookup[bs.get("session_id", "")] = bs.get("prompt_preview", "")

    # Load all.json to get total session count
    all_data = load_all_json(user_dir, DATE_RANGE)
    all_session_ids = {
        r["session_id"]
        for r in all_data.get("a01_interruptions", {}).get("per_session", [])
    }
    total_sessions = len(all_session_ids) if all_session_ids else len(scored_sessions)
    flagged_count = len(scored_sessions)

    # Also enrich scored_sessions with session_start from a07
    a07_starts = {}
    for rec in all_data.get("a07_conversation_dynamics", {}).get("per_session", []):
        a07_starts[rec["session_id"]] = rec.get("session_start", "")
    for s in scored_sessions:
        s["session_start"] = a07_starts.get(s["session_id"], "")

    top_anti_patterns = patterns.get("top_anti_patterns", [])
    root_cause_dist = patterns.get("root_cause_distribution", {})
    resolution_dist = patterns.get("resolution_distribution", {})
    prompt_bucket_scores = patterns.get("prompt_bucket_scores", {})

    # Executive summary
    top_rc = top_anti_patterns[0]["root_cause"] if top_anti_patterns else "unknown"
    top_res = max(resolution_dist, key=resolution_dist.get) if resolution_dist else "unknown"
    exec_summary = (
        f"The most common root cause across flagged sessions is **{top_rc}** "
        f"({top_anti_patterns[0]['count']} sessions, {top_anti_patterns[0]['pct']}% of flagged). "
        f"The dominant resolution pattern is **{top_res}** "
        f"({resolution_dist.get(top_res, 0)} sessions)."
        if top_anti_patterns
        else "Insufficient data for pattern summary."
    )

    lines: list[str] = []
    lines.append(f"# Developer Analysis: {user_dir}")
    lines.append(f"**Period**: {SINCE} to {UNTIL} | **Sessions**: {total_sessions} | **Flagged**: {flagged_count}")
    lines.append("")

    lines.append("## Executive Summary")
    lines.append(f"- {exec_summary}")
    lines.append("")

    # Top Recurring Anti-Patterns
    lines.append("## Top Recurring Anti-Patterns")
    for i, pattern in enumerate(top_anti_patterns, 1):
        rc = pattern["root_cause"]
        count = pattern["count"]
        pct = pattern["pct"]
        description = pattern["description"]
        example_sids = pattern.get("example_sessions", [])

        lines.append(f"### {i}. {rc} ({count} sessions, {pct}% of flagged)")
        lines.append(f"- **What happens**: {description}")

        for ex_sid in example_sids[:1]:
            short_id = ex_sid[:8]
            narr = narratives.get(ex_sid, {})
            score_rec = next((s for s in scored_sessions if s["session_id"] == ex_sid), {})
            date_str = str(score_rec.get("session_start", ""))[:10] or "unknown"
            lines.append(f'- **Example**: Session `{short_id}` on {date_str} — {narr.get("task_summary", "N/A")}')
            pp = bucket_prompt_lookup.get(ex_sid, "")
            if pp:
                lines.append(f'  - Prompt: "{pp}"')
            lines.append(f"  - Failed because: {narr.get('what_went_wrong', 'N/A')}")
            lines.append(f'  - Decision point: "{narr.get("decision_point", "N/A")}"')
            lines.append(f"- **Recommendation**: {narr.get('recommendation', 'N/A')}")
        lines.append("")

    # Prompt Quality Analysis
    lines.append("## Prompt Quality Analysis")
    total_bucket_sessions = sum(v["count"] for v in prompt_bucket_scores.values()) or 1
    lines.append("| Bucket | Count | % | Avg Trouble Score |")
    lines.append("|--------|-------|---|-------------------|")
    for bucket, stats_d in sorted(prompt_bucket_scores.items(), key=lambda x: -x[1]["count"]):
        cnt = stats_d["count"]
        pct = round(cnt / total_bucket_sessions * 100, 1)
        avg = stats_d["avg_trouble_score"]
        lines.append(f"| {bucket} | {cnt} | {pct}% | {avg} |")
    lines.append("")

    # Root Cause Distribution
    total_rc = sum(root_cause_dist.values()) or 1
    lines.append("## Root Cause Distribution")
    lines.append("| Root Cause | Count | % |")
    lines.append("|------------|-------|---|")
    for rc, cnt in sorted(root_cause_dist.items(), key=lambda x: -x[1]):
        pct = round(cnt / total_rc * 100, 1)
        lines.append(f"| {rc} | {cnt} | {pct}% |")
    lines.append("")

    # Resolution Patterns
    total_res = sum(resolution_dist.values()) or 1
    lines.append("## Resolution Patterns")
    lines.append("| Resolution | Count | % |")
    lines.append("|------------|-------|---|")
    for res, cnt in sorted(resolution_dist.items(), key=lambda x: -x[1]):
        pct = round(cnt / total_res * 100, 1)
        lines.append(f"| {res} | {cnt} | {pct}% |")
    lines.append("")

    # All Flagged Sessions
    lines.append("## All Flagged Sessions")
    lines.append("| # | Date | Score | Task | Root Cause | Resolution |")
    lines.append("|---|------|-------|------|------------|------------|")
    for idx, s in enumerate(scored_sessions, 1):
        sid = s["session_id"]
        score = s.get("trouble_score", 0)
        narr = narratives.get(sid, {})
        date_str = str(s.get("session_start", ""))[:10] or "unknown"
        task = (narr.get("task_summary") or "N/A").replace("|", "/")
        rc = (narr.get("root_cause") or "").replace("|", "/")
        res = (narr.get("resolution") or "").replace("|", "/")
        lines.append(f"| {idx} | {date_str} | {score} | {task[:60]} | {rc} | {res} |")
    lines.append("")

    # Detailed Session Analyses — top 10
    top10 = sorted(scored_sessions, key=lambda x: x.get("trouble_score", 0), reverse=True)[:10]
    lines.append("## Detailed Session Analyses")
    for s in top10:
        sid = s["session_id"]
        score = s.get("trouble_score", 0)
        narr = narratives.get(sid, {})
        short_id = sid[:8]
        date_str = str(s.get("session_start", ""))[:10] or "unknown"
        bucket = bucket_lookup.get(sid, "N/A")

        lines.append(f"### Session `{short_id}` — {date_str} — Score: {score}")
        lines.append(f"- **Task**: {narr.get('task_summary', 'N/A')}")
        lines.append(f"- **Prompt bucket**: {bucket}")
        lines.append(f"- **What went wrong**: {narr.get('what_went_wrong', 'N/A')}")
        lines.append(f"- **Root cause**: {narr.get('root_cause', 'N/A')}")
        lines.append(f'- **Decision point**: "{narr.get("decision_point", "N/A")}"')
        lines.append(f"- **Prompt issue**: {narr.get('prompt_issue', 'N/A')}")
        lines.append(f"- **Resolution**: {narr.get('resolution', 'N/A')}")
        lines.append(f"- **Recommendation**: {narr.get('recommendation', 'N/A')}")
        lines.append("")

    report_md = "\n".join(lines)
    out_path = os.path.join(DUMP_DIR, f"{user_dir}_report.md")
    with open(out_path, "w") as f:
        f.write(report_md)

    print(f"  {user_dir}: report saved ({len(lines)} lines)")

print(f"\nDone. Markdown reports written to {DUMP_DIR}")
