# geney/api.py — Frontend-ready JSON serialization of MutationResult.
"""
Converts a MutationResult into a structured JSON payload designed for
progressive disclosure in a web frontend. Three levels:

  Level 1 (verdict):   Overall score, classification, headline, confidence
  Level 2 (summaries): Per-mechanism score + one-line summary + key metric
  Level 3 (details):   Full data for each mechanism (sequences, alignments, etc.)

The output is a single JSON-serializable dict ready for the frontend API.
"""
from __future__ import annotations

import math
from typing import Optional

import numpy as np

from .results import (
    MutationResult,
    SplicingResult,
    ProteinResult,
    TISResult,
    FoldingResult,
    TranscriptionResult,
    MiRNAResult,
    MechanismScore,
)


def serialize_mutation(
    result: MutationResult,
    mut_id: str = "",
    gene: str = "",
    epistasis_data: Optional[dict] = None,
    outcome_tree: Optional[object] = None,
) -> dict:
    """Convert a MutationResult to a frontend-ready JSON payload.

    This is the single entry point for the frontend API. Everything the
    frontend needs is in this dict — no additional lookups required.

    Args:
        result: MutationResult from mutation_pipeline.
        mut_id: Mutation identifier override.
        gene: Gene name override.
        epistasis_data: Optional epistasis info for this mutation.
            Injected by serialize_batch — not for direct use.

    Returns:
        JSON-serializable dict with verdict, summaries, and details.
    """
    # Extract identifiers
    if not mut_id:
        for sub in (result.splicing, result.tis, result.folding, result.transcription):
            if sub is not None:
                mut_id = mut_id or getattr(sub, "mut_id", "")
                gene = gene or getattr(sub, "gene", "")
    if not gene and result.protein:
        gene = result.protein.gene

    scores = result.scores

    payload = {
        # ── Level 1: The Verdict ──
        "verdict": _build_verdict(result, scores, mut_id, gene),

        # ── Level 2: Mechanism Summaries ──
        "mechanisms": _build_mechanism_summaries(result, scores),

        # ── Level 3: Deep Dive Details (lazy-loadable) ──
        "details": _build_details(result),

        # ── Metadata ──
        "meta": {
            "mut_id": mut_id,
            "gene": gene,
            "transcript_id": getattr(result.splicing, "transcript_id", None),
            "timing": result.timing,
            "errors": result.errors,
        },
    }

    # ── Epistasis layer ──
    if epistasis_data is not None:
        payload["epistasis"] = epistasis_data
    elif result.epistasis is not None:
        payload["epistasis"] = _serialize_epistasis_field(result.epistasis)

    # ── Outcome tree ──
    if outcome_tree is not None:
        payload["outcome_tree"] = outcome_tree.to_dict()

    return payload


def _serialize_epistasis_field(epistasis_data) -> dict | None:
    """Serialize the epistasis field from a MutationResult."""
    from .epistasis import EpistasisResult, EpistasisAnnotation

    if epistasis_data is None:
        return None

    # Compound variant: EpistasisResult
    if isinstance(epistasis_data, EpistasisResult):
        return _serialize_compound_epistasis(epistasis_data)

    # Constituent variant: list of EpistasisAnnotation
    if isinstance(epistasis_data, list):
        links = []
        for ann in epistasis_data:
            if isinstance(ann, EpistasisAnnotation):
                links.append({
                    "compound_id": ann.compound_id,
                    "partner_ids": ann.partner_ids,
                    "interaction_type": ann.interaction_type,
                    "most_epistatic_mechanism": ann.most_epistatic_mechanism,
                    "compound_scores": ann.compound_scores,
                    "bar_positions": ann.bar_positions,
                })
        if links:
            return {"status": "constituent", "compound_variants": links}

    # Dict passthrough (from serialize_batch legacy path)
    if isinstance(epistasis_data, dict):
        return epistasis_data

    return None


