"""
PANAS SEMANTIC ANALYSIS // REVISION
====================================================================


import warnings
warnings.filterwarnings('ignore')

import pandas as pd
import numpy as np
from collections import Counter
import re
import json
import os
from itertools import combinations

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
import networkx as nx
from networkx.algorithms import community as nxcomm
import matplotlib.pyplot as plt
import seaborn as sns
from scipy import stats

# Random seed for reproducibility
RNG_SEED = 2026
np.random.seed(RNG_SEED)

# Lazy-load sentence-transformers (slow on import)
SBERT_MODEL = None
def get_sbert():
    global SBERT_MODEL
    if SBERT_MODEL is None:
        from sentence_transformers import SentenceTransformer
        SBERT_MODEL = SentenceTransformer('all-MiniLM-L6-v2')
    return SBERT_MODEL


# =============================================================================
# DATA LOADING
# =============================================================================

PANAS_ITEMS_PA = ['Interested', 'Excited', 'Strong', 'Enthusiastic', 'Proud',
                  'Alert', 'Inspired', 'Determined', 'Attentive', 'Active']
PANAS_ITEMS_NA = ['Distressed', 'Upset', 'Guilty', 'Scared', 'Hostile',
                  'Irritable', 'Ashamed', 'Nervous', 'Jittery', 'Afraid']

# Column mapping in cleaned_definitions.xlsx
COL_MAP = {
    'Q1b_Interested ': 'Interested',
    'Q2b_Distressed': 'Distressed',
    'Q3b_Excited': 'Excited',
    'Q4b_Upset': 'Upset',
    'Q5b_Strong': 'Strong',
    'Q6b_Guilty': 'Guilty',
    'Q7b_Scared': 'Scared',
    'Q8b_Hostile': 'Hostile',
    'Q9b_Enthusiastic': 'Enthusiastic',
    'Q10b_Proud': 'Proud',
    'Q11b_Irritable': 'Irritable',
    'Q12b_Alert': 'Alert',
    'Q13b_Ashamed': 'Ashamed',
    'Q14b_Inspired': 'Inspired',
    'Q15b_Nervous': 'Nervous',
    'Q16b_Determined': 'Determined',
    'Q17b_Attentive': 'Attentive',
    'Q18b_Jittery': 'Jittery',
    'Q19b_Active': 'Active',
    'Q20b': 'Afraid',
}

VALENCE = {it: 'PA' for it in PANAS_ITEMS_PA}
VALENCE.update({it: 'NA' for it in PANAS_ITEMS_NA})
ALL_ITEMS = PANAS_ITEMS_PA + PANAS_ITEMS_NA


def load_data(path='cleaned_definitions.xlsx'):
    df = pd.read_excel(path)
    out = {}
    for col, item in COL_MAP.items():
        defs = df[col].dropna().astype(str).tolist()
        # Basic cleaning
        defs = [d.strip().lower() for d in defs if len(d.strip()) >= 2]
        out[item] = defs
    return out


# =============================================================================
# 1. DEFINITION LENGTH & VOCABULARY DIAGNOSTICS
# =============================================================================

def definition_diagnostics(data):
    rows = []
    for item in ALL_ITEMS:
        defs = data[item]
        word_counts = [len(re.findall(r"\b\w+\b", d)) for d in defs]
        # vocabulary
        all_tokens = set()
        for d in defs:
            all_tokens.update(re.findall(r"\b[a-zA-Z]+\b", d.lower()))
        rows.append({
            'Item': item,
            'Valence': VALENCE[item],
            'n_retained': len(defs),
            'median_word_count': int(np.median(word_counts)),
            'mean_word_count': round(np.mean(word_counts), 2),
            'iqr_words': f"{int(np.percentile(word_counts,25))}-{int(np.percentile(word_counts,75))}",
            'vocab_size': len(all_tokens),
        })
    return pd.DataFrame(rows)


# =============================================================================
# 2. TF-IDF COHERENCE — FULL SAMPLE WITH BOOTSTRAP 95% CIs
# =============================================================================

def coherence_tfidf_full(defs, n_boot=1000, rng=None):
    """Full-sample pairwise cosine similarity with bootstrap CIs."""
    if rng is None:
        rng = np.random.default_rng(RNG_SEED)
    if len(defs) < 3:
        return {'mean_sim': np.nan, 'ci_low': np.nan, 'ci_high': np.nan, 'n': len(defs)}
    vec = TfidfVectorizer(stop_words='english', ngram_range=(1,2), min_df=2, max_features=200)
    try:
        X = vec.fit_transform(defs)
    except ValueError:
        return {'mean_sim': np.nan, 'ci_low': np.nan, 'ci_high': np.nan, 'n': len(defs)}
    S = cosine_similarity(X)
    n = S.shape[0]
    iu = np.triu_indices(n, k=1)
    upper = S[iu]
    point = upper.mean()
    # bootstrap by resampling definitions (rows) with replacement
    boot = []
    indices_full = np.arange(n)
    for _ in range(n_boot):
        idx = rng.choice(indices_full, size=n, replace=True)
        Sb = S[np.ix_(idx, idx)]
        iub = np.triu_indices(n, k=1)
        boot.append(Sb[iub].mean())
    lo, hi = np.percentile(boot, [2.5, 97.5])
    return {'mean_sim': point, 'ci_low': lo, 'ci_high': hi, 'n': n}


def compute_all_coherence(data, n_boot=1000):
    rng = np.random.default_rng(RNG_SEED)
    rows = []
    for item in ALL_ITEMS:
        res = coherence_tfidf_full(data[item], n_boot=n_boot, rng=rng)
        rows.append({
            'Item': item,
            'Valence': VALENCE[item],
            'n': res['n'],
            'cos_mean': round(res['mean_sim'], 3),
            'cos_95CI_low': round(res['ci_low'], 3),
            'cos_95CI_high': round(res['ci_high'], 3),
        })
    return pd.DataFrame(rows)


# =============================================================================
# 3. TOP FEATURES (ranked properly by TF-IDF weight and by raw %)
# =============================================================================

STOPWORDS_EXTRA = {'feel', 'feeling', 'feelings', 'felt', 'something', 'someone',
                   'state', 'emotion', 'thing', 'someones', "someone's", "your", "you",
                   "yourself", "this", "that", "you're", "would", "very"}

def top_features(defs, top_k=5):
    """Returns top features by raw frequency (%) and by mean TF-IDF weight."""
    # raw frequency of content words (single tokens)
    tokens = []
    for d in defs:
        ts = re.findall(r"\b[a-zA-Z][a-zA-Z\-']+\b", d.lower())
        ts = [t for t in ts if len(t) >= 3 and t not in STOPWORDS_EXTRA]
        tokens.extend(ts)
    word_doc_count = Counter()
    for d in defs:
        ts = set(re.findall(r"\b[a-zA-Z][a-zA-Z\-']+\b", d.lower()))
        ts = {t for t in ts if len(t) >= 3 and t not in STOPWORDS_EXTRA}
        for t in ts:
            word_doc_count[t] += 1
    total = len(defs)
    by_pct = sorted(word_doc_count.items(), key=lambda x: -x[1])

    # TF-IDF weight ranking
    vec = TfidfVectorizer(stop_words='english', ngram_range=(1,2),
                          min_df=2, max_features=20)
    try:
        X = vec.fit_transform(defs)
        weights = X.mean(axis=0).A1
        feats = vec.get_feature_names_out()
        by_tfidf = sorted(zip(feats, weights), key=lambda x: -x[1])
    except ValueError:
        by_tfidf = []

    pct_results = [(w, 100*c/total) for w,c in by_pct[:top_k]]
    tfidf_results = by_tfidf[:top_k]
    return pct_results, tfidf_results


# =============================================================================
# 4. PAIRWISE ITEM-LEVEL SEMANTIC SIMILARITY (aggregated definitions)
# =============================================================================

def item_level_similarity_tfidf(data):
    docs = [' '.join(data[item]) for item in ALL_ITEMS]
    # max_features=200 matches the original analysis pipeline
    vec = TfidfVectorizer(stop_words='english', ngram_range=(1,2),
                          min_df=1, max_features=200)
    X = vec.fit_transform(docs)
    S = cosine_similarity(X)
    return pd.DataFrame(S, index=ALL_ITEMS, columns=ALL_ITEMS)


def item_level_similarity_sbert(data):
    """Item-level similarity using sentence-transformer embeddings.
    Each item's definitions are embedded individually, then averaged."""
    model = get_sbert()
    item_emb = {}
    for item in ALL_ITEMS:
        embs = model.encode(data[item], show_progress_bar=False, batch_size=64)
        # Mean-pool embeddings then L2-normalize
        m = embs.mean(axis=0)
        m = m / (np.linalg.norm(m) + 1e-12)
        item_emb[item] = m
    M = np.array([item_emb[it] for it in ALL_ITEMS])
    S = M @ M.T
    return pd.DataFrame(S, index=ALL_ITEMS, columns=ALL_ITEMS)


