#!/usr/bin/env python3
"""
Persona Research Virtual Survey — Analysis & Visualization Pipeline

Usage:
    python analyze_results.py --input results.json --survey-type concept-test
    python analyze_results.py --input results.json --survey-type brand-map
    python analyze_results.py --input results.json --survey-type price-test
    python analyze_results.py --input results.json --survey-type usage-habits
    python analyze_results.py --input results.json --survey-type survey
    python analyze_results.py --input results.json --survey-type ask
    python analyze_results.py --input results.json --survey-type concept-test --report-only
    python analyze_results.py --input results.json --survey-type concept-test --report-llm --backend codex-cli

Outputs (in same directory as input):
    - results.csv           All responses as flat table
    - summary.json          Aggregate statistics
    - cross_tabs.csv        Segment × response cross-tabulation (concept-test)
    - persona_comparison.csv Persona comparison table (degenerate segment case)
    - chart_*.png           Survey-specific charts (skipped with --report-only)
    - report.md             Markdown one-pager report
"""

import argparse
import json
import re
import sys
from collections import Counter, defaultdict
from datetime import date
from pathlib import Path

import pandas as pd

SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
    sys.path.insert(0, str(SCRIPT_DIR))
SKILL_DIR = SCRIPT_DIR.parent

from llm_backends import (
    BACKEND_CHOICES,
    REPORT_BACKEND_CHOICES,
    format_model_label,
    resolve_backend,
    resolve_model,
    resolve_report_backend,
    run_text_completion,
)


# ─── Lazy chart imports ────────────────────────────────────────────────────

_chart_libs_loaded = False


def _load_chart_libs():
    """Load matplotlib/seaborn on first use (skipped in --report-only mode)."""
    global _chart_libs_loaded, matplotlib, plt, sns, COLORS, OPTION_COLORS, FIG_SIZE, DPI
    if _chart_libs_loaded:
        return
    import matplotlib as _matplotlib
    _matplotlib.use("Agg")
    import matplotlib.pyplot as _plt
    import seaborn as _sns
    matplotlib = _matplotlib
    plt = _plt
    sns = _sns
    sns.set_theme(style="whitegrid", palette="Set2", font_scale=1.05)
    COLORS = sns.color_palette("Set2")
    OPTION_COLORS = {"A": "#66c2a5", "B": "#fc8d62", "C": "#8da0cb", "D": "#e78ac3"}
    FIG_SIZE = (10, 6)
    DPI = 150
    _chart_libs_loaded = True


# ─── Core: Load, Normalize, Flatten ────────────────────────────────────────

META_KEYS = {"name", "segment", "age", "gender", "occupation", "responses"}
FREQUENCY_ORDER = {"daily": 5, "weekly": 4, "monthly": 3, "rarely": 2, "never": 1}


def load_results(path: Path) -> list[dict]:
    """Load results JSON (array of persona response objects)."""
    with open(path) as f:
        data = json.load(f)
    if not isinstance(data, list):
        raise ValueError("Expected a JSON array of response objects")
    return data


def normalize_result_entry(result: dict) -> dict:
    """Normalize legacy and canonical result rows to a single contract."""
    if not isinstance(result, dict):
        raise ValueError("Each result entry must be a JSON object")

    responses = {}
    if isinstance(result.get("responses"), dict):
        responses.update(result["responses"])

    for key, value in result.items():
        if key in META_KEYS:
            continue
        responses.setdefault(key, value)

    return {
        "name": result.get("name", ""),
        "segment": result.get("segment", "Unknown") or "Unknown",
        "age": result.get("age"),
        "gender": result.get("gender", ""),
        "occupation": result.get("occupation", ""),
        "responses": responses,
    }


def normalize_results(results: list[dict]) -> list[dict]:
    """Normalize all result entries."""
    return [normalize_result_entry(result) for result in results]


def _normalize_key(value: str) -> str:
    return re.sub(r"[^a-z0-9]+", "_", str(value).lower()).strip("_")


def _display_label(value: str) -> str:
    text = str(value).replace("_", " ").strip()
    return re.sub(r"\s+", " ", text).title()


def _truncate(text: object, limit: int = 140, sentence_aware: bool = False) -> str:
    text = str(text or "").strip()
    if len(text) <= limit:
        return text
    if sentence_aware:
        window = text[:limit]
        for marker in (". ", "! ", "? ", "。", "！", "？"):
            idx = window.rfind(marker)
            if idx >= limit // 2:
                return text[: idx + len(marker)].rstrip()
    return text[: limit - 3].rstrip() + "..."


def _short_text_label(text: object, max_words: int = 6) -> str:
    value = str(text or "").strip()
    if not value:
        return ""
    line = value.splitlines()[0].strip()
    for separator in [" — ", " – ", " - ", ",", ";", "."]:
        if separator in line:
            candidate = line.split(separator, 1)[0].strip()
            if 1 <= len(candidate) <= 48:
                return candidate
    words = line.split()
    if len(words) <= max_words:
        return line
    return " ".join(words[:max_words])


def _format_value(value: object, limit: int = 140) -> str:
    if isinstance(value, list):
        return _truncate(", ".join(str(item) for item in value), limit=limit)
    if isinstance(value, dict):
        parts = []
        for key, item in list(value.items())[:4]:
            if isinstance(item, list):
                item_text = ", ".join(str(v) for v in item[:3])
            else:
                item_text = str(item)
            parts.append(f"{key}: {item_text}")
        return _truncate("; ".join(parts), limit=limit)
    return _truncate(value, limit=limit)


def _normalize_usage_label(value: object) -> str:
    text = str(value or "").lower()
    text = re.sub(r"\([^)]*\)", "", text)
    text = text.replace("/", " ")
    text = re.sub(r"[_-]+", " ", text)
    text = re.sub(r"\s+", " ", text).strip()
    return text


def flatten_responses(results: list[dict]) -> pd.DataFrame:
    """Flatten canonical response objects into a flat DataFrame."""
    rows = []
    for result in results:
        row = {
            "name": result.get("name", ""),
            "segment": result.get("segment", "Unknown"),
            "age": result.get("age"),
            "gender": result.get("gender", ""),
            "occupation": result.get("occupation", ""),
        }
        for key, value in result.get("responses", {}).items():
            if isinstance(value, (str, int, float, bool)) or value is None:
                row[key] = value
            elif isinstance(value, list):
                row[key] = "; ".join(str(item) for item in value)
            elif isinstance(value, dict):
                row[key] = json.dumps(value, ensure_ascii=False)
                for subkey, subvalue in value.items():
                    flat_key = f"{key}_{_normalize_key(subkey)}"
                    if isinstance(subvalue, list):
                        row[flat_key] = "; ".join(str(item) for item in subvalue)
                    elif isinstance(subvalue, (str, int, float, bool)) or subvalue is None:
                        row[flat_key] = subvalue
                    else:
                        row[flat_key] = json.dumps(subvalue, ensure_ascii=False)
            else:
                row[key] = str(value)
        rows.append(row)
    return pd.DataFrame(rows)


def _is_degenerate_crosstab(df: pd.DataFrame) -> bool:
    """Check if segment values are all unique (topic-only mode with archetype labels)."""
    return not df.empty and df["segment"].nunique() == len(df)


def _generate_persona_comparison(df: pd.DataFrame, output_dir: Path):
    """Generate persona comparison table instead of cross-tab when segments are unique."""
    cols = ["name", "segment", "age", "gender", "occupation"]
    preferred_cols = [
        "preferred_option",
        "purchase_likelihood",
        "max_wtp",
        "current_product",
        "purchase_channel",
        "reasoning",
        "pain_points",
    ]
    for col in preferred_cols:
        if col in df.columns:
            cols.append(col)
    comparison = df[[col for col in cols if col in df.columns]].copy()
    comparison.to_csv(output_dir / "persona_comparison.csv", index=False)
    return comparison