def serialize_batch(
    analysis_output: dict,
) -> dict:
    """Serialize the output of analyze_with_epistasis for the frontend.

    Takes the dict returned by analyze_with_epistasis and produces a
    JSON-serializable payload with per-mutation results and epistasis
    annotations integrated into each mutation's output.

    For each mutation:
      - Single variant, no compound involvement → epistasis: null
      - Single variant implicated in compound(s) → epistasis.status = "constituent",
        with links to the compound variant IDs and interaction summaries
      - Compound variant → epistasis.status = "compound", with full per-mechanism
        interaction bars and classification

    Args:
        analysis_output: Return value of analyze_with_epistasis().

    Returns:
        dict with:
            "mutations": dict[str, serialized_payload] — per mutation
            "epistasis_summary": list[dict] — one per compound, overview
    """
    from .epistasis import EpistasisResult, EpistasisAnnotation

    results_map = analysis_output["results"]
    epistasis_results = analysis_output["epistasis"]
    annotations = analysis_output["annotations"]

    # Build lookup: compound_id → EpistasisResult
    ep_by_compound = {ep.compound_id: ep for ep in epistasis_results}

    # Build lookup: individual_id → list of compound_ids it participates in
    individual_compounds: dict[str, list[str]] = {}
    for ep in epistasis_results:
        for ind_id in ep.individual_ids:
            individual_compounds.setdefault(ind_id, []).append(ep.compound_id)

    mutations = {}
    for mut_id, result in results_map.items():
        ep_data = None

        if mut_id in ep_by_compound:
            # This is a compound variant — full epistasis detail
            ep = ep_by_compound[mut_id]
            ep_data = _serialize_compound_epistasis(ep)

        elif mut_id in individual_compounds:
            # This is a single variant implicated in compound(s)
            ep_data = _serialize_constituent_epistasis(
                mut_id, individual_compounds[mut_id],
                annotations.get(mut_id, []), ep_by_compound,
            )

        mutations[mut_id] = serialize_mutation(result, mut_id=mut_id, epistasis_data=ep_data)

    # Top-level epistasis summary
    epistasis_summary = [
        {
            "compound_id": ep.compound_id,
            "individual_ids": ep.individual_ids,
            "has_epistasis": ep.has_epistasis,
            "dominant_interaction": ep.dominant_interaction,
            "most_epistatic_mechanism": ep.most_epistatic_mechanism,
            "summary": ep.summary_line(),
        }
        for ep in epistasis_results
    ]

    return {
        "mutations": mutations,
        "epistasis_summary": epistasis_summary,
    }


def _serialize_compound_epistasis(ep) -> dict:
    """Build epistasis payload for a compound variant."""
    bars = []
    for mech, mi in ep.mechanisms.items():
        bars.append({
            "mechanism": mech,
            "compound_score": round(mi.compound_score, 1),
            "individual_scores": [round(s, 1) for s in mi.individual_scores],
            "max_individual": round(mi.max_individual, 1),
            "delta": round(mi.delta_vs_max, 2),
            "bar_position": round(mi.bar_position, 3),
            "interaction_type": mi.interaction_type,
            "direction": mi.direction,
        })

    return {
        "status": "compound",
        "individual_ids": ep.individual_ids,
        "has_epistasis": ep.has_epistasis,
        "dominant_interaction": ep.dominant_interaction,
        "most_epistatic_mechanism": ep.most_epistatic_mechanism,
        "max_magnitude": round(ep.max_magnitude, 2),
        "summary": ep.summary_line(),
        "bars": bars,
        # Placeholder for future LLM embedding-based epistasis
        "embedding_analysis": None,
    }


def _serialize_constituent_epistasis(
    mut_id: str,
    compound_ids: list[str],
    annotations: list,
    ep_by_compound: dict,
) -> dict:
    """Build epistasis payload for a single variant implicated in compound(s)."""
    links = []
    for comp_id in compound_ids:
        ep = ep_by_compound.get(comp_id)
        if not ep:
            continue
        links.append({
            "compound_id": comp_id,
            "partner_ids": [i for i in ep.individual_ids if i != mut_id],
            "has_epistasis": ep.has_epistasis,
            "dominant_interaction": ep.dominant_interaction,
            "most_epistatic_mechanism": ep.most_epistatic_mechanism,
            "summary": ep.summary_line(),
        })

    return {
        "status": "constituent",
        "compound_variants": links,
    }


# ============================================================================
# Level 1: The Verdict
# ============================================================================

def _build_verdict(result: MutationResult, scores: dict, mut_id: str, gene: str) -> dict:
    """Top-level summary — answers 'does this matter?' in 2 seconds."""

    # Classification
    vc = result.variant_classification

    # Generate headline
    headline = _generate_headline(result, scores, vc)

    # Confidence badge
    confidences = [ms.confidence for ms in scores.values() if ms.status == "completed"]
    overall_confidence = (
        "high" if all(c == "high" for c in confidences)
        else "low" if any(c == "low" for c in confidences)
        else "medium"
    ) if confidences else "unknown"

    return {
        "overall_score": result.overall_score,
        "classification": vc["classification"],
        "headline": headline,
        "confidence": overall_confidence,
        "n_concordant": result.n_concordant_mechanisms,
        "interaction_flags": result.interaction_flags,
        "lof_score": vc.get("lof_score", 0),
        "evidence_summary": vc.get("evidence_summary", []),
    }


