"""
Bias Surface Detection — protected-attribute bias + data imbalance flagging.

Analyses a dataset sample to surface:
  1. Protected-attribute columns (gender, race, age, religion, …) via name
     heuristics and value inspection.
  2. For each protected column: value distribution (imbalance ratio, Gini
     coefficient), cross-column label skew (if a label column is detected),
     and vocabulary divergence across groups in text columns.
  3. Global data-imbalance flag per label/target column.

No heavy ML models are required — all analysis is statistical and lexical.
"""

import math
import re
from collections import Counter, defaultdict
from typing import Any, Optional

from fastapi import APIRouter, HTTPException
from pydantic import BaseModel

router = APIRouter(prefix="/dataset")


# ── Config ──────────────────────────────────────────────────────────────────────

# Column-name fragments that suggest a protected attribute
_PROTECTED_HINTS: dict[str, str] = {
    # Gender / sex
    "gender": "gender",
    "sex": "gender",
    "gendr": "gender",
    "gender_identity": "gender",
    "gender_expression": "gender",
    "biological_sex": "gender",
    "assigned_sex": "gender",
    "pregnant": "gender",
    "pregnancy": "gender",
    "maternity": "gender",
    "paternity": "gender",
    # Race / ethnicity
    "race": "race",
    "ethnicity": "race",
    "ethnic": "race",
    "ethnic_group": "race",
    "racial": "race",
    "skin": "race",
    "skin_color": "race",
    "skin_tone": "race",
    "color": "race",
    "colour": "race",
    "complexion": "race",
    "heritage": "race",
    "ancestry": "race",
    "descent": "race",
    # Nationality / immigration
    "nationality": "nationality",
    "nation": "nationality",
    "citizenship": "nationality",
    "country_of_origin": "nationality",
    "birthplace": "nationality",
    "birth_country": "nationality",
    "immigrant": "nationality",
    "immigration": "nationality",
    "refugee": "nationality",
    "asylum": "nationality",
    "visa_status": "nationality",
    "resident": "nationality",
    # Religion / belief
    "religion": "religion",
    "faith": "religion",
    "belief": "religion",
    "religious": "religion",
    "creed": "religion",
    "denomination": "religion",
    "sect": "religion",
    "spiritual": "religion",
    "church": "religion",
    "mosque": "religion",
    "temple": "religion",
    # Age
    "age": "age",
    "age_group": "age",
    "agegroup": "age",
    "age_range": "age",
    "age_band": "age",
    "birth_year": "age",
    "birthyear": "age",
    "dob": "age",
    "date_of_birth": "age",
    "generation": "age",
    "cohort": "age",
    # Disability
    "disability": "disability",
    "disabled": "disability",
    "handicap": "disability",
    "impairment": "disability",
    "accessibility": "disability",
    "special_needs": "disability",
    "neurodiversity": "disability",
    "autism": "disability",
    "adhd": "disability",
    "mental_health": "disability",
    "mental_illness": "disability",
    "chronic_illness": "disability",
    # Sexual orientation
    "orientation": "sexual_orientation",
    "sexual": "sexual_orientation",
    "sexual_orientation": "sexual_orientation",
    "lgbtq": "sexual_orientation",
    "lgbt": "sexual_orientation",
    "queer": "sexual_orientation",
    # Marital / family status
    "marital": "marital_status",
    "marriage": "marital_status",
    "marital_status": "marital_status",
    "family_status": "marital_status",
    "relationship_status": "marital_status",
    "divorced": "marital_status",
    "widowed": "marital_status",
    "single": "marital_status",
    # Political affiliation
    "political": "political_affiliation",
    "party": "political_affiliation",
    "politics": "political_affiliation",
    "political_party": "political_affiliation",
    "affiliation": "political_affiliation",
    "ideology": "political_affiliation",
    "vote": "political_affiliation",
    # Socioeconomic
    "income": "socioeconomic",
    "salary": "socioeconomic",
    "wage": "socioeconomic",
    "wealth": "socioeconomic",
    "poverty": "socioeconomic",
    "class": "socioeconomic",
    "socioeconomic": "socioeconomic",
    "ses": "socioeconomic",
    "economic_status": "socioeconomic",
    "financial_status": "socioeconomic",
    # Education
    "education": "education",
    "edu": "education",
    "education_level": "education",
    "educational_attainment": "education",
    "degree": "education",
    "qualification": "education",
    "literacy": "education",
    "schooling": "education",
    # Caste / tribe / indigenous
    "caste": "caste",
    "tribe": "ethnicity",
    "tribal": "ethnicity",
    "indigenous": "ethnicity",
    "aboriginal": "ethnicity",
    "native": "ethnicity",
    "first_nations": "ethnicity",
    # Military / veteran status
    "veteran": "military_status",
    "military": "military_status",
    "armed_forces": "military_status",
    "service_member": "military_status",
    # Body characteristics
    "weight": "body_characteristics",
    "height": "body_characteristics",
    "bmi": "body_characteristics",
    "body_weight": "body_characteristics",
    "obesity": "body_characteristics",
    "appearance": "body_characteristics",
    # Language
    "language": "language",
    "native_language": "language",
    "mother_tongue": "language",
    "first_language": "language",
    "lang": "language",
    # Occupation / employment
    "occupation": "occupation",
    "employment": "occupation",
    "job": "occupation",
    "profession": "occupation",
    "employment_status": "occupation",
    "unemployed": "occupation",
    # Geographic / regional
    "region": "geographic",
    "urban": "geographic",
    "rural": "geographic",
    "location_type": "geographic",
    "zip_code": "geographic",
    "postal_code": "geographic",
}