# =============================================================================
# 5. PERMUTATION TEST FOR VALENCE STRUCTURE 
# =============================================================================

def valence_separation_permutation(sim_df, n_perm=10000):
    """
    Tests whether within-valence mean similarity > cross-valence mean similarity.
    Uses permutation of item-to-valence labels (respects dyadic dependence).
    Returns observed difference and one-sided permutation p-values.
    """
    rng = np.random.default_rng(RNG_SEED)
    items = list(sim_df.index)
    labels_obs = np.array([VALENCE[it] for it in items])

    def compute_means(labels):
        n = len(items)
        iu = np.triu_indices(n, k=1)
        S = sim_df.values
        wp_mask, wn_mask, cv_mask = [], [], []
        for i, j in zip(*iu):
            li, lj = labels[i], labels[j]
            if li == 'PA' and lj == 'PA':
                wp_mask.append(S[i, j])
            elif li == 'NA' and lj == 'NA':
                wn_mask.append(S[i, j])
            else:
                cv_mask.append(S[i, j])
        return np.mean(wp_mask) if wp_mask else np.nan, \
               np.mean(wn_mask) if wn_mask else np.nan, \
               np.mean(cv_mask) if cv_mask else np.nan

    wp_obs, wn_obs, cv_obs = compute_means(labels_obs)
    obs_diff_pa = wp_obs - cv_obs
    obs_diff_na = wn_obs - cv_obs

    # SDs
    iu = np.triu_indices(len(items), k=1)
    S = sim_df.values
    wp_list, wn_list, cv_list = [], [], []
    for i, j in zip(*iu):
        li, lj = labels_obs[i], labels_obs[j]
        if li == 'PA' and lj == 'PA':
            wp_list.append(S[i, j])
        elif li == 'NA' and lj == 'NA':
            wn_list.append(S[i, j])
        else:
            cv_list.append(S[i, j])

    # Permutation distribution
    null_diff_pa = []
    null_diff_na = []
    for _ in range(n_perm):
        perm = rng.permutation(labels_obs)
        wp_p, wn_p, cv_p = compute_means(perm)
        null_diff_pa.append(wp_p - cv_p)
        null_diff_na.append(wn_p - cv_p)
    null_diff_pa = np.array(null_diff_pa)
    null_diff_na = np.array(null_diff_na)
    p_pa = (np.sum(null_diff_pa >= obs_diff_pa) + 1) / (n_perm + 1)
    p_na = (np.sum(null_diff_na >= obs_diff_na) + 1) / (n_perm + 1)

    # Effect sizes: standardized mean diff relative to pooled SD of similarities
    pooled_sd_pa = np.sqrt(((len(wp_list)-1)*np.var(wp_list, ddof=1) +
                            (len(cv_list)-1)*np.var(cv_list, ddof=1)) /
                           (len(wp_list) + len(cv_list) - 2))
    pooled_sd_na = np.sqrt(((len(wn_list)-1)*np.var(wn_list, ddof=1) +
                            (len(cv_list)-1)*np.var(cv_list, ddof=1)) /
                           (len(wn_list) + len(cv_list) - 2))
    d_pa = obs_diff_pa / pooled_sd_pa
    d_na = obs_diff_na / pooled_sd_na

    return {
        'wp_M': wp_obs, 'wp_SD': np.std(wp_list, ddof=1), 'wp_n': len(wp_list), 'wp_range': (min(wp_list), max(wp_list)),
        'wn_M': wn_obs, 'wn_SD': np.std(wn_list, ddof=1), 'wn_n': len(wn_list), 'wn_range': (min(wn_list), max(wn_list)),
        'cv_M': cv_obs, 'cv_SD': np.std(cv_list, ddof=1), 'cv_n': len(cv_list), 'cv_range': (min(cv_list), max(cv_list)),
        'obs_diff_pa': obs_diff_pa, 'p_perm_pa': p_pa, 'd_pa': d_pa,
        'obs_diff_na': obs_diff_na, 'p_perm_na': p_na, 'd_na': d_na,
        'n_perm': n_perm,
    }