def _generate_headline(result: MutationResult, scores: dict, vc: dict) -> str:
    """Generate a single-sentence narrative summarizing the mutation's impact."""
    parts = []

    # Protein-level change
    pr = result.protein
    if pr and pr.status == "completed" and pr.mutation_type != "synonymous":
        if pr.mutation_type == "missense":
            parts.append(pr.mutation_description)
        elif pr.mutation_type == "frameshift":
            parts.append(f"Frameshift at position {pr.first_mismatch or '?'}")
            if pr.nmd_eligible:
                parts.append("NMD-eligible")
        elif pr.mutation_type == "nonsense":
            parts.append(f"Premature stop at {pr.mutation_description}")
        elif pr.mutation_type == "start_loss":
            parts.append("Start codon destroyed")
        else:
            parts.append(pr.mutation_description)

    # Splicing
    sp = scores.get("splicing")
    if sp and sp.status == "completed" and sp.score > 3:
        n_iso = len(result.splicing.isoforms) if not result.splicing.empty else 0
        if n_iso > 0:
            parts.append(f"aberrant splicing ({n_iso} isoform{'s' if n_iso != 1 else ''})")

    # Transcription
    tr = result.transcription
    if tr and tr.status == "completed" and scores.get("transcription", MechanismScore("", 0, "")).score > 3:
        direction = tr.predominant_direction
        n_tissues = tr.num_affected_tissues
        if direction != "unchanged" and n_tissues > 0:
            parts.append(f"{direction} in {n_tissues} tissue{'s' if n_tissues != 1 else ''}")

    # miRNA
    mi = result.mirna
    if mi and mi.status == "completed" and mi.n_lost + mi.n_gained > 0:
        changes = []
        if mi.n_lost > 0:
            changes.append(f"lost {mi.n_lost} miRNA site{'s' if mi.n_lost != 1 else ''}")
        if mi.n_gained > 0:
            changes.append(f"gained {mi.n_gained} miRNA site{'s' if mi.n_gained != 1 else ''}")
        parts.append(", ".join(changes))

    if not parts:
        if result.overall_score < 2:
            return "No significant impact detected"
        return "Variant detected with moderate effect"

    return "; ".join(parts)


# ============================================================================
# Level 2: Mechanism Summaries
# ============================================================================

_MECHANISM_LABELS = {
    "splicing": {"label": "Splicing", "icon": "scissors", "color": "#9333ea"},
    "protein": {"label": "Protein", "icon": "dna", "color": "#dc2626"},
    "tis": {"label": "Translation Initiation", "icon": "play", "color": "#0d9488"},
    "folding": {"label": "mRNA Folding", "icon": "waves", "color": "#7c3aed"},
    "transcription": {"label": "Gene Expression", "icon": "activity", "color": "#f97316"},
    "mirna": {"label": "miRNA Regulation", "icon": "target", "color": "#2563eb"},
}


def _build_mechanism_summaries(result: MutationResult, scores: dict) -> list[dict]:
    """Per-mechanism score + one-line summary. Sorted by score descending."""
    summaries = []

    for name, ms in scores.items():
        meta = _MECHANISM_LABELS.get(name, {"label": name, "icon": "circle", "color": "#64748b"})
        summary = {
            "id": name,
            "label": meta["label"],
            "icon": meta["icon"],
            "color": meta["color"],
            "score": ms.score,
            "status": ms.status,
            "confidence": ms.confidence,
            "confidence_reasons": list(ms.confidence_reasons),
            "empirical_p": ms.empirical_p,
            "summary_line": _mechanism_summary_line(name, result, ms),
            "key_metric": _mechanism_key_metric(name, result, ms),
        }
        summaries.append(summary)

    # Sort: completed mechanisms by score descending, then incomplete ones
    summaries.sort(key=lambda s: (
        0 if s["status"] == "completed" else 1,
        -s["score"],
    ))

    return summaries