# ─── Utilities ─────────────────────────────────────────────────────────────



def _parse_price_label(label: str) -> tuple[float, str]:
    """Parse a price label for sorting while preserving the original label."""
    match = re.search(r"([0-9]+(?:\.[0-9]+)?)", str(label))
    numeric = float(match.group(1)) if match else float("inf")
    cleaned = str(label)
    if cleaned and not cleaned.startswith("$") and match:
        cleaned = f"${cleaned}"
    return numeric, cleaned


def _numeric_series(df: pd.DataFrame, column: str) -> pd.Series:
    """Return a numeric series with NaNs dropped."""
    if column not in df.columns:
        return pd.Series(dtype=float)
    return pd.to_numeric(df[column], errors="coerce").dropna()


def _summarize_numeric(series: pd.Series) -> dict | None:
    if series.empty:
        return None
    return {
        "mean": round(float(series.mean()), 2),
        "median": round(float(series.median()), 2),
        "min": round(float(series.min()), 2),
        "max": round(float(series.max()), 2),
    }


def _sorted_counter(counter: Counter, limit: int | None = None) -> dict:
    items = counter.most_common(limit)
    return {key: value for key, value in items}


def _top_ranked_factor(factor_ranking: dict) -> str:
    if not isinstance(factor_ranking, dict) or not factor_ranking:
        return ""
    best = None
    for factor, rank in factor_ranking.items():
        try:
            numeric = float(rank)
        except (TypeError, ValueError):
            continue
        if best is None or numeric < best[1]:
            best = (str(factor), numeric)
    return best[0] if best else ""


def _favorite_price_point(intent_by_price: dict) -> tuple[str, float] | tuple[None, None]:
    if not isinstance(intent_by_price, dict) or not intent_by_price:
        return (None, None)
    best = None
    for label, intent in intent_by_price.items():
        try:
            numeric_intent = float(intent)
        except (TypeError, ValueError):
            continue
        price_value, cleaned = _parse_price_label(label)
        candidate = (numeric_intent, -price_value, cleaned)
        if best is None or candidate > best:
            best = candidate
    if best is None:
        return (None, None)
    return best[2], best[0]


def _summarize_usage_frequency(usage_frequency: dict) -> str:
    if not isinstance(usage_frequency, dict) or not usage_frequency:
        return ""
    ranked = []
    for occasion, frequency in usage_frequency.items():
        score = FREQUENCY_ORDER.get(str(frequency).lower(), 0)
        if score > 1:
            ranked.append((score, str(occasion), str(frequency)))
    ranked.sort(key=lambda item: (-item[0], item[1]))
    if not ranked:
        return "No active usage occasions mentioned"
    parts = [f"{occasion} ({frequency})" for _, occasion, frequency in ranked[:3]]
    return ", ".join(parts)


def _collect_verbatims(results: list[dict], survey_type: str) -> list[tuple[str, str, object, str]]:
    """Collect candidate verbatims for the report. Max 1 per persona."""
    candidates = []
    seen = set()
    for result in results:
        responses = result.get("responses", {})
        texts = []
        if survey_type == "concept-test":
            texts = [responses.get("reasoning", "")]
        elif survey_type == "brand-map":
            associations = responses.get("brand_associations", {})
            if isinstance(associations, dict):
                texts = list(associations.values())
        elif survey_type == "price-test":
            texts = [responses.get("reasoning", ""), responses.get("value_perception", "")]
        elif survey_type == "usage-habits":
            texts = [responses.get("pain_points", ""), responses.get("current_product", "")]
        elif survey_type == "ask":
            texts = [responses.get("reasoning", ""), responses.get("short_answer", "")]
        else:
            for value in responses.values():
                if isinstance(value, str):
                    texts.append(value)
        # Pick the single best (longest non-duplicate) text for this persona
        best = None
        for text in texts:
            cleaned = str(text).strip()
            if len(cleaned) < 20 or cleaned in seen:
                continue
            if best is None or len(cleaned) > len(best):
                best = cleaned
        if best is not None:
            seen.add(best)
            candidates.append(
                (best, result.get("name", "Unknown"), result.get("age", "?"), result.get("occupation", ""))
            )
    # Sort by length descending so the most articulate quotes appear first
    candidates.sort(key=lambda c: len(c[0]), reverse=True)
    return candidates[:3]


# ─── Analysis: Concept Test ───────────────────────────────────────────────


def analyze_concept_test(results: list[dict], df: pd.DataFrame, output_dir: Path, report_only: bool = False):
    """Analyze concept test results: cross-tabs, charts, summary."""
    if df.empty:
        print("WARNING: No valid responses to analyze")
        return {"total_respondents": 0}
    if "preferred_option" not in df.columns:
        print("WARNING: No 'preferred_option' column found, skipping concept analysis")
        return {}

    degenerate = _is_degenerate_crosstab(df)
    if degenerate:
        _generate_persona_comparison(df, output_dir)
    else:
        cross_tab = pd.crosstab(df["segment"], df["preferred_option"], margins=True)
        cross_tab.to_csv(output_dir / "cross_tabs.csv")
        cross_pct = pd.crosstab(df["segment"], df["preferred_option"], normalize="index") * 100
        cross_pct.to_csv(output_dir / "cross_tabs_pct.csv", float_format="%.1f")

    if not report_only:
        _load_chart_libs()
        options = sorted(df["preferred_option"].dropna().unique())
        colors = [OPTION_COLORS.get(opt, COLORS[i % len(COLORS)]) for i, opt in enumerate(options)]

        if not degenerate:
            cross_tab_local = pd.crosstab(df["segment"], df["preferred_option"], margins=True)
            fig, ax = plt.subplots(figsize=FIG_SIZE)
            cross_tab_no_margin = cross_tab_local.drop("All", errors="ignore")
            cross_tab_no_margin = cross_tab_no_margin[[c for c in options if c in cross_tab_no_margin.columns]]
            cross_tab_no_margin.plot(kind="bar", stacked=True, color=colors, ax=ax, edgecolor="white")
            ax.set_title("Concept Preference by Segment", fontsize=14, fontweight="bold")
            ax.set_ylabel("Count")
            ax.set_xlabel("")
            ax.legend(title="Option", bbox_to_anchor=(1.02, 1), loc="upper left")
            plt.xticks(rotation=30, ha="right")
            plt.tight_layout()
            fig.savefig(output_dir / "chart_preference.png", dpi=DPI, bbox_inches="tight")
            plt.close(fig)

        fig, ax = plt.subplots(figsize=(8, 5))
        counts = df["preferred_option"].value_counts().reindex(options, fill_value=0)
        bars = ax.bar(
            counts.index,
            counts.values,
            color=[OPTION_COLORS.get(option, "#999") for option in counts.index],
            edgecolor="white",
            linewidth=1.5,
        )
        for bar, value in zip(bars, counts.values):
            ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.3, str(value),
                    ha="center", va="bottom", fontweight="bold")
        ax.set_ylim(0, (max(counts.values) * 1.35 + 1) if len(counts) > 0 else 1)
        ax.set_title("Overall Concept Preference", fontsize=14, fontweight="bold")
        ax.set_ylabel("Count")
        ax.set_xlabel("Option")
        plt.tight_layout()
        fig.savefig(output_dir / "chart_overall.png", dpi=DPI, bbox_inches="tight")
        plt.close(fig)

        if "purchase_likelihood" in df.columns:
            purchase = pd.to_numeric(df["purchase_likelihood"], errors="coerce").dropna()
            if not purchase.empty:
                fig, ax = plt.subplots(figsize=(8, 5))
                ax.hist(purchase, bins=[0.5, 1.5, 2.5, 3.5, 4.5, 5.5], color=COLORS[0], edgecolor="white")
                ax.set_title("Purchase Likelihood Distribution", fontsize=14, fontweight="bold")
                ax.set_xlabel("Likelihood (1-5)")
                ax.set_ylabel("Count")
                ax.set_xticks([1, 2, 3, 4, 5])
                plt.tight_layout()
                fig.savefig(output_dir / "chart_purchase_likelihood.png", dpi=DPI, bbox_inches="tight")
                plt.close(fig)

    summary = {
        "total_respondents": len(df),
        "segments": df["segment"].value_counts().to_dict(),
        "overall_preference": df["preferred_option"].value_counts().to_dict(),
        "preference_by_segment": {},
    }
    for segment in df["segment"].unique():
        seg_df = df[df["segment"] == segment]
        summary["preference_by_segment"][segment] = {
            "count": len(seg_df),
            "preferences": seg_df["preferred_option"].value_counts().to_dict(),
        }
    purchase = _numeric_series(df, "purchase_likelihood")
    purchase_summary = _summarize_numeric(purchase)
    if purchase_summary:
        summary["purchase_likelihood"] = purchase_summary
    return summary