# =============================================================================
# 6. NETWORK ANALYSIS — INCLUDING THRESHOLD SENSITIVITY
# =============================================================================

def build_network(sim_df, threshold=0.15):
    G = nx.Graph()
    items = list(sim_df.index)
    for it in items:
        G.add_node(it, valence=VALENCE[it])
    n = len(items)
    for i in range(n):
        for j in range(i+1, n):
            w = sim_df.iloc[i, j]
            if w >= threshold:
                G.add_edge(items[i], items[j], weight=float(w))
    return G


def network_metrics(G):
    n = G.number_of_nodes()
    m = G.number_of_edges()
    density = nx.density(G)
    avg_clustering = nx.average_clustering(G, weight='weight') if m else 0.0
    degree_norm = nx.degree_centrality(G)
    raw_degree = dict(G.degree())
    betweenness = nx.betweenness_centrality(G, weight=None)
    # community detection (greedy modularity)
    communities = [list(c) for c in nxcomm.greedy_modularity_communities(G, weight='weight')]
    try:
        modularity = nxcomm.modularity(G, [set(c) for c in communities], weight='weight')
    except Exception:
        modularity = np.nan
    return {
        'n': n, 'm': m, 'density': density,
        'avg_clustering': avg_clustering,
        'degree_norm': degree_norm, 'raw_degree': raw_degree,
        'betweenness': betweenness,
        'communities': communities, 'modularity': modularity,
    }