# Value-level keywords → attribute type (regex patterns)
_VALUE_HINTS: dict[str, str] = {
    # Gender (expanded with more identities)
    r"\b(male|female|man|woman|men|women|boy|girl|nonbinary|non[-_]binary|transgender|trans|nb|genderqueer|agender|bigender|intersex|two[-_\s]spirit|genderfluid|demi[-_]gender|neutrois|pangender|androgyne|questioning)\b": "gender",
    # Race / ethnicity (expanded, multilingual common terms)
    r"\b(white|black|asian|hispanic|latino|latina|latinx|african|caucasian|arab|middle[-_\s]eastern|indigenous|native|pacific[-_\s]islander|south[-_\s]asian|east[-_\s]asian|southeast[-_\s]asian|mixed[-_\s]race|biracial|multiracial|aboriginal|afro[-_\s]caribbean|west[-_\s]indian|creole|mestizo|mulato|moreno|blanco|negro)\b": "race",
    # Religion (expanded globally)
    r"\b(christian|muslim|jewish|hindu|buddhist|sikh|atheist|agnostic|catholic|protestant|orthodox|sunni|shia|evangelical|mormon|lds|jehovah|pentecostal|baptist|methodist|presbyterian|anglican|episcopal|lutheran|calvinist|jain|zoroastrian|pagan|wiccan|animist|spiritualist|taoist|confucian|shinto|bahai|druze|yazidi|secular|humanist|deist|theist|nonreligious)\b": "religion",
    # Sexual orientation (expanded)
    r"\b(straight|gay|lesbian|bisexual|queer|lgbtq|homosexual|heterosexual|pansexual|asexual|demisexual|aromantic|polyamorous|questioning|bicurious|omnisexual|graysexual)\b": "sexual_orientation",
    # Political affiliation (expanded)
    r"\b(republican|democrat|liberal|conservative|socialist|communist|libertarian|green[-_\s]party|progressive|moderate|centrist|far[-_\s]right|far[-_\s]left|populist|nationalist|fascist|anarchist|labour|tory|social[-_\s]democrat|christian[-_\s]democrat|independent|unaffiliated|labour[-_\s]party|new[-_\s]labour)\b": "political_affiliation",
    # Age groups
    r"\b(infant|toddler|child|adolescent|teen|teenager|youth|young[-_\s]adult|adult|middle[-_\s]aged|senior|elderly|geriatric|minor|juvenile|gen[-_\s]z|millennial|gen[-_\s]x|boomer|baby[-_\s]boomer|silent[-_\s]generation)\b": "age",
    # Disability
    r"\b(disabled|handicapped|wheelchair|blind|deaf|mute|autistic|adhd|dyslexic|epileptic|paraplegic|quadriplegic|amputee|visually[-_\s]impaired|hearing[-_\s]impaired|chronically[-_\s]ill|mentally[-_\s]ill|schizophrenic|bipolar|depressed|anxious|neurodivergent|neurodiverse)\b": "disability",
    # Nationality / country of origin
    r"\b(american|british|chinese|indian|pakistani|bangladeshi|nigerian|mexican|brazilian|russian|german|french|italian|spanish|japanese|korean|vietnamese|indonesian|philippine|filipino|thai|turkish|iranian|saudi|egyptian|south[-_\s]african|australian|canadian|undocumented|undocumented[-_\s]immigrant|illegal[-_\s]alien|asylum[-_\s]seeker|stateless)\b": "nationality",
    # Marital / family status
    r"\b(single|married|divorced|widowed|separated|cohabiting|partnered|engaged|common[-_\s]law|domestic[-_\s]partner|never[-_\s]married)\b": "marital_status",
    # Socioeconomic
    r"\b(poor|wealthy|rich|low[-_\s]income|middle[-_\s]class|upper[-_\s]class|working[-_\s]class|underclass|homeless|impoverished|affluent|high[-_\s]income|low[-_\s]ses|high[-_\s]ses|food[-_\s]insecure|unhoused)\b": "socioeconomic",
    # Education level
    r"\b(uneducated|illiterate|high[-_\s]school|college[-_\s]educated|university|graduate|postgraduate|phd|bachelor|associate|vocational|trade[-_\s]school|dropout|no[-_\s]degree)\b": "education",
    # Caste (South Asian context)
    r"\b(brahmin|kshatriya|vaishya|shudra|dalit|untouchable|scheduled[-_\s]caste|obc|upper[-_\s]caste|lower[-_\s]caste)\b": "caste",
}