# ─── Analysis: Brand Map ──────────────────────────────────────────────────


def analyze_brand_map(results: list[dict], df: pd.DataFrame, output_dir: Path, report_only: bool = False):
    """Analyze brand perception results."""
    if df.empty:
        print("WARNING: No valid responses to analyze")
        return {"total_respondents": 0}
    summary = {
        "total_respondents": len(df),
        "segments": df["segment"].value_counts().to_dict(),
    }

    if _is_degenerate_crosstab(df):
        _generate_persona_comparison(df, output_dir)

    awareness_counts = Counter()
    consideration_counts = Counter()
    sentiment_counts = defaultdict(Counter)
    familiarity_counts = defaultdict(Counter)

    for result in results:
        responses = result.get("responses", {})

        awareness = responses.get("unaided_awareness", [])
        if isinstance(awareness, str):
            awareness = [item.strip() for item in awareness.split(";") if item.strip()]
        for brand in awareness:
            awareness_counts[str(brand).strip()] += 1

        consideration = responses.get("consideration_set", [])
        if isinstance(consideration, str):
            consideration = [item.strip() for item in consideration.split(";") if item.strip()]
        for brand in consideration:
            consideration_counts[str(brand).strip()] += 1

        familiarity = responses.get("aided_familiarity", {})
        if isinstance(familiarity, dict):
            for brand, status in familiarity.items():
                familiarity_counts[str(brand).strip()][str(status).strip()] += 1

        buckets = responses.get("brand_buckets", {})
        if isinstance(buckets, dict):
            for bucket_name in ("like", "dislike", "neutral"):
                brands = buckets.get(bucket_name, [])
                if isinstance(brands, str):
                    brands = [item.strip() for item in brands.split(";") if item.strip()]
                for brand in brands:
                    sentiment_counts[str(brand).strip()][bucket_name] += 1

    summary["unaided_awareness_top10"] = _sorted_counter(awareness_counts, 10)
    summary["consideration_top10"] = _sorted_counter(consideration_counts, 10)
    summary["brand_sentiment"] = {
        brand: dict(counts)
        for brand, counts in sorted(
            sentiment_counts.items(),
            key=lambda item: sum(item[1].values()),
            reverse=True,
        )[:10]
    }
    summary["aided_familiarity"] = {
        brand: dict(counts)
        for brand, counts in sorted(
            familiarity_counts.items(),
            key=lambda item: sum(item[1].values()),
            reverse=True,
        )[:10]
    }

    if awareness_counts and not report_only:
        _load_chart_libs()
        fig, ax = plt.subplots(figsize=FIG_SIZE)
        brands, counts = zip(*awareness_counts.most_common(10))
        ax.barh(list(reversed(brands)), list(reversed(counts)), color=COLORS[0], edgecolor="white")
        ax.set_title("Top-of-Mind Brand Awareness", fontsize=14, fontweight="bold")
        ax.set_xlabel("Mentions")
        plt.tight_layout()
        fig.savefig(output_dir / "chart_awareness.png", dpi=DPI, bbox_inches="tight")
        plt.close(fig)

    if consideration_counts and not report_only:
        _load_chart_libs()
        fig, ax = plt.subplots(figsize=FIG_SIZE)
        brands, counts = zip(*consideration_counts.most_common(10))
        ax.barh(list(reversed(brands)), list(reversed(counts)), color=COLORS[1], edgecolor="white")
        ax.set_title("Brand Consideration Set Mentions", fontsize=14, fontweight="bold")
        ax.set_xlabel("Mentions")
        plt.tight_layout()
        fig.savefig(output_dir / "chart_consideration.png", dpi=DPI, bbox_inches="tight")
        plt.close(fig)

    return summary


# ─── Analysis: Price Sensitivity ──────────────────────────────────────────


def analyze_price_test(results: list[dict], df: pd.DataFrame, output_dir: Path, report_only: bool = False):
    """Analyze price sensitivity results."""
    if df.empty:
        print("WARNING: No valid responses to analyze")
        return {"total_respondents": 0}
    summary = {
        "total_respondents": len(df),
        "segments": df["segment"].value_counts().to_dict(),
    }

    if _is_degenerate_crosstab(df):
        _generate_persona_comparison(df, output_dir)

    wtp = _numeric_series(df, "max_wtp")
    wtp_summary = _summarize_numeric(wtp)
    if wtp_summary:
        summary["willingness_to_pay"] = wtp_summary

        if not report_only:
            _load_chart_libs()
            fig, ax = plt.subplots(figsize=FIG_SIZE)
            ax.hist(wtp, bins=min(10, max(4, len(wtp))), color=COLORS[0], edgecolor="white")
            ax.set_title("Willingness to Pay Distribution", fontsize=14, fontweight="bold")
            ax.set_xlabel("Maximum WTP ($)")
            ax.set_ylabel("Count")
            plt.tight_layout()
            fig.savefig(output_dir / "chart_wtp.png", dpi=DPI, bbox_inches="tight")
            plt.close(fig)

    intent_by_price = defaultdict(list)
    for result in results:
        price_map = result.get("responses", {}).get("intent_by_price", {})
        if not isinstance(price_map, dict):
            continue
        for label, intent in price_map.items():
            try:
                numeric_intent = float(intent)
            except (TypeError, ValueError):
                continue
            _, cleaned_label = _parse_price_label(str(label))
            intent_by_price[cleaned_label].append(numeric_intent)

    if intent_by_price:
        summary["mean_intent_by_price"] = {
            label: round(sum(values) / len(values), 2)
            for label, values in sorted(intent_by_price.items(), key=lambda item: _parse_price_label(item[0])[0])
        }

        if not report_only:
            _load_chart_libs()
            fig, ax = plt.subplots(figsize=FIG_SIZE)
            prices = list(summary["mean_intent_by_price"].keys())
            intents = list(summary["mean_intent_by_price"].values())
            ax.plot(prices, intents, "o-", color=COLORS[0], linewidth=2, markersize=8)
            ax.set_title("Purchase Intent by Price Point", fontsize=14, fontweight="bold")
            ax.set_xlabel("Price")
            ax.set_ylabel("Mean Purchase Intent (1-5)")
            ax.set_ylim(0.5, 5.5)
            plt.xticks(rotation=30, ha="right")
            plt.tight_layout()
            fig.savefig(output_dir / "chart_demand_curve.png", dpi=DPI, bbox_inches="tight")
            plt.close(fig)

    if "price_quality_preference" in df.columns:
        summary["price_quality_preference"] = df["price_quality_preference"].dropna().value_counts().to_dict()

    competitive = _numeric_series(df, "competitive_reference")
    competitive_summary = _summarize_numeric(competitive)
    if competitive_summary:
        summary["competitive_reference"] = competitive_summary

    return summary


# ─── Analysis: Usage Habits ───────────────────────────────────────────────