def threshold_sensitivity(sim_df, thresholds=(0.10, 0.15, 0.20, 0.25)):
    rows = []
    comm_table = {}
    for t in thresholds:
        G = build_network(sim_df, threshold=t)
        met = network_metrics(G)
        rows.append({
            'threshold': t,
            'n_edges': met['m'],
            'density': round(met['density'], 3),
            'avg_clustering': round(met['avg_clustering'], 3),
            'n_communities': len(met['communities']),
            'modularity': round(met['modularity'], 3),
            'moral_cluster_present': any(set(['Guilty','Ashamed','Proud']).issubset(set(c)) for c in met['communities']),
        })
        comm_table[t] = met['communities']
    return pd.DataFrame(rows), comm_table


# =============================================================================
# 7. NETWORK BOOTSTRAP STABILITY (case-dropping for CS-coefficient style)
# =============================================================================

def bootstrap_network_stability(data, threshold=0.15, n_boot=200, drop_pcts=(0.25, 0.50, 0.75)):
    """
    Bootstrap the item-level similarity network by resampling participant
    definitions WITH REPLACEMENT, recomputing item-level similarities, and
    refitting the network. Reports:
      - Edge weight stability (mean and SD per edge)
      - Centrality stability under case-dropping (CS coefficient analog)
    """
    rng = np.random.default_rng(RNG_SEED)
    items = ALL_ITEMS
    n_items = len(items)

    # original
    orig_sim = item_level_similarity_tfidf(data)
    orig_G = build_network(orig_sim, threshold)
    orig_metrics = network_metrics(orig_G)
    orig_deg = orig_metrics['degree_norm']
    orig_btw = orig_metrics['betweenness']

    # edge bootstrap
    edge_weights = {}
    centralities_boot = {it: {'degree': [], 'betweenness': []} for it in items}

    for b in range(n_boot):
        # resample each item's definitions
        boot_data = {}
        for item in items:
            defs = data[item]
            idx = rng.integers(0, len(defs), size=len(defs))
            boot_data[item] = [defs[i] for i in idx]
        sim_b = item_level_similarity_tfidf(boot_data)
        G_b = build_network(sim_b, threshold)
        # record edges
        for i in range(n_items):
            for j in range(i+1, n_items):
                key = (items[i], items[j])
                w = sim_b.iloc[i, j]
                edge_weights.setdefault(key, []).append(float(w))
        # centralities
        deg = nx.degree_centrality(G_b)
        btw = nx.betweenness_centrality(G_b)
        for it in items:
            centralities_boot[it]['degree'].append(deg.get(it, 0.0))
            centralities_boot[it]['betweenness'].append(btw.get(it, 0.0))

    edge_stab = []
    for k, vals in edge_weights.items():
        edge_stab.append({
            'item_a': k[0], 'item_b': k[1],
            'mean_w': np.mean(vals), 'sd_w': np.std(vals, ddof=1),
            'ci_low': np.percentile(vals, 2.5),
            'ci_high': np.percentile(vals, 97.5),
        })
    edge_stab_df = pd.DataFrame(edge_stab)

    # Case-dropping CS-coefficient analog
    cs_results = {'degree': {}, 'betweenness': {}}
    orig_rank_deg = pd.Series(orig_deg).rank(ascending=False)
    orig_rank_btw = pd.Series(orig_btw).rank(ascending=False)
    for pct in drop_pcts:
        keep = int(round((1 - pct) * 600))
        cors_deg = []
        cors_btw = []
        for b in range(100):
            sub_data = {}
            for item in items:
                defs = data[item]
                idx = rng.choice(len(defs), size=min(keep, len(defs)), replace=False)
                sub_data[item] = [defs[i] for i in idx]
            sim_s = item_level_similarity_tfidf(sub_data)
            G_s = build_network(sim_s, threshold)
            deg_s = nx.degree_centrality(G_s)
            btw_s = nx.betweenness_centrality(G_s)
            r_d = pd.Series(deg_s).reindex(items).rank(ascending=False).corr(orig_rank_deg, method='spearman')
            r_b = pd.Series(btw_s).reindex(items).rank(ascending=False).corr(orig_rank_btw, method='spearman')
            cors_deg.append(r_d)
            cors_btw.append(r_b)
        cs_results['degree'][pct] = {'mean': np.nanmean(cors_deg), 'sd': np.nanstd(cors_deg)}
        cs_results['betweenness'][pct] = {'mean': np.nanmean(cors_btw), 'sd': np.nanstd(cors_btw)}

    return {
        'edge_stability': edge_stab_df,
        'centralities_boot': centralities_boot,
        'cs_results': cs_results,
        'orig_metrics': orig_metrics,
    }