def _mechanism_summary_line(name: str, result: MutationResult, ms: MechanismScore) -> str:
    """One-line natural language summary for a mechanism."""
    if ms.status != "completed":
        return f"Not analyzed" if ms.status == "not_run" else ms.reason or ms.status

    if name == "splicing":
        r = result.splicing
        if r.empty:
            return "No splice site disruption detected"
        n = len(r.isoforms)
        return f"{n} aberrant isoform{'s' if n != 1 else ''}, max Oncosplice = {r.max_oncosplice_score:.1f}"

    elif name == "protein":
        r = result.protein
        if r is None:
            return "Not analyzed"
        if r.mutation_type == "synonymous":
            return "Synonymous — no amino acid change"
        return r.mutation_description

    elif name == "tis":
        r = result.tis
        if r is None:
            return "Not analyzed"
        if r.initiation_intact:
            return "Canonical initiation preserved"
        return f"TIS shift score {r.score:.2f}, {r.num_alternative_tis} alternative site{'s' if r.num_alternative_tis != 1 else ''}"

    elif name == "folding":
        r = result.folding
        if r is None:
            return "Not analyzed"
        p_str = f" (p={r.empirical_p:.3f})" if r.empirical_p is not None else ""
        return f"ΔMFE = {r.delta_mfe:+.2f} kcal/mol{p_str}" if r.delta_mfe else "No structural change"

    elif name == "transcription":
        r = result.transcription
        if r is None:
            return "Not analyzed"
        if r.num_affected_tissues == 0:
            return "No significant expression changes"
        return f"{r.predominant_direction} in {r.num_affected_tissues} tissues, max |LFC| = {r.max_abs_lfc:.2f} ({r.max_lfc_tissue})"

    elif name == "mirna":
        r = result.mirna
        if r is None:
            return "Not analyzed"
        if r.n_lost + r.n_gained == 0:
            return f"No binding site changes ({r.n_mirnas_scanned} miRNAs scanned)"
        parts = []
        if r.n_lost:
            parts.append(f"{r.n_lost} lost")
        if r.n_gained:
            parts.append(f"{r.n_gained} gained")
        if r.n_modified:
            parts.append(f"{r.n_modified} modified")
        return f"Binding sites: {', '.join(parts)}"

    return ""


def _mechanism_key_metric(name: str, result: MutationResult, ms: MechanismScore) -> dict | None:
    """The single most important number for this mechanism, with label and unit."""
    if ms.status != "completed":
        return None

    if name == "splicing" and not result.splicing.empty:
        return {"label": "Oncosplice", "value": result.splicing.max_oncosplice_score, "unit": "", "format": ".1f"}
    elif name == "protein" and result.protein:
        r = result.protein
        if r.mutation_type == "missense":
            return {"label": "Conservation impact", "value": r.oncosplice_score, "unit": "", "format": ".2f"}
        elif r.truncation_fraction > 0:
            return {"label": "Truncation", "value": r.truncation_fraction * 100, "unit": "%", "format": ".0f"}
    elif name == "tis" and result.tis:
        return {"label": "Shift score", "value": result.tis.score, "unit": "", "format": ".2f"}
    elif name == "folding" and result.folding and result.folding.delta_mfe:
        return {"label": "ΔMFE", "value": result.folding.delta_mfe, "unit": "kcal/mol", "format": "+.2f"}
    elif name == "transcription" and result.transcription:
        return {"label": "Max |LFC|", "value": result.transcription.max_abs_lfc, "unit": "log₂FC", "format": ".2f"}
    elif name == "mirna" and result.mirna:
        return {"label": "Δrepression", "value": result.mirna.repression_delta, "unit": "log₂FC", "format": "+.3f"}

    return None


# ============================================================================
# Level 3: Deep Dive Details
# ============================================================================

def _build_details(result: MutationResult) -> dict:
    """Full data for each mechanism — loaded on demand by the frontend.

    Each mechanism detail dict has two keys:
      - "data": raw typed values (for custom visualizations)
      - "sections": list of section descriptors (for generic rendering)

    The frontend can use EITHER approach:
      1. Custom components that read "data" directly (current approach)
      2. A generic DetailRenderer that maps "sections" to components (scalable)
    """
    from . import api_sections as S

    details = {}

    if not result.splicing.empty:
        data = _splicing_detail(result.splicing)
        details["splicing"] = {
            "data": data,
            "sections": _splicing_sections(result.splicing, data),
        }

    if result.protein and result.protein.status == "completed":
        data = _protein_detail(result.protein)
        details["protein"] = {
            "data": data,
            "sections": _protein_sections(result.protein, data),
        }

    if result.tis and result.tis.status == "completed":
        data = _tis_detail(result.tis)
        details["tis"] = {
            "data": data,
            "sections": _tis_sections(result.tis, data),
        }

    if result.folding and result.folding.status == "completed":
        data = _folding_detail(result.folding)
        details["folding"] = {
            "data": data,
            "sections": _folding_sections(result.folding, data),
        }

    if result.transcription and result.transcription.status == "completed":
        data = _transcription_detail(result.transcription)
        details["transcription"] = {
            "data": data,
            "sections": _transcription_sections(result.transcription, data),
        }

    if result.mirna and result.mirna.status == "completed":
        data = _mirna_detail(result.mirna)
        details["mirna"] = {
            "data": data,
            "sections": _mirna_sections(result.mirna, data),
        }

    if result.multi_transcript:
        details["multi_transcript"] = {
            "data": _multi_transcript_detail(result.multi_transcript),
        }

    return details