def analyze_usage_habits(results: list[dict], df: pd.DataFrame, output_dir: Path, report_only: bool = False):
    """Analyze usage-habits responses with dedicated summary logic."""
    if df.empty:
        print("WARNING: No valid responses to analyze")
        return {"total_respondents": 0}
    summary = {
        "total_respondents": len(df),
        "segments": df["segment"].value_counts().to_dict(),
    }

    if _is_degenerate_crosstab(df):
        _generate_persona_comparison(df, output_dir)

    usage_counts = defaultdict(Counter)
    usage_labels = {}
    factor_scores = defaultdict(list)
    factor_labels = {}
    purchase_channels = Counter()
    current_products = Counter()
    info_sources = Counter()

    for result in results:
        responses = result.get("responses", {})

        usage_frequency = responses.get("usage_frequency", {})
        if isinstance(usage_frequency, dict):
            for occasion, frequency in usage_frequency.items():
                key = _normalize_usage_label(occasion)
                usage_labels.setdefault(key, _display_label(key))
                usage_counts[key][str(frequency).lower()] += 1

        factor_ranking = responses.get("factor_ranking", {})
        if isinstance(factor_ranking, dict):
            for factor, rank in factor_ranking.items():
                try:
                    numeric_rank = float(rank)
                except (TypeError, ValueError):
                    continue
                key = _normalize_key(factor)
                factor_labels.setdefault(key, str(factor))
                factor_scores[key].append(numeric_rank)

        channel = _short_text_label(responses.get("purchase_channel", ""))
        if channel:
            purchase_channels[channel] += 1

        product = _short_text_label(responses.get("current_product", ""))
        if product:
            current_products[product] += 1

        source_list = responses.get("info_sources", [])
        if isinstance(source_list, str):
            source_list = [item.strip() for item in source_list.split(";") if item.strip()]
        for source in source_list:
            label = _short_text_label(source)
            if label:
                info_sources[label] += 1

    summary["usage_frequency_by_occasion"] = {
        usage_labels[key]: dict(counts)
        for key, counts in sorted(usage_counts.items(), key=lambda item: usage_labels.get(item[0], item[0]))
    }
    summary["factor_importance"] = {
        factor_labels[key]: round(sum(values) / len(values), 2)
        for key, values in sorted(factor_scores.items(), key=lambda item: sum(item[1]) / len(item[1]))
    }
    summary["top_purchase_channels"] = _sorted_counter(purchase_channels, 5)
    summary["top_current_products"] = _sorted_counter(current_products, 5)
    summary["top_info_sources"] = _sorted_counter(info_sources, 8)

    if factor_scores and not report_only:
        _load_chart_libs()
        fig, ax = plt.subplots(figsize=FIG_SIZE)
        ordered = list(summary["factor_importance"].items())[:7]
        labels = [label for label, _ in ordered]
        values = [value for _, value in ordered]
        ax.barh(list(reversed(labels)), list(reversed(values)), color=COLORS[0], edgecolor="white")
        ax.set_title("Average Factor Importance (Lower = More Important)", fontsize=14, fontweight="bold")
        ax.set_xlabel("Average Rank")
        plt.tight_layout()
        fig.savefig(output_dir / "chart_factor_importance.png", dpi=DPI, bbox_inches="tight")
        plt.close(fig)

    return summary


# ─── Analysis: Generic Survey ─────────────────────────────────────────────


def analyze_generic(results: list[dict], df: pd.DataFrame, output_dir: Path, report_only: bool = False):
    """Basic analysis for custom surveys."""
    if df.empty:
        print("WARNING: No valid responses to analyze")
        return {"total_respondents": 0}
    summary = {
        "total_respondents": len(df),
        "segments": df["segment"].value_counts().to_dict(),
        "response_keys": sorted(
            {
                key
                for result in results
                for key in result.get("responses", {}).keys()
            }
        ),
    }

    if _is_degenerate_crosstab(df):
        _generate_persona_comparison(df, output_dir)

    numeric_summary = {}
    numeric_cols = df.select_dtypes(include=["number"]).columns.tolist()
    numeric_cols = [column for column in numeric_cols if column != "age"]
    for column in numeric_cols:
        series = _numeric_series(df, column)
        stats = _summarize_numeric(series)
        if stats:
            numeric_summary[column] = stats
    if numeric_summary:
        summary["numeric_summary"] = numeric_summary

    categorical_summary = {}
    for column in df.columns:
        if column in {"name", "segment", "age", "gender", "occupation"}:
            continue
        series = df[column].dropna()
        if series.empty:
            continue
        if pd.api.types.is_numeric_dtype(series):
            continue
        unique_values = series.astype(str).nunique()
        if unique_values <= 8:
            categorical_summary[column] = series.astype(str).value_counts().head(8).to_dict()
    if categorical_summary:
        summary["categorical_summary"] = categorical_summary

    text_columns = []
    for column in df.columns:
        if column in {"name", "segment", "age", "gender", "occupation"}:
            continue
        series = df[column].dropna().astype(str)
        if series.empty:
            continue
        avg_length = series.map(len).mean()
        if avg_length >= 25:
            text_columns.append(column)
    return summary


# ─── Analysis: Ask ─────────────────────────────────────────────────────────


def analyze_ask(results: list[dict], df: pd.DataFrame, output_dir: Path, report_only: bool = False):
    """Analyze open-question ask results: themes, emotions."""
    if df.empty:
        print("WARNING: No valid responses to analyze")
        return {"total_respondents": 0}
    all_themes: list[str] = []
    all_emotions: list[str] = []

    for result in results:
        responses = result.get("responses", {})
        themes = responses.get("themes", [])
        if isinstance(themes, list):
            all_themes.extend(t.strip().lower() for t in themes if isinstance(t, str) and t.strip())
        emotion = responses.get("emotion", "")
        if isinstance(emotion, str) and emotion.strip():
            all_emotions.append(emotion.strip().lower())

    theme_counts = Counter(all_themes)
    emotion_counts = Counter(all_emotions)

    summary = {
        "total_respondents": len(results),
        "segments": df["segment"].value_counts().to_dict(),
        "top_signals": [{"theme": t, "count": c} for t, c in theme_counts.most_common(10)],
        "emotion_distribution": dict(emotion_counts.most_common()),
    }

    if _is_degenerate_crosstab(df):
        _generate_persona_comparison(df, output_dir)

    return summary


# ─── Report Generation ─────────────────────────────────────────────────────


def _build_panel_overview(results: list[dict], survey_type: str) -> list[str]:
    headers = ["#", "Name", "Age", "Occupation", "Profile"]
    rows = []

    for index, result in enumerate(results, 1):
        responses = result.get("responses", {})
        row = [
            str(index),
            result.get("name", ""),
            str(int(result["age"])) if pd.notna(result.get("age")) else "",
            result.get("occupation", ""),
            result.get("segment", ""),
        ]
        if survey_type == "concept-test":
            headers.append("Preferred") if "Preferred" not in headers else None
            row.append(str(responses.get("preferred_option", "")))
        elif survey_type == "brand-map":
            headers.append("Consideration") if "Consideration" not in headers else None
            row.append(_truncate(", ".join(responses.get("consideration_set", [])[:3]), 40))
        elif survey_type == "price-test":
            headers.extend(["Max WTP"]) if "Max WTP" not in headers else None
            row.append(str(responses.get("max_wtp", "")))
        elif survey_type == "usage-habits":
            headers.append("Current Product") if "Current Product" not in headers else None
            row.append(_truncate(_short_text_label(responses.get("current_product", "")), 30))
        elif survey_type == "ask":
            headers.append("Emotion") if "Emotion" not in headers else None
            row.append(str(responses.get("emotion", "")))
        else:
            first_items = []
            for key, value in list(responses.items())[:2]:
                first_items.append(f"{key}: {_format_value(value, limit=30)}")
            headers.append("Key Response") if "Key Response" not in headers else None
            row.append(_truncate("; ".join(first_items), 40))
        rows.append(row)

    lines = ["## Panel Overview", "| " + " | ".join(headers) + " |", "| " + " | ".join(["---"] * len(headers)) + " |"]
    for row in rows:
        lines.append("| " + " | ".join(row) + " |")
    lines.append("")
    return lines