# =============================================================================
# 8. MAIN
# =============================================================================

def main():
    print("Loading data...")
    data = load_data('cleaned_definitions.xlsx')

    print("\n=== 1. Definition diagnostics ===")
    diag = definition_diagnostics(data)
    print(diag)
    diag.to_csv('out_definition_diagnostics.csv', index=False)

    print("\n=== 2. Per-item coherence (full sample + bootstrap CIs) ===")
    coh = compute_all_coherence(data, n_boot=1000)
    print(coh)
    coh.to_csv('out_coherence_full.csv', index=False)

    print("\n=== 3. Top features per item ===")
    feature_rows = []
    for item in ALL_ITEMS:
        pct, tfidf = top_features(data[item])
        feature_rows.append({
            'Item': item,
            'Valence': VALENCE[item],
            'TopByPct': '; '.join([f"{w} ({p:.1f}%)" for w,p in pct]),
            'TopByTFIDF': '; '.join([f"{w} ({s:.3f})" for w,s in tfidf]),
        })
    feat_df = pd.DataFrame(feature_rows)
    feat_df.to_csv('out_top_features.csv', index=False)
    print(feat_df)

    print("\n=== 4. Item-level similarity matrices ===")
    sim_tfidf = item_level_similarity_tfidf(data)
    sim_tfidf.to_csv('out_sim_matrix_tfidf.csv')
    print("TF-IDF similarity matrix (first 5x5):")
    print(sim_tfidf.iloc[:5,:5].round(3))

    print("\nComputing SBERT embeddings")
    sim_sbert = item_level_similarity_sbert(data)
    sim_sbert.to_csv('out_sim_matrix_sbert.csv')
    print("SBERT similarity matrix (first 5x5):")
    print(sim_sbert.iloc[:5,:5].round(3))

    # Correlation between TF-IDF and SBERT off-diagonal similarities
    n = 20
    iu = np.triu_indices(n, k=1)
    tf_off = sim_tfidf.values[iu]
    sb_off = sim_sbert.values[iu]
    r_method = np.corrcoef(tf_off, sb_off)[0,1]
    print(f"\nTF-IDF vs SBERT off-diagonal correlation: r = {r_method:.3f}")

    print("\n=== 5. Permutation test for valence structure (TF-IDF) ===")
    perm_tfidf = valence_separation_permutation(sim_tfidf, n_perm=10000)
    for k, v in perm_tfidf.items():
        print(f"  {k}: {v}")

    print("\n=== 5b. Permutation test for valence structure (SBERT) ===")
    perm_sbert = valence_separation_permutation(sim_sbert, n_perm=10000)
    for k, v in perm_sbert.items():
        print(f"  {k}: {v}")

    print("\n=== 6. Threshold sensitivity analysis (TF-IDF) ===")
    sens_df, comm_table = threshold_sensitivity(sim_tfidf)
    print(sens_df)
    sens_df.to_csv('out_threshold_sensitivity_tfidf.csv', index=False)

    print("\nCommunities by threshold (TF-IDF):")
    for t, comms in comm_table.items():
        print(f"  Threshold {t}:")
        for i, c in enumerate(comms):
            print(f"    Community {i+1} (n={len(c)}): {c}")

    print("\n=== 6b. Threshold sensitivity (SBERT, anchored higher) ===")
    # SBERT similarities are typically higher; use higher thresholds
    sens_sbert, comm_sbert = threshold_sensitivity(sim_sbert, thresholds=(0.30, 0.40, 0.50, 0.60))
    print(sens_sbert)
    sens_sbert.to_csv('out_threshold_sensitivity_sbert.csv', index=False)

    print("\n=== 7. Primary network metrics (threshold = 0.15, TF-IDF) ===")
    G_main = build_network(sim_tfidf, threshold=0.15)
    main_metrics = network_metrics(G_main)
    print(f"  Nodes: {main_metrics['n']}, Edges: {main_metrics['m']}, "
          f"Density: {main_metrics['density']:.3f}")
    print(f"  Avg clustering: {main_metrics['avg_clustering']:.3f}")
    print(f"  Modularity: {main_metrics['modularity']:.3f}")
    print("  Communities:")
    for i, c in enumerate(main_metrics['communities']):
        print(f"    C{i+1} (n={len(c)}): {c}")
    print("  Degree centrality (normalized + raw):")
    for it in ALL_ITEMS:
        print(f"    {it}: norm={main_metrics['degree_norm'][it]:.3f}, raw={main_metrics['raw_degree'][it]}")
    print("  Betweenness centrality:")
    for it in ALL_ITEMS:
        print(f"    {it}: {main_metrics['betweenness'][it]:.3f}")

    # Save centrality
    cent_rows = []
    for it in ALL_ITEMS:
        cent_rows.append({
            'Item': it, 'Valence': VALENCE[it],
            'degree_norm': main_metrics['degree_norm'][it],
            'raw_degree': main_metrics['raw_degree'][it],
            'betweenness': main_metrics['betweenness'][it],
        })
    pd.DataFrame(cent_rows).to_csv('out_centralities.csv', index=False)

    print("\n=== 8. Bootstrap stability of network ===")
    print("Running 200 edge bootstraps + case-dropping... (slow)")
    boot = bootstrap_network_stability(data, threshold=0.15, n_boot=200)
    boot['edge_stability'].to_csv('out_edge_stability.csv', index=False)
    print("Top 15 most stable edges (by mean weight) and their 95% CIs:")
    print(boot['edge_stability'].sort_values('mean_w', ascending=False).head(15))

    print("\nCase-dropping CS analog (mean correlation with full-sample ranks):")
    for metric in ['degree', 'betweenness']:
        print(f"  {metric}:")
        for pct, stats_d in boot['cs_results'][metric].items():
            print(f"    Drop {int(pct*100)}%: r = {stats_d['mean']:.3f} (SD = {stats_d['sd']:.3f})")

    # Save summary JSON for the manuscript
    summary = {
        'n_total': 600,
        'method_correlation_tfidf_vs_sbert': round(r_method, 3),
        'permutation_tfidf': {k: (v if not isinstance(v, tuple) else list(v))
                              for k, v in perm_tfidf.items()},
        'permutation_sbert': {k: (v if not isinstance(v, tuple) else list(v))
                              for k, v in perm_sbert.items()},
        'main_network': {
            'edges': main_metrics['m'],
            'density': round(main_metrics['density'], 3),
            'avg_clustering': round(main_metrics['avg_clustering'], 3),
            'modularity': round(main_metrics['modularity'], 3),
            'n_communities': len(main_metrics['communities']),
            'communities': main_metrics['communities'],
        },
        'sensitivity_tfidf': sens_df.to_dict(orient='records'),
        'sensitivity_sbert': sens_sbert.to_dict(orient='records'),
        'cs_results': {
            metric: {str(k): v for k, v in d.items()}
            for metric, d in boot['cs_results'].items()
        }
    }
    with open('out_summary.json', 'w') as f:
        json.dump(summary, f, indent=2, default=str)

    print("\n\nAll outputs saved. Done.")


if __name__ == '__main__':
    main()