def _safe(v, precision=4):
    """Safely convert numpy/float to JSON-serializable value."""
    if v is None:
        return None
    if isinstance(v, (np.floating, np.integer)):
        v = float(v)
    if isinstance(v, float):
        if math.isnan(v) or math.isinf(v):
            return None
        return round(v, precision)
    if isinstance(v, np.ndarray):
        return [round(float(x), precision) for x in v]
    return v


def _splicing_detail(r: SplicingResult) -> dict:
    isoforms = []
    if not r.isoforms.empty:
        for _, row in r.isoforms.iterrows():
            iso = {}
            for col in ("summary", "isoform_id", "known_transcript_id", "oncosplice_score",
                        "percentile", "isoform_prevalence", "variant_protein",
                        "aligned_reference_protein", "aligned_variant_protein",
                        "number_of_deletions", "number_of_insertions", "modified_positions_count"):
                if col in row.index:
                    v = row[col]
                    if isinstance(v, str):
                        iso[col] = v
                    elif isinstance(v, (int, np.integer)):
                        iso[col] = int(v)
                    else:
                        iso[col] = _safe(v)
            isoforms.append(iso)
        isoforms.sort(key=lambda x: x.get("oncosplice_score") or 0, reverse=True)

    return {
        "reference_protein": r.reference_protein or None,
        "reference_protein_length": len(r.reference_protein) if r.reference_protein else 0,
        "conservation_vector": _safe(r.conservation_vector),
        "isoforms": isoforms,
        "missplicing": _safe(r.missplicing),
        "missense_score": r.missense_score,
    }


def _protein_detail(r: ProteinResult) -> dict:
    return {
        "mutation_type": r.mutation_type,
        "mutation_description": r.mutation_description,
        "ref_length": r.ref_length,
        "var_length": r.var_length,
        "length_change": r.length_change,
        "truncation_fraction": _safe(r.truncation_fraction),
        "n_mismatches": r.n_mismatches,
        "mismatch_positions": r.mismatch_positions,
        "first_mismatch": r.first_mismatch,
        "oncosplice_score": _safe(r.oncosplice_score),
        "oncosplice_percentile": _safe(r.oncosplice_percentile),
        "has_conservation_data": r.has_conservation_data,
        "has_ptc": r.has_ptc,
        "nmd_eligible": r.nmd_eligible,
        "nmd_reason": r.nmd_reason,
    }


def _tis_detail(r: TISResult) -> dict:
    return {
        "canonical_prob_ref": _safe(r.canonical_prob_ref),
        "canonical_prob_var": _safe(r.canonical_prob_var),
        "canonical_prob_change": _safe(r.canonical_prob_change),
        "canonical_usage_ref": _safe(r.canonical_usage_ref),
        "canonical_usage_var": _safe(r.canonical_usage_var),
        "initiation_intact": r.initiation_intact,
        "num_alternative_tis": r.num_alternative_tis,
        "gained_alternative_tis": r.gained_alternative_tis,
        "atg_sites": r.atg_sites,
        "kozak_ref": r.kozak_ref,
        "kozak_var": r.kozak_var,
    }


def _folding_detail(r: FoldingResult) -> dict:
    central = None
    if r.central_window:
        w = r.central_window
        central = {
            "ref_sequence": w.ref_sequence,
            "var_sequence": w.var_sequence,
            "ref_structure": w.ref_structure,
            "var_structure": w.var_structure,
            "ref_mfe": _safe(w.ref_mfe),
            "var_mfe": _safe(w.var_mfe),
            "delta_mfe": _safe(w.delta_mfe),
            "bp_distance": w.bp_distance,
            "jaccard_similarity": _safe(w.jaccard_similarity),
        }

    return {
        "delta_mfe": _safe(r.delta_mfe),
        "central_window": central,
        "empirical_p": _safe(r.empirical_p, 6),
        "null_z_score": _safe(r.null_z_score),
        "null_percentile": _safe(r.null_percentile),
        "null_n_samples": r.null_n_samples,
        "bp_distance": r.bp_distance,
        "jaccard_similarity": _safe(r.jaccard_similarity),
        "structural_motifs": r.structural_motifs,
    }


def _transcription_detail(r: TranscriptionResult) -> dict:
    return {
        "tissue_fold_changes": r.tissue_fold_changes,
        "tissue_predicted_levels": r.tissue_predicted_levels,
        "tissue_wt_levels": r.tissue_wt_levels,
        "max_lfc_tissue": r.max_lfc_tissue,
        "max_lfc_value": _safe(r.max_lfc_value),
        "max_abs_lfc": _safe(r.max_abs_lfc),
        "num_affected_tissues": r.num_affected_tissues,
        "median_abs_lfc": _safe(r.median_abs_lfc),
        "tissue_specificity_tau": _safe(r.tissue_specificity_tau),
        "top_affected_group": r.top_affected_group,
        "group_mean_abs_lfc": r.group_mean_abs_lfc,
        "n_affected_groups": r.n_affected_groups,
        "direction_consistency": _safe(r.direction_consistency),
        "predominant_direction": r.predominant_direction,
        "top_affected_tissues": r.top_affected_tissues,
        "n_upregulated": r.n_upregulated,
        "n_downregulated": r.n_downregulated,
        "track_deltas": r.track_deltas,
    }