_LABEL_HINTS = [
    "label", "target", "y", "class", "category", "outcome",
    "output", "result", "split", "sentiment", "score",
]
_NON_ATTR_HINTS = [
    "id", "idx", "index", "uuid", "hash", "timestamp", "date",
    "url", "path", "file", "key", "text", "prompt", "response",
    "content", "body", "title", "description", "summary",
]

MAX_CARD = 60       # max unique values to treat a column as categorical
MAX_ROWS = 2000     # rows analysed
TOP_IMBALANCE_N = 8 # show this many value counts in the distribution table


# ── Helpers ──────────────────────────────────────────────────────────────────────

def _gini(counts: list[int]) -> float:
    """Gini impurity: 0 = pure, approaches 1 = max imbalance."""
    n = sum(counts)
    if n == 0:
        return 0.0
    probs = [c / n for c in counts]
    return round(1.0 - sum(p * p for p in probs), 4)


def _imbalance_ratio(counts: list[int]) -> float:
    """max_count / min_count — 1.0 means perfectly balanced."""
    if not counts or min(counts) == 0:
        return float("inf")
    return round(max(counts) / min(counts), 2)


def _entropy(counts: list[int]) -> float:
    n = sum(counts)
    if n == 0:
        return 0.0
    h = 0.0
    for c in counts:
        if c > 0:
            p = c / n
            h -= p * math.log2(p)
    return round(h, 4)


def _normalise_val(v: Any) -> str:
    if v is None:
        return "__null__"
    return str(v).strip().lower()


def _is_likely_label_col(col: str) -> bool:
    cl = col.lower()
    return any(h == cl or cl.endswith(f"_{h}") or cl.startswith(f"{h}_") for h in _LABEL_HINTS)


def _is_non_attr(col: str) -> bool:
    cl = col.lower()
    return any(h == cl or cl.endswith(f"_{h}") or cl.startswith(f"{h}_") for h in _NON_ATTR_HINTS)


def _detect_attribute_by_name(col: str) -> str | None:
    cl = col.lower().replace("-", "_").replace(" ", "_")
    for hint, attr in _PROTECTED_HINTS.items():
        if hint in cl:
            return attr
    return None


def _detect_attribute_by_values(values: list[str]) -> str | None:
    """Sample up to 200 values and check against known-value patterns."""
    sample = " ".join(values[:200]).lower()
    for pattern, attr in _VALUE_HINTS.items():
        if re.search(pattern, sample):
            return attr
    return None


def _top_tokens(texts: list[str], n: int = 15) -> list[str]:
    """Simple word-frequency top-n, ignoring stopwords."""
    _STOP = {"the","a","an","and","or","but","in","on","at","to","for",
             "of","with","is","it","this","that","be","was","are","were",
             "as","by","from","have","has","had","not","if","he","she",
             "they","we","i","you","my","your","their","our","its","do"}
    counts: Counter[str] = Counter()
    for t in texts:
        for w in re.findall(r"[a-z]+", t.lower()):
            if w not in _STOP and len(w) > 2:
                counts[w] += 1
    return [w for w, _ in counts.most_common(n)]