def _render_concept_analysis(results: list[dict]) -> list[str]:
    """Concept-centric section: support reasons, who passed, and improvement themes."""
    lines = ["## Concept Analysis", ""]
    total = len(results)

    by_concept: dict[str, list[dict]] = {}
    for r in results:
        opt = r.get("responses", {}).get("preferred_option", "")
        if opt:
            by_concept.setdefault(opt, []).append(r)

    all_improvements: list[tuple[str, str, str]] = []

    for opt in sorted(by_concept.keys()):
        choosers = by_concept[opt]
        passers = [r for r in results if r.get("responses", {}).get("preferred_option", "") != opt]
        lines.append(f"### Concept {opt} — {len(choosers)}/{total} chose this")

        lines.append("**Support reasons:**")
        for r in choosers:
            name = r.get("name", "")
            reasoning = _truncate(r.get("responses", {}).get("reasoning", ""), 600, sentence_aware=True)
            if reasoning:
                lines.append(f"- {name}: {reasoning}")
        lines.append("")

        if passers:
            passer_desc = ", ".join(
                f"{r['name']} (chose {r.get('responses', {}).get('preferred_option', '')})"
                for r in passers
            )
            lines.append(f"**Who passed:** {passer_desc}")
            lines.append("")

        for r in choosers:
            imp = r.get("responses", {}).get("improvement", "")
            if imp:
                all_improvements.append((opt, r.get("name", ""), imp))

    if all_improvements:
        lines.append("### Improvement Themes")
        for opt, name, text in all_improvements:
            lines.append(f"- **Concept {opt}** ({name}): {_truncate(text, 400, sentence_aware=True)}")
        lines.append("")

    return lines


def _render_concept_profiles(results: list[dict]) -> list[str]:
    lines = ["## Profile Analysis"]
    for result in results:
        responses = result.get("responses", {})
        preferred = responses.get("preferred_option", "")
        purchase = responses.get("purchase_likelihood")
        improvement = responses.get("improvement", "")
        detail = _truncate(responses.get("reasoning", ""), 600, sentence_aware=True)
        prefix = f"**{result.get('segment', '')}** ({result.get('name', '')}, {result.get('age', '?')}):"
        if preferred:
            prefix += f" Chose option {preferred}"
            if purchase not in (None, ""):
                prefix += f" with purchase likelihood {purchase}/5."
            else:
                prefix += "."
        lines.append(prefix)
        if detail:
            lines.append(detail)
        if improvement:
            lines.append(f"Suggested improvement: {_truncate(improvement, 300, sentence_aware=True)}")
        lines.append("")
    return lines


def _render_brand_profiles(results: list[dict]) -> list[str]:
    lines = ["## Brand Profiles"]
    for result in results:
        responses = result.get("responses", {})
        awareness = ", ".join(responses.get("unaided_awareness", [])[:3]) or "no clear top-of-mind brands"
        consideration = ", ".join(responses.get("consideration_set", [])[:3]) or "no active consideration set"
        buckets = responses.get("brand_buckets", {}) if isinstance(responses.get("brand_buckets"), dict) else {}
        likes = ", ".join(buckets.get("like", [])[:3])
        associations = responses.get("brand_associations", {}) if isinstance(responses.get("brand_associations"), dict) else {}
        association_line = ""
        if associations:
            brand, text = next(iter(associations.items()))
            association_line = f"{brand}: {_truncate(text, 220)}"
        lines.append(
            f"**{result.get('segment', '')}** ({result.get('name', '')}, {result.get('age', '?')}): "
            f"Top-of-mind brands are {awareness}. Consideration set includes {consideration}."
        )
        if likes:
            lines.append(f"Positive leaning: {likes}.")
        if association_line:
            lines.append(association_line)
        lines.append("")
    return lines


def _render_price_profiles(results: list[dict]) -> list[str]:
    lines = ["## Price Profiles"]
    for result in results:
        responses = result.get("responses", {})
        favorite_price, favorite_intent = _favorite_price_point(responses.get("intent_by_price", {}))
        preference = responses.get("price_quality_preference", "")
        reasoning = _truncate(responses.get("reasoning", ""), 260)
        value_perception = _truncate(responses.get("value_perception", ""), 220)
        lead = (
            f"**{result.get('segment', '')}** ({result.get('name', '')}, {result.get('age', '?')}): "
            f"Max WTP is ${responses.get('max_wtp', 'n/a')}."
        )
        if favorite_price:
            lead += f" Highest stated intent is at {favorite_price} ({favorite_intent}/5)."
        if preference:
            lead += f" Leans {preference} on price-quality tradeoffs."
        lines.append(lead)
        if reasoning:
            lines.append(reasoning)
        if value_perception:
            lines.append(f"Value perception: {value_perception}")
        lines.append("")
    return lines


def _render_usage_profiles(results: list[dict]) -> list[str]:
    lines = ["## Usage Profiles"]
    for result in results:
        responses = result.get("responses", {})
        usage_summary = _summarize_usage_frequency(responses.get("usage_frequency", {}))
        top_factor = _top_ranked_factor(responses.get("factor_ranking", {}))
        current_product = _truncate(responses.get("current_product", ""), 180)
        purchase_channel = _truncate(responses.get("purchase_channel", ""), 160)
        pain_points = _truncate(responses.get("pain_points", ""), 240)
        lines.append(
            f"**{result.get('segment', '')}** ({result.get('name', '')}, {result.get('age', '?')}): "
            f"Current product is {_short_text_label(current_product) or 'not specified'}. "
            f"Most active occasions: {usage_summary}."
        )
        if top_factor:
            lines.append(f"Top decision factor: {top_factor}.")
        if purchase_channel:
            lines.append(f"Purchase channel: {purchase_channel}")
        if pain_points:
            lines.append(f"Pain points: {pain_points}")
        lines.append("")
    return lines


def _render_ask_synthesis(results: list[dict], summary: dict) -> list[str]:
    """Render signal and emotion synthesis section for ask reports."""
    lines = ["## Signal Analysis", ""]

    top_signals = summary.get("top_signals", [])
    if top_signals:
        lines.append("### Top Signals")
        for item in top_signals[:6]:
            count = item["count"]
            total = summary.get("total_respondents", len(results))
            lines.append(f"- **{item['theme']}** — {count}/{total} personas")
        lines.append("")

    emotions = summary.get("emotion_distribution", {})
    if emotions:
        lines.append("### Emotion Landscape")
        for emotion, count in list(emotions.items())[:6]:
            lines.append(f"- {emotion}: {count}")
        lines.append("")

    return lines


def _render_ask_profiles(results: list[dict]) -> list[str]:
    """Render per-persona answer profiles for ask reports."""
    lines = ["## Response Profiles"]
    for result in results:
        responses = result.get("responses", {})
        short_answer = _truncate(responses.get("short_answer", ""), 400, sentence_aware=True)
        emotion = responses.get("emotion", "")
        themes = responses.get("themes", [])
        theme_str = ", ".join(themes[:3]) if isinstance(themes, list) else ""
        profile_label = result.get("segment", "")
        name = result.get("name", "")
        age = result.get("age", "?")
        suffix = f" [{emotion}]" if emotion else ""
        lines.append(
            f"**{profile_label}** ({name}, {age}){suffix}: {short_answer}"
        )
        if theme_str:
            lines.append(f"  *Themes*: {theme_str}")
        lines.append("")
    return lines


def _render_generic_profiles(results: list[dict]) -> list[str]:
    lines = ["## Response Profiles"]
    for result in results:
        responses = result.get("responses", {})
        parts = []
        for key, value in list(responses.items())[:4]:
            parts.append(f"{key}: {_format_value(value, limit=140)}")
        lines.append(
            f"**{result.get('segment', '')}** ({result.get('name', '')}, {result.get('age', '?')}): "
            + "; ".join(parts)
        )
        lines.append("")
    return lines