def _mirna_detail(r: MiRNAResult) -> dict:
    site_changes = []
    for c in (r.site_changes or []):
        if hasattr(c, '__dict__'):
            site_changes.append({
                "mirna_id": c.mirna_id,
                "position": c.position,
                "change_type": c.change_type,
                "ref_seed_type": c.ref_seed_type,
                "var_seed_type": c.var_seed_type,
                "region": c.region,
                "estimated_repression_delta": _safe(c.estimated_repression_delta),
            })
        elif isinstance(c, dict):
            site_changes.append(c)

    return {
        "n_sites_ref": r.n_sites_ref,
        "n_sites_var": r.n_sites_var,
        "n_gained": r.n_gained,
        "n_lost": r.n_lost,
        "n_modified": r.n_modified,
        "n_unchanged": r.n_unchanged,
        "total_repression_ref": _safe(r.total_repression_ref),
        "total_repression_var": _safe(r.total_repression_var),
        "repression_delta": _safe(r.repression_delta),
        "site_changes": site_changes,
        "top_affected_mirnas": [
            {"mirna_id": m.get("mirna_id", ""), "total_abs_impact": _safe(m.get("total_abs_impact", 0))}
            for m in (r.top_affected_mirnas or [])
        ],
        "n_mirnas_scanned": r.n_mirnas_scanned,
    }


def _multi_transcript_detail(mt) -> dict:
    """Serialize MultiTranscriptResult."""
    per_transcript = []
    for t in (mt.per_transcript or []):
        per_transcript.append({
            "transcript_id": t.transcript_id,
            "is_primary": t.is_primary,
            "max_oncosplice_score": _safe(t.max_oncosplice_score),
            "missplicing": _safe(t.missplicing),
            "n_isoforms": t.n_isoforms,
            "has_impact": t.has_impact,
        })

    non_coding = []
    for nc in (mt.non_coding_checks or []):
        if nc.has_impact:
            non_coding.append({
                "transcript_id": nc.transcript_id,
                "biotype": nc.biotype,
                "n_affected_donors": nc.n_affected_donors,
                "n_affected_acceptors": nc.n_affected_acceptors,
                "max_donor_delta": _safe(nc.max_donor_delta),
                "max_acceptor_delta": _safe(nc.max_acceptor_delta),
            })

    return {
        "n_transcripts_analyzed": mt.n_transcripts_analyzed,
        "n_transcripts_affected": mt.n_transcripts_affected,
        "n_non_coding_affected": mt.n_non_coding_affected,
        "worst_transcript_id": mt.worst_transcript_id,
        "worst_oncosplice_score": _safe(mt.worst_oncosplice_score),
        "transcript_scores": mt.transcript_scores,
        "per_transcript": per_transcript,
        "non_coding_affected": non_coding,
        "timing": _safe(mt.timing),
    }


# ============================================================================
# Section descriptor builders (generic rendering instructions for frontend)
# ============================================================================

def _splicing_sections(r, data) -> list[dict]:
    from . import api_sections as S
    sections = []

    isoforms = data.get("isoforms", [])
    if isoforms:
        cols = [
            {"key": "oncosplice_score", "label": "Score", "format": ".2f"},
            {"key": "summary", "label": "Event"},
            {"key": "isoform_prevalence", "label": "Prevalence", "format": ".1%"},
            {"key": "known_transcript_id", "label": "Known ID"},
        ]
        sections.append(S.table(
            f"Aberrant Isoforms ({len(isoforms)})", cols, isoforms,
            max_rows=10,
        ))

    sections.append(S.stats_grid([
        S.stat("Ref protein", f"{data.get('reference_protein_length', 0)} aa"),
        S.stat("Missplicing Δ", f"{data.get('missplicing', 0):.3f}"),
        S.stat("Isoforms", len(isoforms)),
    ], columns=3))

    if data.get("missense_score"):
        ms = data["missense_score"]
        sections.append(S.alert(
            f"Direct missense conservation impact: {ms.get('missense_oncosplice_score', 0):.2f}",
            "info",
        ))

    return sections