def _jaccard(set_a: set[str], set_b: set[str]) -> float:
    if not set_a and not set_b:
        return 1.0
    return round(len(set_a & set_b) / len(set_a | set_b), 4)


# ── Request / Response models ─────────────────────────────────────────────────────

class BiasSurfaceRequest(BaseModel):
    rows: list[dict[str, Any]]
    columns: Optional[list[str]] = None
    text_columns: Optional[list[str]] = None
    max_rows: int = MAX_ROWS


class ValueDist(BaseModel):
    value: str
    count: int
    pct: float


class LabelSkew(BaseModel):
    label_col: str
    groups: dict[str, dict[str, float]]
    skew_score: float
    dominant_group: Optional[str]
    dominant_label: Optional[str]


class VocabDivergence(BaseModel):
    text_col: str
    top_tokens_by_group: dict[str, list[str]]
    pairwise_jaccard: dict[str, float]


class ProtectedAttrResult(BaseModel):
    column: str
    attribute_type: str
    detection_source: str
    rows_analyzed: int
    unique_values: int
    distribution: list[ValueDist]
    imbalance_ratio: float
    gini: float
    entropy: float
    imbalance_severity: str
    label_skew: Optional[LabelSkew]
    vocab_divergence: Optional[VocabDivergence]
    top_values: list[str]


class LabelImbalance(BaseModel):
    column: str
    rows_analyzed: int
    unique_labels: int
    distribution: list[ValueDist]
    imbalance_ratio: float
    gini: float
    entropy: float
    severity: str
    minority_class: Optional[str]
    majority_class: Optional[str]
    minority_pct: float
    majority_pct: float


class BiasSurfaceResult(BaseModel):
    rows_analyzed: int
    protected_columns_found: int
    label_columns_found: int
    protected_attrs: list[ProtectedAttrResult]
    label_imbalance: list[LabelImbalance]
    overall_bias_risk: str
    summary_flags: list[str]
    error: Optional[str] = None


# ── Core logic ────────────────────────────────────────────────────────────────────

def _severity(imbalance_ratio: float, gini: float) -> str:
    if imbalance_ratio == float("inf") or imbalance_ratio > 10 or gini > 0.7:
        return "severe"
    if imbalance_ratio > 4 or gini > 0.5:
        return "moderate"
    if imbalance_ratio > 2 or gini > 0.3:
        return "mild"
    return "none"


def _label_skew_score(groups_labels: dict[str, list[str]], all_labels: list[str]) -> float:
    """
    Compute a simple skew score: average total variation distance between each
    group's label distribution and the overall distribution. Ranges 0–1.
    """
    total = len(all_labels)
    if total == 0:
        return 0.0
    overall: Counter[str] = Counter(all_labels)
    overall_dist = {k: v / total for k, v in overall.items()}
    scores = []
    for _, lbls in groups_labels.items():
        n = len(lbls)
        if n == 0:
            continue
        grp: Counter[str] = Counter(lbls)
        grp_dist = {k: v / n for k, v in grp.items()}
        all_keys = set(overall_dist) | set(grp_dist)
        tvd = 0.5 * sum(abs(grp_dist.get(k, 0) - overall_dist.get(k, 0)) for k in all_keys)
        scores.append(tvd)
    return round(sum(scores) / len(scores), 4) if scores else 0.0