def _extract_findings(df: pd.DataFrame, summary: dict, survey_type: str) -> list[str]:
    """Extract 3-5 key findings from the summary data."""
    findings = []
    n = len(df)

    if survey_type == "concept-test" and "overall_preference" in summary:
        preferences = summary["overall_preference"]
        if preferences:
            winner = max(preferences, key=preferences.get)
            winner_pct = round(preferences[winner] / n * 100)
            if winner_pct > 60:
                findings.append(f"Concept {winner} is the clear winner with {winner_pct}% preference.")
            elif winner_pct > 40:
                findings.append(f"Concept {winner} leads with {winner_pct}%, but the panel is still split.")
            else:
                findings.append(f"No clear winner emerged; the leading option is {winner} at {winner_pct}%.")
        if "purchase_likelihood" in summary:
            findings.append(f"Average purchase likelihood is {summary['purchase_likelihood']['mean']}/5.")

    elif survey_type == "brand-map":
        awareness = summary.get("unaided_awareness_top10", {})
        if awareness:
            findings.append(
                "Top-of-mind awareness is led by "
                + ", ".join(list(awareness.keys())[:3])
                + "."
            )
        consideration = summary.get("consideration_top10", {})
        if consideration:
            findings.append(
                "Active consideration clusters around "
                + ", ".join(list(consideration.keys())[:3])
                + "."
            )
        for brand, counts in summary.get("brand_sentiment", {}).items():
            if counts.get("like", 0) and counts.get("dislike", 0):
                findings.append(
                    f"{brand} is polarizing: {counts.get('like', 0)} positive mentions vs {counts.get('dislike', 0)} negative mentions."
                )
                break

    elif survey_type == "price-test":
        wtp = summary.get("willingness_to_pay")
        if wtp:
            findings.append(f"Mean WTP is ${wtp['mean']} with a median of ${wtp['median']}.")
        intent = summary.get("mean_intent_by_price", {})
        if intent:
            best_price = max(intent, key=intent.get)
            worst_price = min(intent, key=intent.get)
            findings.append(f"Purchase intent peaks at {best_price} and is weakest at {worst_price}.")
        preference = summary.get("price_quality_preference", {})
        if preference:
            leader = max(preference, key=preference.get)
            findings.append(f"The dominant price-quality stance is `{leader}`.")

    elif survey_type == "usage-habits":
        factor_importance = summary.get("factor_importance", {})
        if factor_importance:
            top_factors = list(factor_importance.keys())[:2]
            findings.append("Top decision factors are " + " and ".join(top_factors) + ".")
        channels = summary.get("top_purchase_channels", {})
        if channels:
            findings.append(
                "Purchase happens primarily through "
                + ", ".join(list(channels.keys())[:3])
                + "."
            )
        occasions = summary.get("usage_frequency_by_occasion", {})
        if occasions:
            best = max(
                occasions.items(),
                key=lambda item: item[1].get("daily", 0),
            )
            findings.append(f"The strongest daily usage occasion is {best[0]}.")

    elif survey_type == "survey":
        numeric_summary = summary.get("numeric_summary", {})
        if numeric_summary:
            field, stats = next(iter(numeric_summary.items()))
            findings.append(f"{field} averages {stats['mean']} across the panel.")
        categorical = summary.get("categorical_summary", {})
        if categorical:
            field, counts = next(iter(categorical.items()))
            leading = max(counts, key=counts.get)
            findings.append(f"The most common response on {field} is `{leading}`.")

    elif survey_type == "ask":
        top_signals = summary.get("top_signals", [])
        if top_signals:
            signal_labels = ", ".join(item["theme"] for item in top_signals[:3])
            findings.append(f"Recurring signals: {signal_labels}.")
        emotions = summary.get("emotion_distribution", {})
        if emotions:
            dominant = max(emotions, key=emotions.get)
            findings.append(f"Dominant emotional tone is `{dominant}` ({emotions[dominant]}/{n} personas).")

    return findings[:5]


def resolve_report_date(output_dir: Path) -> str:
    """Prefer the original run timestamp when metadata is available."""
    metadata = _load_run_metadata(output_dir)
    if metadata:
        timestamp = metadata.get("timestamp")
        if timestamp:
            return str(timestamp).split("T", 1)[0]
    return date.today().isoformat()