def _protein_sections(r, data) -> list[dict]:
    from . import api_sections as S
    sections = []

    mt = data.get("mutation_type", "unknown")
    desc = data.get("mutation_description", "")

    # Mutation type badge
    severity = "error" if mt in ("frameshift", "nonsense", "start_loss", "no_protein") \
        else "warning" if mt in ("missense", "in_frame_deletion", "in_frame_insertion", "stop_loss") \
        else "success" if mt == "synonymous" else "info"
    sections.append(S.alert(f"{mt.replace('_', ' ').title()}: {desc}", severity))

    sections.append(S.stats_grid([
        S.stat("Ref length", f"{data.get('ref_length', 0)} aa"),
        S.stat("Var length", f"{data.get('var_length', 0)} aa"),
        S.stat("Length change", data.get("length_change", 0)),
        S.stat("Truncation", f"{(data.get('truncation_fraction', 0) * 100):.0f}%",
               highlight=data.get("truncation_fraction", 0) > 0.2),
    ]))

    if data.get("has_conservation_data") and data.get("oncosplice_score", 0) > 0:
        sections.append(S.metric(
            "Conservation Impact (Oncosplice)", data["oncosplice_score"],
            format=".2f",
            subtext=f"{(data.get('oncosplice_percentile', 0) * 100):.0f}th percentile",
        ))

    if data.get("has_ptc"):
        msg = "Premature stop codon — NMD eligible" if data.get("nmd_eligible") \
            else f"Premature stop codon — {(data.get('nmd_reason') or '').replace('_', ' ')}"
        sections.append(S.alert(msg, "warning" if not data.get("nmd_eligible") else "error"))

    return sections


def _tis_sections(r, data) -> list[dict]:
    from . import api_sections as S
    sections = []

    intact = data.get("initiation_intact", True)
    sections.append(S.alert(
        "Canonical translation initiation preserved" if intact
        else "Canonical translation initiation disrupted",
        "success" if intact else "warning",
    ))

    sections.append(S.stats_grid([
        S.stat("Ref probability", f"{data.get('canonical_prob_ref', 0):.3f}"),
        S.stat("Var probability", f"{data.get('canonical_prob_var', 0):.3f}"),
        S.stat("Change", f"{data.get('canonical_prob_change', 0):+.3f}",
               highlight=abs(data.get("canonical_prob_change", 0)) > 0.1),
    ], columns=3))

    if data.get("kozak_ref") or data.get("kozak_var"):
        badges = []
        for label, kz in [("Ref", data.get("kozak_ref")), ("Var", data.get("kozak_var"))]:
            if kz:
                badges.append({"label": f"{label}: {kz.get('kozak_strength', '?')}",
                               "tooltip": kz.get("context_seq", "")})
        sections.append(S.badge_list("Kozak Context", badges))

    alts = data.get("gained_alternative_tis", [])
    if alts:
        cols = [
            {"key": "position", "label": "Position"},
            {"key": "location", "label": "Location"},
            {"key": "in_frame", "label": "In-frame"},
            {"key": "var_probability", "label": "Probability", "format": ".3f"},
        ]
        sections.append(S.table(f"Alternative TIS Sites ({len(alts)})", cols, alts, max_rows=5))

    return sections


def _folding_sections(r, data) -> list[dict]:
    from . import api_sections as S
    sections = []

    _dmfe = data.get('delta_mfe') if data.get('delta_mfe') is not None else 0
    _ep = data.get('empirical_p') if data.get('empirical_p') is not None else 1
    _zs = data.get('null_z_score') if data.get('null_z_score') is not None else 0
    _np = data.get('null_percentile') if data.get('null_percentile') is not None else 50
    sections.append(S.stats_grid([
        S.stat("ΔMFE", f"{_dmfe:+.2f}", unit="kcal/mol"),
        S.stat("Empirical p",
               "<0.001" if _ep < 0.001
               else f"{_ep:.3f}",
               highlight=_ep <= 0.05),
        S.stat("Z-score", f"{_zs:.2f}"),
        S.stat("Percentile", f"{_np:.0f}th"),
    ]))

    if data.get("bp_distance") is not None:
        sections.append(S.stats_grid([
            S.stat("BP distance", data["bp_distance"]),
            S.stat("Structure similarity", f"{((data.get('jaccard_similarity') or 0) * 100):.0f}%"),
        ], columns=2))

    if (data.get("structural_motifs") or {}).get("has_g_quadruplex"):
        sections.append(S.alert("G-quadruplex motif detected in folding window", "info"))

    if data.get("null_n_samples"):
        sections.append(S.text(
            f"Compared against {data['null_n_samples']} local single-nucleotide substitutions"
        ))

    cw = data.get("central_window")
    if cw:
        sections.append(S.structure_viewer("Reference", cw["ref_sequence"],
                                            cw["ref_structure"], cw.get("ref_mfe")))
        sections.append(S.structure_viewer("Variant", cw["var_sequence"],
                                            cw["var_structure"], cw.get("var_mfe")))

    return sections