def _analyse_protected(
    col: str,
    attr_type: str,
    detection_source: str,
    rows: list[dict],
    text_columns: list[str],
    label_cols: list[str],
) -> ProtectedAttrResult:
    values = [_normalise_val(r.get(col)) for r in rows]
    counter: Counter[str] = Counter(values)
    unique_count = len(counter)
    total = len(values)

    # Build distribution
    counts_sorted = counter.most_common(TOP_IMBALANCE_N)
    dist = [
        ValueDist(value=v, count=c, pct=round(c / total * 100, 1))
        for v, c in counts_sorted
    ]
    top_values = [v for v, _ in counter.most_common(5)]

    all_counts = list(counter.values())
    ir = _imbalance_ratio(all_counts)
    gi = _gini(all_counts)
    en = _entropy(all_counts)
    sev = _severity(ir, gi)

    # Label skew — pick most likely label column
    label_skew: LabelSkew | None = None
    if label_cols:
        lc = label_cols[0]
        groups_labels: dict[str, list[str]] = defaultdict(list)
        for r in rows:
            g = _normalise_val(r.get(col))
            lv = _normalise_val(r.get(lc))
            groups_labels[g].append(lv)
        all_labels = [_normalise_val(r.get(lc)) for r in rows]
        skew_score = _label_skew_score(groups_labels, all_labels)

        # Build groups dict (top 6 groups × top 6 labels)
        label_counter: Counter[str] = Counter(all_labels)
        top_labels = [l for l, _ in label_counter.most_common(6)]
        top_groups = [v for v, _ in counter.most_common(6)]
        groups_out: dict[str, dict[str, float]] = {}
        for g in top_groups:
            n = len(groups_labels[g])
            if n == 0:
                continue
            gc: Counter[str] = Counter(groups_labels[g])
            groups_out[g] = {
                lv: round(gc.get(lv, 0) / n * 100, 1)
                for lv in top_labels
            }
        # dominant group/label
        dominant_group = top_groups[0] if top_groups else None
        dominant_label = top_labels[0] if top_labels else None

        label_skew = LabelSkew(
            label_col=lc,
            groups=groups_out,
            skew_score=skew_score,
            dominant_group=dominant_group,
            dominant_label=dominant_label,
        )

    # Vocab divergence — pick first text column
    vocab_div: VocabDivergence | None = None
    if text_columns:
        tc = text_columns[0]
        group_texts: dict[str, list[str]] = defaultdict(list)
        for r in rows:
            g = _normalise_val(r.get(col))
            tv = str(r.get(tc, "") or "")
            if tv:
                group_texts[g].append(tv)

        top_grps = [v for v, _ in counter.most_common(4)]
        top_tok_by_grp: dict[str, list[str]] = {}
        for g in top_grps:
            if g in group_texts and group_texts[g]:
                top_tok_by_grp[g] = _top_tokens(group_texts[g])

        # Pairwise jaccard between groups
        pairwise: dict[str, float] = {}
        grp_list = [g for g in top_grps if g in top_tok_by_grp]
        for i in range(len(grp_list)):
            for j in range(i + 1, len(grp_list)):
                ga, gb = grp_list[i], grp_list[j]
                key = f"{ga} vs {gb}"
                pairwise[key] = _jaccard(set(top_tok_by_grp[ga]), set(top_tok_by_grp[gb]))

        if top_tok_by_grp:
            vocab_div = VocabDivergence(
                text_col=tc,
                top_tokens_by_group=top_tok_by_grp,
                pairwise_jaccard=pairwise,
            )

    return ProtectedAttrResult(
        column=col,
        attribute_type=attr_type,
        detection_source=detection_source,
        rows_analyzed=total,
        unique_values=unique_count,
        distribution=dist,
        imbalance_ratio=ir if ir != float("inf") else -1.0,
        gini=gi,
        entropy=en,
        imbalance_severity=sev,
        label_skew=label_skew,
        vocab_divergence=vocab_div,
        top_values=top_values,
    )


def _analyse_label_imbalance(col: str, rows: list[dict]) -> LabelImbalance:
    values = [_normalise_val(r.get(col)) for r in rows]
    counter: Counter[str] = Counter(values)
    total = len(values)
    dist = [
        ValueDist(value=v, count=c, pct=round(c / total * 100, 1))
        for v, c in counter.most_common(TOP_IMBALANCE_N)
    ]
    all_counts = list(counter.values())
    ir = _imbalance_ratio(all_counts)
    gi = _gini(all_counts)
    en = _entropy(all_counts)
    sev = _severity(ir, gi)

    ordered = counter.most_common()
    majority = ordered[0][0] if ordered else None
    minority = ordered[-1][0] if ordered else None
    majority_pct = round(ordered[0][1] / total * 100, 1) if ordered else 0.0
    minority_pct = round(ordered[-1][1] / total * 100, 1) if ordered else 0.0

    return LabelImbalance(
        column=col,
        rows_analyzed=total,
        unique_labels=len(counter),
        distribution=dist,
        imbalance_ratio=ir if ir != float("inf") else -1.0,
        gini=gi,
        entropy=en,
        severity=sev,
        majority_class=majority,
        minority_class=minority,
        majority_pct=majority_pct,
        minority_pct=minority_pct,
    )