def _load_run_metadata(output_dir: Path) -> dict | None:
    metadata_path = output_dir / "run_metadata.json"
    if not metadata_path.exists():
        return None
    try:
        return json.loads(metadata_path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return None


def _read_topic_from_config(config_path: Path) -> str | None:
    if not config_path.exists():
        return None
    try:
        config = json.loads(config_path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return None
    topic = config.get("topic")
    return str(topic).strip() if topic else None


def _read_user_question_from_config(config_path: Path) -> str | None:
    if not config_path.exists():
        return None
    try:
        config = json.loads(config_path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return None
    uq = config.get("variables", {}).get("user_question")
    return str(uq).strip() if uq else None


def _resolve_topic(output_dir: Path, df: pd.DataFrame, survey_type: str | None = None) -> str:
    """Resolve report topic from metadata, config, or segment labels."""
    metadata = _load_run_metadata(output_dir)
    if metadata:
        if survey_type == "ask":
            user_question = metadata.get("user_question")
            if user_question:
                return str(user_question).strip()
            # Fallback: read user_question from config file before using generic topic
            config_file = metadata.get("config_file")
            if config_file:
                config_path = Path(str(config_file))
                if not config_path.is_absolute():
                    config_path = Path.cwd() / config_path
                uq = _read_user_question_from_config(config_path)
                if uq:
                    return uq
        topic = metadata.get("topic")
        if topic:
            return str(topic)

        config_file = metadata.get("config_file")
        if config_file:
            config_path = Path(str(config_file))
            if not config_path.is_absolute():
                config_path = Path.cwd() / config_path
            topic = _read_topic_from_config(config_path)
            if topic:
                return topic

    for candidate in [output_dir / "config.json", output_dir.parent / "config.json",
                      output_dir / "ask-config.json"]:
        if survey_type == "ask":
            uq = _read_user_question_from_config(candidate)
            if uq:
                return uq
        topic = _read_topic_from_config(candidate)
        if topic:
            return topic

    segments = list(df["segment"].unique())[:3] if not df.empty else []
    return ", ".join(segments) if segments else "Survey"


def generate_markdown_report(
    results: list[dict],
    df: pd.DataFrame,
    summary: dict,
    survey_type: str,
    output_dir: Path,
    report_date: str | None = None,
    topic: str | None = None,
) -> str:
    """Generate a one-pager markdown report from survey results."""
    today = report_date or resolve_report_date(output_dir)
    survey_label = survey_type.replace("-", " ").title()
    if not topic:
        topic = _resolve_topic(output_dir, df, survey_type=survey_type)
    if survey_type == "ask":
        lines = [
            f"# Ask: {topic}",
            "",
            f"**Date**: {today} | **Panel**: {len(results)} personas",
            "",
        ]
    else:
        lines = [
            f"# {survey_label}: {topic} — Virtual Research Report",
            "",
            f"**Date**: {today} | **Panel**: {len(results)} personas",
            "",
        ]

    lines.extend(_build_panel_overview(results, survey_type))

    lines.append("## Key Findings")
    for index, finding in enumerate(_extract_findings(df, summary, survey_type), 1):
        lines.append(f"{index}. {finding}")
    lines.append("")

    if survey_type == "concept-test":
        lines.extend(_render_concept_analysis(results))
        lines.extend(_render_concept_profiles(results))
    elif survey_type == "brand-map":
        lines.extend(_render_brand_profiles(results))
    elif survey_type == "price-test":
        lines.extend(_render_price_profiles(results))
    elif survey_type == "usage-habits":
        lines.extend(_render_usage_profiles(results))
    elif survey_type == "ask":
        lines.extend(_render_ask_synthesis(results, summary))
        lines.extend(_render_ask_profiles(results))
    else:
        lines.extend(_render_generic_profiles(results))

    lines.append("## Notable Verbatims")
    verbatims = _collect_verbatims(results, survey_type)
    if verbatims:
        for text, name, age, occupation in verbatims:
            lines.append(f'> "{_truncate(text, 600, sentence_aware=True)}" — {name}, {age}, {occupation}')
            lines.append("")
    else:
        lines.append("No verbatim text was captured in this run.")
        lines.append("")

    lines.append("## Caveats")
    lines.append(f"- Virtual panel of {len(results)} personas; directional only")
    lines.append("- Not statistically representative; use for hypothesis generation")
    lines.append("- AI-generated responses may exhibit positivity bias")
    lines.append("")

    report = "\n".join(lines)
    report_path = output_dir / "report.md"
    report_path.write_text(report, encoding="utf-8")
    print(f"Saved report: {report_path}")
    return report


# ─── LLM Report Generation ────────────────────────────────────────────────

GENERIC_REPORT_SYSTEM_PROMPT = """\
You are a consumer research analyst writing a one-pager report from virtual persona survey results.

## Report Structure

Write a markdown report with these sections:

# [Survey Type]: [Topic] — Virtual Research Report

**Date**: {date} | **Panel**: {n} personas

## Panel Overview
[Table: #, Name, Age, Occupation, Profile, and any survey-specific columns like Preferred Option or Max WTP]

## Key Findings
[3-5 insights focusing on PATTERNS, SPLITS, and SURPRISES across the panel.
DO NOT just summarize each persona — synthesize across them.
Highlight: which personas agree/disagree, what drives the split, any unexpected choices.]

## Profile Analysis
[One paragraph per persona. For each, explain:
- What they chose and why (grounded in their personality/background)
- How their Big Five traits or life circumstances influenced the response
- What makes their perspective unique vs. the rest of the panel
Write in third person, not persona voice.]

## Notable Verbatims
[Pick 2-3 direct quotes from the most relevant free-text fields.
Include full quotes, do not truncate.
Format: > "quote" — Name, Age, Occupation]

## Caveats
- Virtual panel of {n} personas; directional only
- Not statistically representative; use for hypothesis generation
- AI-generated responses may exhibit positivity bias

## Rules
- Write for a marketing manager audience — insightful but accessible
- Use data from the summary statistics to support findings with numbers
- DO NOT invent data or statistics not present in the results
- Total length: 400-800 words (excluding panel table and verbatims)
"""


ASK_REPORT_SYSTEM_PROMPT = """\
You are a consumer research analyst writing a qualitative synthesis report from an open-ended
"ask" study where a virtual persona panel answered a single research question.

Your job is cross-persona SYNTHESIS, not per-persona summarization. A reader should be able to
skim this and understand what the panel collectively said, where they agreed, and where the
interesting splits live.

## Report Structure

Use this exact markdown skeleton. All sections are required.

# Ask: [Topic]

**Date**: {date} | **Panel**: {n} personas

## Panel Overview
[Markdown table: #, Name, Age, Occupation, Profile, Emotion]

## Direct Answer
[2-3 sentences. Summarize what the panel said overall — the dominant response pattern —
WITHOUT attributing to any single persona. Lead with the most important takeaway.]

## Key Findings
1. [First synthesized insight — a pattern, split, or surprise]
2. [Second insight]
3. [Third insight]
[Include 3-5 findings. Every finding must be cross-persona; no per-persona summary sentences.
No-occurrence observations are valuable ("0 positive emotions across 10 responses").
Absolutely NO filler like "Panel size is N personas" or "Panel covers N segments".]

## Where They Agreed
[What most or all personas said in common. 1-3 sentences. Focus on the shared belief/complaint/
experience, not on listing names.]

## Where They Differed
[Notable splits — by age, segment, personality, circumstance, emotional register, engagement level.
2-4 sentences. Name the axis of difference explicitly.]

## Notable Verbatims
[3-4 direct quotes from `reasoning` fields. Use FULL SENTENCES — do NOT truncate mid-sentence.
Pick quotes that deliver the panel's feeling, not generic statements.
Format: > "quote" — Name, Age, Occupation]

## Top Signals
[Cluster semantically-similar themes before counting. For example, "greenwashing and false claims",
"greenwashing with no accountability", and "greenwashing by brands" are ONE cluster mentioned by 3
personas — not three separate signals of 1 each. Output as:
- **cluster label** (N) — short gloss
List 3-7 clusters ordered by count.]

## Emotion Distribution
[Raw emotion counts with a one-line observation. Call out striking patterns:
three-way ties, skewed-negative panels, absent positive emotions, single outlier emotions.
Format: emotion (N), emotion (N), ...  followed by a 1-sentence takeaway.]

## Caveats
- Virtual panel of {n} personas; directional only
- Not statistically representative; use for hypothesis generation
- AI-generated responses may exhibit positivity bias

## Rules
- Write for a marketing / consumer-insights audience.
- Synthesis over summary: no "Profile Analysis" section with one paragraph per persona.
- Do NOT invent data not present in results.
- Do NOT use mechanical filler findings.
- Do NOT truncate verbatim quotes mid-sentence; prefer shorter quotes to mid-sentence cuts.
- When in doubt, ground every claim in specific personas or `reasoning` snippets.
"""


CONCEPT_TEST_REPORT_SYSTEM_PROMPT = """\
You are a consumer research analyst writing a concept-test synthesis report from a virtual persona
panel that evaluated multiple product concepts (typically A/B/C).

Your job is cross-persona, cross-concept SYNTHESIS. A reader should be able to decide:
(1) which concept leads, (2) for whom, (3) what to fix, and (4) whether to move forward.

## Report Structure

Use this exact markdown skeleton. All sections are required.

# Concept Test: [Topic] — Virtual Research Report

**Date**: {date} | **Panel**: {n} personas

## Panel Overview
[Markdown table: #, Name, Age, Occupation, Profile, Preferred, Purchase Likelihood]

## Preference Verdict
[2-3 sentences. State the leader, the margin, and the confidence. Use precise language:
"Clear winner" (>60% preference), "Narrow lead" (40-60%), "No winner; tied" (ties),
"Fragmented" (every concept below 40% with multiple near-ties). Include raw counts.]

## Key Findings
1. [Cross-persona insight — who preferred what and why; not a per-persona summary]
2. [Second insight]
3. [Third insight]
[3-5 findings. Absolutely NO filler like "Panel size is N personas" or "Panel covers N segments".]

## Segment / Profile Splits
[Who picked what. Organize by segment/profile, not by persona. For each segment with a notable
pattern, state: segment → preference → the shared reason. 3-6 lines.]

## Purchase-Intent Drivers
[What moved `purchase_likelihood` up or down across the panel. Look for cross-concept patterns:
ingredient transparency, price, claim credibility, packaging, etc. Cite specific factors that
personas explicitly credited. 2-4 sentences.]

## Per-Concept Strengths and Weaknesses
[For each concept that received at least one vote or meaningful reaction:

### Concept [X] — N/total chose this
**Strengths (cross-persona):** What multiple personas liked. Synthesize, do not per-chooser-dump.
**Weaknesses (cross-persona):** What passers (and sometimes choosers) flagged as issues.

Write in third person. Reference 2-3 personas by name inline where it adds credibility, but
do NOT list every chooser's verbatim reasoning — that is noise, not analysis.]

## Improvement Theme Clusters
[Cluster semantically-similar `improvement` suggestions. Example: "disclose ceramide percentages",
"list niacinamide %", "show active concentrations" are ONE cluster about ingredient transparency.
Output as:
- **Cluster label** (N mentions across Concepts X, Y) — one-line gloss with 1-2 persona attributions.
List 3-6 clusters ordered by cross-concept relevance.]

## Notable Verbatims
[3-4 direct quotes from `reasoning` fields. FULL SENTENCES — do NOT truncate mid-sentence.
Pick quotes that illuminate the split or the verdict, not generic praise.
Format: > "quote" — Name, Age, Occupation]

## Caveats
- Virtual panel of {n} personas; directional only
- Not statistically representative; use for hypothesis generation
- AI-generated responses may exhibit positivity bias

## Rules
- Synthesis over summary. NO "Profile Analysis" section with one paragraph per persona.
- NO per-concept "support reasons" list that just repeats each chooser's reasoning verbatim.
- Cluster improvement suggestions; do not dump them.
- Do NOT invent data not present in results.
- Do NOT truncate verbatim quotes mid-sentence.
- When segments are small (N=1 per segment), state that explicitly rather than over-generalize.
"""


def _select_report_system_prompt(survey_type: str) -> str:
    """Pick the survey-type-specific LLM report prompt; fall back to generic."""
    return {
        "ask": ASK_REPORT_SYSTEM_PROMPT,
        "concept-test": CONCEPT_TEST_REPORT_SYSTEM_PROMPT,
    }.get(survey_type, GENERIC_REPORT_SYSTEM_PROMPT)


REPORT_SYSTEM_PROMPT = GENERIC_REPORT_SYSTEM_PROMPT


def generate_llm_report(
    results: list[dict],
    df: pd.DataFrame,
    summary: dict,
    survey_type: str,
    output_dir: Path,
    *,
    backend: str,
    model: str | None = None,
    report_date: str | None = None,
    topic: str | None = None,
    isolation: bool = True,
    effort: str | None = None,
) -> str:
    """Generate a narrative one-pager report using the resolved LLM backend."""
    system_prompt = _select_report_system_prompt(survey_type).format(
        date=report_date or resolve_report_date(output_dir),
        n=len(results),
    )

    resolved_topic = topic or _resolve_topic(output_dir, df, survey_type=survey_type)

    user_message = (
        f"Survey type: {survey_type}\n"
        f"Topic: {resolved_topic}\n\n"
        f"## Results Data ({len(results)} personas)\n\n"
        f"{json.dumps(results, indent=2, ensure_ascii=False)}\n\n"
        f"## Summary Statistics\n\n"
        f"{json.dumps(summary, indent=2, ensure_ascii=False)}\n\n"
        f"Generate the one-pager report now. Return only the markdown report content."
    )

    try:
        completion = run_text_completion(
            backend=backend,
            system_prompt=system_prompt,
            user_message=user_message,
            model=model,
            cwd=output_dir,
            isolation=isolation,
            effort=effort,
        )
    except (FileNotFoundError, RuntimeError, TimeoutError, json.JSONDecodeError) as error:
        print(f"WARNING: LLM report generation failed: {error}", file=sys.stderr)
        print("Falling back to Python report generation...", file=sys.stderr)
        fallback_report = generate_markdown_report(
            results,
            df,
            summary,
            survey_type,
            output_dir,
            report_date=report_date,
            topic=resolved_topic,
        )
        fallback_note = (
            "> **Note**: This report was generated using the Python template engine "
            f"(LLM report generation failed: {error}). "
            "Re-run with `--report-llm` to retry.\n\n"
        )
        return fallback_note + fallback_report

    report_text = completion["text"]

    report_text = re.sub(r"^```(?:markdown)?\s*\n", "", report_text)
    report_text = re.sub(r"\n```\s*$", "", report_text)

    report_path = output_dir / "report.md"
    report_path.write_text(report_text, encoding="utf-8")
    print(f"Saved LLM report: {report_path}")
    return report_text


# ─── Main ──────────────────────────────────────────────────────────────────


def main():
    parser = argparse.ArgumentParser(description="Persona Research Survey Analysis")
    parser.add_argument("--input", required=True, help="Path to results.json")
    parser.add_argument(
        "--survey-type",
        default="concept-test",
        choices=["concept-test", "brand-map", "price-test", "usage-habits", "survey", "ask"],
        help="Type of survey to analyze",
    )
    parser.add_argument(
        "--report-only",
        action="store_true",
        help="Skip chart generation, produce report.md only",
    )
    parser.add_argument(
        "--topic",
        help="Research topic for report title (auto-detected from config/manifest if omitted)",
    )
    parser.add_argument(
        "--report-llm",
        action="store_true",
        help="Generate report using the selected LLM backend for narrative synthesis",
    )
    parser.add_argument(
        "--model",
        help="Model override for the selected report backend",
    )
    parser.add_argument(
        "--backend",
        choices=BACKEND_CHOICES,
        default="auto",
        help="Default execution backend (`auto` infers from runtime markers/CLI availability)",
    )
    parser.add_argument(
        "--report-backend",
        choices=REPORT_BACKEND_CHOICES,
        default="same",
        help="Backend for LLM report generation (`same` uses --backend)",
    )
    parser.add_argument(
        "--no-isolation",
        action="store_true",
        help="Disable claude CLI --safe-mode context isolation for the report call",
    )
    parser.add_argument(
        "--effort",
        choices=["low", "medium", "high", "xhigh", "max"],
        help="claude CLI effort level for the report call (not supported by haiku)",
    )
    args = parser.parse_args()

    input_path = Path(args.input).resolve()
    if not input_path.exists():
        print(f"ERROR: Input file not found: {input_path}")
        sys.exit(1)

    output_dir = input_path.parent
    resolved_backend = None
    resolved_report_backend = "python"
    resolved_report_model = None
    if args.report_llm:
        resolved_backend = resolve_backend(args.backend)
        resolved_report_backend = resolve_report_backend(
            args.report_backend,
            resolved_backend,
        )
        if resolved_report_backend != "python":
            resolved_report_model = resolve_model(args.model, resolved_report_backend)

    print(f"Loading results from: {input_path}")
    raw_results = load_results(input_path)
    results = normalize_results(raw_results)
    print(f"Loaded {len(results)} responses")
    if args.report_llm:
        print(
            "LLM report backend: "
            f"{resolved_report_backend} | model: {format_model_label(resolved_report_model)}"
        )

    df = flatten_responses(results)
    csv_path = output_dir / "results.csv"
    df.to_csv(csv_path, index=False)
    print(f"Saved flat CSV: {csv_path}")

    analyzers = {
        "concept-test": analyze_concept_test,
        "brand-map": analyze_brand_map,
        "price-test": analyze_price_test,
        "usage-habits": analyze_usage_habits,
        "survey": analyze_generic,
        "ask": analyze_ask,
    }
    analyzer = analyzers[args.survey_type]
    summary = analyzer(results, df, output_dir, report_only=args.report_only)

    summary_path = output_dir / "summary.json"
    with open(summary_path, "w") as f:
        json.dump(summary, f, indent=2, ensure_ascii=False)
    print(f"Saved summary: {summary_path}")

    report_date = resolve_report_date(output_dir)
    if args.report_llm and resolved_report_backend != "python":
        generate_llm_report(
            results,
            df,
            summary,
            args.survey_type,
            output_dir,
            backend=resolved_report_backend,
            model=resolved_report_model,
            report_date=report_date,
            topic=args.topic,
            isolation=not args.no_isolation,
            effort=args.effort,
        )
    else:
        generate_markdown_report(
            results,
            df,
            summary,
            args.survey_type,
            output_dir,
            report_date=report_date,
            topic=args.topic,
        )

    print("\n" + "=" * 60)
    print("ANALYSIS SUMMARY")
    print("=" * 60)
    print(json.dumps(summary, indent=2, ensure_ascii=False))
    print("=" * 60)
    print(f"\nOutput files in: {output_dir}")
    for path in sorted(output_dir.glob("*")):
        if path.name != "results.json":
            print(f"  {path.name}")


if __name__ == "__main__":
    main()