def _transcription_sections(r, data) -> list[dict]:
    from . import api_sections as S
    sections = []

    sections.append(S.stats_grid([
        S.stat("Affected tissues", data.get("num_affected_tissues", 0)),
        S.stat("Max |LFC|", f"{data.get('max_abs_lfc', 0):.3f}",
               subtext=data.get("max_lfc_tissue", "")),
        S.stat("Median |LFC|", f"{data.get('median_abs_lfc', 0):.3f}"),
        S.stat("Specificity (τ)", f"{data.get('tissue_specificity_tau', 0):.2f}"),
    ]))

    direction = data.get("predominant_direction", "unchanged")
    if direction != "unchanged":
        badges = [
            {"label": direction, "color": "blue" if direction == "downregulated" else "red"},
            {"label": f"{data.get('n_downregulated', 0)}↓ {data.get('n_upregulated', 0)}↑"},
        ]
        if data.get("top_affected_group"):
            badges.append({"label": f"Top: {data['top_affected_group'].replace('_', ' ')}"})
        sections.append(S.badge_list("Direction", badges))

    # Tissue bar chart
    tfc = data.get("tissue_fold_changes", {})
    if tfc:
        chart_data = [{"label": t, "value": v} for t, v in
                      sorted(tfc.items(), key=lambda x: abs(x[1]), reverse=True)[:15]]
        sections.append(S.bar_chart("Per-Tissue Log₂ Fold Change", chart_data))

    # Track-level summary
    td = data.get("track_deltas")
    if td:
        sections.append(S.section_group("Track-Level Analysis", [
            S.stats_grid([
                S.stat("Spatial Gini", f"{td.get('spatial_gini', 0):.3f}",
                       subtext="concentrated" if td.get("spatial_gini", 0) > 0.7 else "diffuse"),
                S.stat("Local fraction", f"{(td.get('local_disruption_fraction', 0) * 100):.0f}%",
                       subtext="within ±10kb"),
                S.stat("Tracks changed", td.get("n_tracks_with_large_change", 0)),
            ], columns=3),
        ], collapsible=True, default_open=False))

    return sections


def _mirna_sections(r, data) -> list[dict]:
    from . import api_sections as S
    sections = []

    sections.append(S.stats_grid([
        S.stat("Ref sites", data.get("n_sites_ref", 0)),
        S.stat("Var sites", data.get("n_sites_var", 0)),
        S.stat("Repression Δ",
               f"{data.get('repression_delta', 0):+.3f}", unit="log₂FC",
               highlight=abs(data.get("repression_delta", 0)) > 0.2),
        S.stat("miRNAs scanned", data.get("n_mirnas_scanned", 0)),
    ]))

    change_badges = []
    if data.get("n_lost", 0) > 0:
        change_badges.append({"label": f"{data['n_lost']} lost", "color": "red"})
    if data.get("n_gained", 0) > 0:
        change_badges.append({"label": f"{data['n_gained']} gained", "color": "green"})
    if data.get("n_modified", 0) > 0:
        change_badges.append({"label": f"{data['n_modified']} modified", "color": "amber"})
    if change_badges:
        sections.append(S.badge_list("Binding Site Changes", change_badges))

    # Repression comparison
    ref_rep = data.get("total_repression_ref")
    var_rep = data.get("total_repression_var")
    if ref_rep is not None and var_rep is not None:
        sections.append(S.stats_grid([
            S.stat("Reference repression", f"{ref_rep:.3f}", unit="log₂FC"),
            S.stat("Variant repression", f"{var_rep:.3f}", unit="log₂FC"),
        ], columns=2))

    # Top affected miRNAs
    top = data.get("top_affected_mirnas", [])
    if top:
        badges = [{"label": f"{m['mirna_id']} (|Δ|={m.get('total_abs_impact', 0):.3f})"}
                  for m in top]
        sections.append(S.badge_list("Most Affected miRNAs", badges))

    # Site changes table
    changes = data.get("site_changes", [])
    if changes:
        cols = [
            {"key": "change_type", "label": "Change"},
            {"key": "mirna_id", "label": "miRNA"},
            {"key": "position", "label": "Position"},
            {"key": "region", "label": "Region"},
            {"key": "ref_seed_type", "label": "Ref Type"},
            {"key": "var_seed_type", "label": "Var Type"},
        ]
        sections.append(S.table(f"Site Changes ({len(changes)})", cols, changes, max_rows=10))

    return sections