def _overall_risk(
    protected: list[ProtectedAttrResult],
    labels: list[LabelImbalance],
) -> str:
    severities = [p.imbalance_severity for p in protected] + [li.severity for li in labels]
    skew_scores = [p.label_skew.skew_score for p in protected if p.label_skew]
    if "severe" in severities or any(s > 0.3 for s in skew_scores):
        return "high"
    if "moderate" in severities or any(s > 0.15 for s in skew_scores):
        return "elevated"
    if "mild" in severities:
        return "moderate"
    return "low"


def _summary_flags(
    protected: list[ProtectedAttrResult],
    labels: list[LabelImbalance],
) -> list[str]:
    flags: list[str] = []
    for p in protected:
        if p.imbalance_severity in ("severe", "moderate"):
            flags.append(f"Severe group imbalance in '{p.column}' ({p.attribute_type}) — ratio {p.imbalance_ratio:.1f}×")
        if p.label_skew and p.label_skew.skew_score > 0.2:
            flags.append(f"Label skew across '{p.column}' groups (skew score {p.label_skew.skew_score:.2f})")
        if p.vocab_divergence and p.vocab_divergence.pairwise_jaccard:
            min_j = min(p.vocab_divergence.pairwise_jaccard.values())
            if min_j < 0.1:
                flags.append(f"High vocabulary divergence between '{p.column}' groups (Jaccard {min_j:.2f})")
    for li in labels:
        if li.severity in ("severe", "moderate"):
            flags.append(f"Class imbalance in label '{li.column}' — {li.majority_class} ({li.majority_pct}%) vs {li.minority_class} ({li.minority_pct}%)")
    return flags or ["No significant bias signals detected"]


def run_bias_surface(
    rows: list[dict],
    columns: list[str],
    text_columns: list[str],
    max_rows: int = MAX_ROWS,
) -> BiasSurfaceResult:
    rows = rows[:max_rows]
    if not rows:
        return BiasSurfaceResult(
            rows_analyzed=0, protected_columns_found=0, label_columns_found=0,
            protected_attrs=[], label_imbalance=[],
            overall_bias_risk="low", summary_flags=["No data to analyse"],
        )

    # Detect label columns
    label_cols: list[str] = []
    for col in columns:
        if _is_non_attr(col):
            continue
        if _is_likely_label_col(col):
            # Only if low cardinality
            vals = [_normalise_val(r.get(col)) for r in rows]
            if 2 <= len(set(vals)) <= MAX_CARD:
                label_cols.append(col)

    # Detect protected attribute columns
    protected_results: list[ProtectedAttrResult] = []
    seen_cols: set[str] = set()

    for col in columns:
        if col in seen_cols or _is_non_attr(col) or col in label_cols:
            continue
        vals_raw = [r.get(col) for r in rows]
        vals_str = [_normalise_val(v) for v in vals_raw]
        unique_vals = set(vals_str)

        # Skip high-cardinality or non-categorical
        if len(unique_vals) > MAX_CARD or len(unique_vals) <= 1:
            continue

        attr_by_name = _detect_attribute_by_name(col)
        attr_by_vals = _detect_attribute_by_values(list(unique_vals))

        if attr_by_name and attr_by_vals:
            source = "name+values"
            attr_type = attr_by_name
        elif attr_by_name:
            source = "name"
            attr_type = attr_by_name
        elif attr_by_vals:
            source = "values"
            attr_type = attr_by_vals
        else:
            continue

        res = _analyse_protected(
            col, attr_type, source, rows, text_columns, label_cols
        )
        protected_results.append(res)
        seen_cols.add(col)

    # Analyse label imbalance
    label_results = [_analyse_label_imbalance(lc, rows) for lc in label_cols]

    risk = _overall_risk(protected_results, label_results)
    flags = _summary_flags(protected_results, label_results)

    return BiasSurfaceResult(
        rows_analyzed=len(rows),
        protected_columns_found=len(protected_results),
        label_columns_found=len(label_results),
        protected_attrs=protected_results,
        label_imbalance=label_results,
        overall_bias_risk=risk,
        summary_flags=flags,
    )


# ── Route ─────────────────────────────────────────────────────────────────────────

@router.post("/bias-surface")
def bias_surface(req: BiasSurfaceRequest):
    try:
        cols = req.columns or (list(req.rows[0].keys()) if req.rows else [])
        text_cols = req.text_columns or []
        result = run_bias_surface(req.rows, cols, text_cols, req.max_rows)
        return result.model_dump()
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))
