# geney/results.py — Typed result dataclasses for pipeline outputs.
from __future__ import annotations

from dataclasses import dataclass, field, fields
from typing import Optional

import numpy as np
import pandas as pd


class _DictCompatMixin:
    """Mixin providing dict-like access for backward compatibility."""

    def __getitem__(self, key):
        try:
            return getattr(self, key)
        except AttributeError:
            raise KeyError(key)

    def get(self, key, default=None):
        return getattr(self, key, default)

    def keys(self):
        return [f.name for f in fields(self)]

    def __contains__(self, key):
        return hasattr(self, key)


# ============================================================================
# Splicing Result
# ============================================================================

@dataclass
class SplicingResult:
    """Result of splicing / Oncosplice analysis.

    Stores shared reference data once and per-isoform results in a DataFrame.

    Attributes:
        score: Max oncosplice_score across isoforms (0 = silent, higher = more disruptive).
        missplicing: Max splice-site probability delta.
        isoforms: DataFrame with per-isoform columns only (variant_protein,
            variant_mrna, oncosplice_score, percentile, isoform_prevalence, etc.).
        reference_protein: Reference protein sequence (shared across isoforms).
        reference_mrna: Reference mature mRNA sequence (shared across isoforms).
        conservation_vector: Per-residue conservation (shared across isoforms).
        splice_events: Splice site change metadata (donor/acceptor events JSON,
            proximity info).
    """
    # Summary
    score: float = 0.0                      # max splice-site probability delta
    missplicing: float = 0.0                # same as score (explicit name)
    max_oncosplice_score: float = 0.0       # max oncosplice_score across isoforms

    # Standalone missense scoring (conservation impact without splice change)
    missense_score: Optional[dict] = None   # From enrichment.score_missense_standalone

    # Per-isoform data (variant-specific columns only)
    isoforms: pd.DataFrame = field(default_factory=pd.DataFrame)

    # Shared reference sequences (stored once, not per row)
    reference_protein: str = ""
    reference_mrna: str = ""
    conservation_vector: Optional[np.ndarray] = None

    # Shared metadata
    mut_id: str = ""
    gene: str = ""
    transcript_id: Optional[str] = None
    splicing_engine: str = ""
    central_position: int = 0

    # Splice site data (full per-position probabilities for visualization)
    donor_df: Optional[pd.DataFrame] = None     # all donors: ref_prob, event_prob, deltas
    acceptor_df: Optional[pd.DataFrame] = None  # all acceptors: ref_prob, event_prob, deltas

    # Splice event metadata (stored once)
    splice_events: Optional[pd.Series] = None

    # Branch point disruption (from BPP analysis)
    branch_point_disrupted: bool = False
    branch_point_info: Optional[dict] = None  # {motif, score, distance_to_3ss, mutation_distance}

    # Status (matches other result types)
    status: str = "completed"
    reason: Optional[str] = None

    @property
    def empty(self) -> bool:
        return self.isoforms.empty

    @property
    def columns(self) -> pd.Index:
        """Backward-compat: columns of the isoforms DataFrame."""
        return self.isoforms.columns

    def __len__(self) -> int:
        return len(self.isoforms)

    def to_dataframe(self) -> pd.DataFrame:
        """Reconstruct the full flat DataFrame (for backward compatibility)."""
        if self.isoforms.empty:
            return pd.DataFrame()
        df = self.isoforms.copy()
        df["mut_id"] = self.mut_id
        df["gene"] = self.gene
        df["transcript_id"] = self.transcript_id
        df["splicing_engine"] = self.splicing_engine
        df["central_position"] = self.central_position
        df["reference_protein"] = self.reference_protein
        df["reference_mrna"] = self.reference_mrna
        df["conservation_vector"] = [self.conservation_vector] * len(df)
        if self.splice_events is not None:
            for k, v in self.splice_events.items():
                df[k] = v
        return df


# ============================================================================
# TIS Result
# ============================================================================

@dataclass
class TISSite:
    """A single translation initiation site."""
    position: int
    location: str                # 'canonical', 'upstream', 'downstream'
    in_frame: bool
    ref_probability: float
    var_probability: float
    prob_change: float
    ref_usage: float = 0.0
    var_usage: float = 0.0


@dataclass
class TISResult(_DictCompatMixin):
    """Result of TIS (translation initiation site) shift analysis.

    Attributes:
        score: TIS shift score (0 = canonical intact, 1 = fully shifted away).
        status: 'completed', 'skipped', or 'error'.
        canonical: The canonical TIS site with ref/var probabilities.
        gained_alternatives: Sites that gained significant probability in the variant.
        all_sites: Every ATG site with ref/var probabilities.
        initiation_intact: Whether canonical initiation is preserved (within 20%).
    """
    status: str
    score: float                            # tis_shift_score

    # Canonical TIS
    canonical: Optional[TISSite] = None
    canonical_prob_ref: float = 0.0
    canonical_prob_var: float = 0.0
    canonical_prob_change: float = 0.0
    canonical_usage_ref: float = 0.0
    canonical_usage_var: float = 0.0
    canonical_usage_change: float = 0.0
    initiation_intact: bool = True

    # Alternatives
    gained_alternative_tis: list[dict] = field(default_factory=list)
    num_alternative_tis: int = 0
    top_alternative_position: Optional[int] = None
    top_alternative_prob: float = 0.0
    top_alternative_location: Optional[str] = None
    top_alternative_in_frame: Optional[bool] = None

    # All ATG sites
    atg_sites: list[dict] = field(default_factory=list)

    # Sequences
    ref_mrna_length: int = 0
    var_mrna_length: int = 0
    ref_mrna_window: str = ""
    var_mrna_window: str = ""
    mutation_mrna_positions: list[int] = field(default_factory=list)

    # Metadata
    mut_id: str = ""
    gene: str = ""
    transcript_id: Optional[str] = None
    summary: Optional[pd.Series] = None

    # Kozak context scoring
    kozak_ref: Optional[dict] = None        # Kozak context for reference canonical ATG
    kozak_var: Optional[dict] = None        # Kozak context for variant canonical ATG

    # Raw result for advanced use
    tis_result: object = None               # TISShiftResult

    # Skip/error metadata
    reason: Optional[str] = None
    proximity_window: Optional[int] = None

    # Legacy aliases
    @property
    def tis_shift_score(self) -> float:
        return self.score


# ============================================================================
# Folding Result
# ============================================================================

@dataclass
class FoldingWindow:
    """A single folding window around a mutation site."""
    ref_sequence: str
    var_sequence: str
    ref_structure: str              # dot-bracket notation
    var_structure: str              # dot-bracket notation
    ref_mfe: float                  # kcal/mol
    var_mfe: float                  # kcal/mol
    delta_mfe: float                # var_mfe - ref_mfe
    mutation_offset: int            # position within window
    # Structural dissimilarity (from enrichment.base_pair_distance)
    bp_distance: Optional[int] = None
    jaccard_similarity: Optional[float] = None


@dataclass
class FoldingMutation(_DictCompatMixin):
    """Folding result for a single mutation position."""
    genomic_position: int
    ref: str
    alt: str
    status: str                     # 'ok', 'not_in_mrna', 'ref_mismatch', 'fold_error'
    delta_mfe: Optional[float] = None
    score: Optional[float] = None
    mrna_position: Optional[int] = None
    coding_alt: Optional[str] = None
    window: Optional[FoldingWindow] = None
    # Error details
    expected_ref: Optional[str] = None
    error: Optional[str] = None

    # Legacy dict keys from _central_window_dict
    @property
    def ref_window(self) -> Optional[str]:
        return self.window.ref_sequence if self.window else None

    @property
    def var_window(self) -> Optional[str]:
        return self.window.var_sequence if self.window else None

    @property
    def ref_structure(self) -> Optional[str]:
        return self.window.ref_structure if self.window else None

    @property
    def var_structure(self) -> Optional[str]:
        return self.window.var_structure if self.window else None

    @property
    def mfe_ref(self) -> Optional[float]:
        return self.window.ref_mfe if self.window else None

    @property
    def mfe_var(self) -> Optional[float]:
        return self.window.var_mfe if self.window else None

    @property
    def mutation_offset(self) -> Optional[int]:
        return self.window.mutation_offset if self.window else None


@dataclass
class FoldingResult(_DictCompatMixin):
    """Result of mRNA folding analysis.

    Attributes:
        score: Folding score (from delta-MFE normalization, now using nonlinear
            transform and empirical null calibration).
        delta_mfe: Mean delta-MFE across windows (kcal/mol).
            Positive = destabilized, negative = stabilized.
        mutations: Per-position folding results.
        central_window: The best-centered folding window (if available).
        empirical_p: Empirical p-value from null distribution (if computed).
        null_mean: Mean |delta_mfe| from null distribution.
        null_std: Std of |delta_mfe| from null distribution.
        null_z_score: Z-score of observed |delta_mfe| vs null.
        null_percentile: Percentile of observed |delta_mfe| in null.
        null_n_samples: Number of substitutions in null landscape.
    """
    status: str
    score: float = 0.0
    delta_mfe: Optional[float] = None
    mutations: list[dict] = field(default_factory=list)
    central_window: Optional[FoldingWindow] = None

    # Null distribution enrichment
    empirical_p: Optional[float] = None
    null_mean: Optional[float] = None
    null_std: Optional[float] = None
    null_z_score: Optional[float] = None
    null_percentile: Optional[float] = None
    null_n_samples: Optional[int] = None

    # Structural dissimilarity
    bp_distance: Optional[int] = None
    jaccard_similarity: Optional[float] = None

    # Structural motifs in the folding window
    structural_motifs: Optional[dict] = None

    # Metadata
    mut_id: str = ""
    gene: str = ""
    transcript_id: Optional[str] = None
    window_size: int = 39

    # Error
    reason: Optional[str] = None


# ============================================================================
# Transcription Result
# ============================================================================

@dataclass
class TranscriptionResult(_DictCompatMixin):
    """Result of Borzoi transcription-effect prediction.

    Attributes:
        score: Regulatory score (0-10 normalized from max |log2FC|).
        tissue_fold_changes: Per-tissue log2 fold-changes.
        max_lfc_tissue: Tissue with largest absolute fold-change.
        max_lfc_value: log2FC of that tissue (signed).
        num_affected_tissues: Count of tissues exceeding threshold.
        median_abs_lfc: Median absolute LFC across all tissues.
        tissue_specificity_tau: How concentrated the effect is (0=uniform, 1=specific).
        top_affected_group: Organ system with largest mean |LFC|.
        group_mean_abs_lfc: Per-organ-system mean |LFC|.
        n_affected_groups: Number of organ systems above threshold.
        direction_consistency: Fraction of affected tissues moving same direction.
        predominant_direction: "upregulated", "downregulated", or "unchanged".
        top_affected_tissues: Top 5 most affected tissues with context.
    """
    status: str
    score: float = 0.0                      # regulatory_score

    tissue_fold_changes: dict[str, float] = field(default_factory=dict)
    tissue_predicted_levels: dict[str, float] = field(default_factory=dict)
    tissue_wt_levels: dict[str, float] = field(default_factory=dict)
    max_lfc_tissue: str = ""
    max_lfc_value: float = 0.0
    max_abs_lfc: float = 0.0
    num_affected_tissues: int = 0

    # Enriched summary statistics
    median_abs_lfc: float = 0.0
    tissue_specificity_tau: float = 0.0
    top_affected_group: str = ""
    group_mean_abs_lfc: dict[str, float] = field(default_factory=dict)
    n_affected_groups: int = 0
    direction_consistency: float = 0.0
    predominant_direction: str = "unchanged"
    top_affected_tissues: list[dict] = field(default_factory=list)
    n_upregulated: int = 0
    n_downregulated: int = 0

    # Raw track-level delta analysis
    track_deltas: Optional[dict] = None

    # Metadata
    mut_id: str = ""
    gene: str = ""

    # Error
    reason: Optional[str] = None

    # Legacy alias
    @property
    def regulatory_score(self) -> float:
        return self.score


# ============================================================================
# Protein Result
# ============================================================================

@dataclass
class ProteinResult(_DictCompatMixin):
    """Result of protein-level mutation analysis (standalone, no splicing).

    Classifies the mutation type from direct ORF translation and scores
    functional impact via conservation-weighted alignment.
    """
    status: str
    score: float = 0.0

    mutation_type: str = "unknown"
    mutation_description: str = ""

    ref_length: int = 0
    var_length: int = 0
    length_change: int = 0
    truncation_fraction: float = 0.0

    n_mismatches: int = 0
    mismatch_positions: list[int] = field(default_factory=list)
    first_mismatch: Optional[int] = None

    oncosplice_score: float = 0.0
    oncosplice_percentile: float = 0.0
    has_conservation_data: bool = False

    has_ptc: bool = False
    nmd_eligible: bool = False
    nmd_reason: str = ""

    # Metadata
    mut_id: str = ""
    gene: str = ""
    transcript_id: Optional[str] = None
    reason: Optional[str] = None


# ============================================================================
# miRNA Result
# ============================================================================

@dataclass
class MiRNAResult(_DictCompatMixin):
    """Result of miRNA binding site disruption analysis.

    Attributes:
        score: Disruption score (0-10). Higher = more binding site change.
        n_gained: Binding sites gained in variant.
        n_lost: Binding sites lost in variant.
        n_modified: Binding sites with changed seed type.
        repression_delta: Change in estimated repression (var - ref).
        top_affected_mirnas: miRNAs most impacted by the mutation.
    """
    status: str
    score: float = 0.0

    n_sites_ref: int = 0
    n_sites_var: int = 0
    n_gained: int = 0
    n_lost: int = 0
    n_modified: int = 0
    n_unchanged: int = 0

    total_repression_ref: float = 0.0
    total_repression_var: float = 0.0
    repression_delta: float = 0.0

    top_affected_mirnas: list[dict] = field(default_factory=list)
    site_changes: list = field(default_factory=list)
    n_mirnas_scanned: int = 0

    # Metadata
    mut_id: str = ""
    gene: str = ""
    reason: Optional[str] = None


# ============================================================================
# Mechanism Score (unified per-mechanism score)
# ============================================================================

@dataclass(frozen=True)
class MechanismScore:
    """Normalized score for a single analysis mechanism.

    Attributes:
        name: Mechanism name (e.g. "splicing", "tis").
        score: Normalized score, 0.0–10.0 (1 decimal).
            0.0 = no disturbance detected; 10.0 = maximum disturbance.
        status: "completed", "skipped", "error", or "not_run".
        reason: Why it was skipped or errored (None if completed/not_run).
        confidence: Evidence quality tier — "high", "medium", or "low".
            Reflects input data quality, not score magnitude.
            high  = full conservation/model data, mutation in well-characterized region.
            medium = adequate input but some caveats (edge of sequence, partial data).
            low   = fallback mode (no conservation, boundary context, very short input).
        confidence_reasons: Human-readable list of factors that influenced confidence.
        empirical_p: Empirical p-value from null distribution (where available).
            None if no null was computed. Lower = more significant.
    """
    name: str
    score: float
    status: str
    reason: Optional[str] = None
    confidence: str = "high"
    confidence_reasons: tuple[str, ...] = ()
    empirical_p: Optional[float] = None

    def __repr__(self):
        if self.status == "completed":
            conf = f", conf={self.confidence}" if self.confidence != "high" else ""
            p_str = f", p={self.empirical_p:.3g}" if self.empirical_p is not None else ""
            return f"MechanismScore({self.name!r}, {self.score}{conf}{p_str})"
        return f"MechanismScore({self.name!r}, status={self.status!r})"


# -- Per-mechanism normalization to 0–10 scale --------------------------------
# Each function takes the sub-result and returns a dict with keys:
#   score, status, reason, confidence, confidence_reasons, empirical_p
# Adding a new mechanism = add one normalizer + one entry in _MECHANISMS.

def _norm_splicing(r: SplicingResult) -> dict:
    # Use missplicing penetrance (0–1 → 0–10) as the primary score
    raw = abs(r.missplicing) * 10

    if r.empty and raw == 0.0:
        return dict(score=0.0, status="completed", reason=None,
                    confidence="high", confidence_reasons=(), empirical_p=None)

    # --- Confidence assessment ---
    reasons: list[str] = []
    conf = "high"

    # Check if conservation data was a fallback (all ones)
    cv = r.conservation_vector
    if cv is not None and hasattr(cv, 'size') and cv.size > 0:
        if np.allclose(cv, 1.0):
            conf = "low"
            reasons.append("conservation_data_missing_using_fallback")
        elif cv.size < 50:
            if conf == "high":
                conf = "medium"
            reasons.append("short_conservation_vector")
    else:
        conf = "low"
        reasons.append("no_conservation_vector")

    # Single-AA mismatch vs multi-residue indel distinction
    if not r.isoforms.empty and "modified_positions_count" in r.isoforms.columns:
        max_mod = r.isoforms["modified_positions_count"].max()
        if max_mod <= 1:
            if conf == "high":
                conf = "medium"
            reasons.append("single_residue_change_only")

    return dict(score=min(10.0, raw), status="completed", reason=None,
                confidence=conf, confidence_reasons=tuple(reasons),
                empirical_p=None)


def _norm_tis(r: Optional[TISResult]) -> dict:
    base = dict(confidence="high", confidence_reasons=(), empirical_p=None)
    if r is None:
        return dict(score=0.0, status="not_run", reason=None, **base)
    if r.status != "completed":
        return dict(score=0.0, status=r.status, reason=getattr(r, "reason", None), **base)

    raw = r.score  # 0-1 scale
    score_10 = min(10.0, raw * 10)

    reasons = []
    conf = "high"

    # Assess whether the shift is driven by a credible alternative
    top_prob = r.top_alternative_prob or 0.0
    canonical_prob = r.canonical_prob_var or 0.0

    if raw > 0.3 and top_prob < 0.15:
        # High shift score but no strong competing site — likely ATG-dense noise
        conf = "medium"
        reasons.append("shift_not_driven_by_strong_alternative")

    if canonical_prob < 0.05 and (r.canonical_prob_ref or 0.0) < 0.05:
        conf = "low"
        reasons.append("canonical_tis_weak_in_both_ref_and_var")

    return dict(score=score_10, status="completed", reason=None,
                confidence=conf, confidence_reasons=tuple(reasons),
                empirical_p=None)


def _norm_folding(r: Optional[FoldingResult]) -> dict:
    base = dict(confidence="high", confidence_reasons=(), empirical_p=None)
    if r is None:
        return dict(score=0.0, status="not_run", reason=None, **base)
    if r.status != "completed":
        return dict(score=0.0, status=r.status, reason=getattr(r, "reason", None), **base)

    reasons = []
    conf = "high"

    empirical_p = getattr(r, "empirical_p", None)

    # Score uses the nonlinear transform already computed in delta_mfe.py.
    # The score reflects magnitude: 10*(1-exp(-0.5*|ΔMFE|)).
    # The empirical p-value is reported separately as an annotation.

    # Confidence reflects DATA QUALITY only, not effect size:
    #   - ViennaRNA prediction quality degrades at sequence edges
    #   - Very short folding windows have less context
    if r.window_size < 20:
        conf = "medium"
        reasons.append("short_folding_window")

    # If null was computed and is not significant, note it (but don't downgrade
    # confidence — the null is about significance, not data quality)
    if empirical_p is not None and empirical_p > 0.1:
        reasons.append("not_significant_vs_local_null_p_gt_0.1")

    return dict(score=min(10.0, r.score), status="completed", reason=None,
                confidence=conf, confidence_reasons=tuple(reasons),
                empirical_p=empirical_p)


def _norm_transcription(r: Optional[TranscriptionResult]) -> dict:
    base = dict(confidence="high", confidence_reasons=(), empirical_p=None)
    if r is None:
        return dict(score=0.0, status="not_run", reason=None, **base)
    if r.status != "completed":
        return dict(score=0.0, status=r.status, reason=getattr(r, "reason", None), **base)

    reasons = []
    conf = "high"

    # Check tissue specificity — broad effect is higher confidence
    n_affected = r.num_affected_tissues
    n_total = len(r.tissue_fold_changes) if r.tissue_fold_changes else 1

    if n_affected == 1 and n_total > 5:
        if conf == "high":
            conf = "medium"
        reasons.append("effect_limited_to_single_tissue")

    return dict(score=min(10.0, r.score), status="completed", reason=None,
                confidence=conf, confidence_reasons=tuple(reasons),
                empirical_p=None)


def _norm_protein(r: Optional[ProteinResult]) -> dict:
    base = dict(confidence="high", confidence_reasons=(), empirical_p=None)
    if r is None:
        return dict(score=0.0, status="not_run", reason=None, **base)
    if r.status != "completed":
        return dict(score=0.0, status=r.status, reason=getattr(r, "reason", None), **base)

    reasons = []
    conf = "high"

    if not r.has_conservation_data and r.mutation_type == "missense":
        conf = "low"
        reasons.append("no_conservation_data_for_missense_scoring")

    if r.mutation_type == "synonymous":
        conf = "high"  # We're very confident it's nothing

    return dict(score=min(10.0, r.score), status="completed", reason=None,
                confidence=conf, confidence_reasons=tuple(reasons),
                empirical_p=None)


def _norm_mirna(r: Optional[MiRNAResult]) -> dict:
    base = dict(confidence="high", confidence_reasons=(), empirical_p=None)
    if r is None:
        return dict(score=0.0, status="not_run", reason=None, **base)
    if r.status != "completed":
        return dict(score=0.0, status=r.status, reason=getattr(r, "reason", None), **base)

    reasons = []
    conf = "high"

    if r.n_mirnas_scanned < 50:
        conf = "medium"
        reasons.append("limited_mirna_panel_scanned")

    if r.n_gained + r.n_lost == 0 and r.n_modified > 0:
        # Only type changes, no gain/loss — lower impact
        if conf == "high":
            conf = "medium"
        reasons.append("only_seed_type_changes_no_gain_loss")

    return dict(score=min(10.0, r.score), status="completed", reason=None,
                confidence=conf, confidence_reasons=tuple(reasons),
                empirical_p=None)


# Registry: (field_name_on_MutationResult, normalizer_function)
_MECHANISMS: tuple[tuple[str, callable], ...] = (
    ("splicing", _norm_splicing),
    ("protein", _norm_protein),
    ("tis", _norm_tis),
    ("folding", _norm_folding),
    ("transcription", _norm_transcription),
    ("mirna", _norm_mirna),
)


# ============================================================================
# Mutation Result (top-level pipeline output)
# ============================================================================

@dataclass
class MutationResult:
    """Result of the unified mutation pipeline.

    Attributes:
        splicing: Splicing / Oncosplice analysis result.
        tis: TIS shift analysis result.
        folding: mRNA folding analysis result.
        transcription: Borzoi transcription-effect result.
        transcript: The mutated transcript object.
        mutation_context: Pre-mRNA clone around the mutation site.
        timing: Per-stage wall-clock durations in seconds.
        errors: Pipeline name → error message for any pipeline that failed.
    """
    splicing: SplicingResult = field(default_factory=SplicingResult)
    multi_transcript: object = None  # MultiTranscriptResult (from _multi_transcript.py)
    protein: Optional[ProteinResult] = None
    tis: Optional[TISResult] = None
    folding: Optional[FoldingResult] = None
    transcription: Optional[TranscriptionResult] = None
    mirna: Optional[MiRNAResult] = None
    epistasis: object = None          # EpistasisResult or EpistasisAnnotation data
    transcript: object = None
    mutation_context: object = None
    timing: dict = field(default_factory=dict)
    errors: dict = field(default_factory=dict)

    # Backward-compat: namedtuple-like interface
    _fields = ("splicing", "protein", "tis", "folding", "transcription", "mirna", "transcript", "mutation_context")

    def __iter__(self):
        return iter((self.splicing, self.protein, self.tis, self.folding,
                     self.transcription, self.mirna, self.transcript, self.mutation_context))

    def __getitem__(self, idx):
        return tuple(self)[idx]

    # -- Unified scoring interface --------------------------------------------

    def __post_init__(self):
        self._scores: Optional[dict[str, MechanismScore]] = None
        self._overall_score: Optional[float] = None
        self._interaction_flags: Optional[list[str]] = None

    @property
    def scores(self) -> dict[str, MechanismScore]:
        """Per-mechanism normalized scores (0.0–10.0, 1 decimal). Cached on first access."""
        if self._scores is None:
            out: dict[str, MechanismScore] = {}
            for attr, norm_fn in _MECHANISMS:
                info = norm_fn(getattr(self, attr))
                out[attr] = MechanismScore(
                    name=attr,
                    score=round(info["score"], 1),
                    status=info["status"],
                    reason=info.get("reason"),
                    confidence=info.get("confidence", "high"),
                    confidence_reasons=info.get("confidence_reasons", ()),
                    empirical_p=info.get("empirical_p"),
                )
            self._scores = out
        return self._scores

    @property
    def overall_score(self) -> float:
        """Concordance-weighted composite score (0.0–10.0). Cached.

        Uses max score as base, then boosts by up to 20% when multiple
        independent mechanisms agree (score > 2.0 with high/medium confidence).
        This rewards multi-evidence concordance without penalizing single
        strong hits.
        """
        if self._overall_score is None:
            completed = [ms for ms in self.scores.values()
                         if ms.status == "completed"]
            if not completed:
                self._overall_score = 0.0
            else:
                base = max(ms.score for ms in completed)
                # Count concordant mechanisms: score > 2.0 and not low confidence
                concordant = sum(
                    1 for ms in completed
                    if ms.score > 2.0 and ms.confidence != "low"
                )
                # Boost: each additional concordant mechanism adds 5% (max 20%)
                bonus_factor = 1.0 + 0.05 * max(0, concordant - 1)
                self._overall_score = min(10.0, round(base * bonus_factor, 1))
        return self._overall_score

    @property
    def interaction_flags(self) -> list[str]:
        """Cross-mechanism interaction flags for compound effects.

        Detects biologically meaningful interactions between mechanisms
        that wouldn't be apparent from individual scores alone.
        """
        if self._interaction_flags is not None:
            return self._interaction_flags

        flags = []
        s = self.scores

        # Splicing + TIS: compound translational impact
        sp = s.get("splicing")
        ti = s.get("tis")
        if (sp and sp.status == "completed" and sp.score > 3.0 and
                ti and ti.status == "completed" and ti.score > 3.0):
            flags.append("splicing_tis_compound_translational_impact")

        # Folding near splice site: potential cryptic splicing regulator
        fo = s.get("folding")
        if (fo and fo.status == "completed" and fo.score > 3.0 and
                sp and sp.status == "completed" and sp.score > 2.0):
            flags.append("folding_may_affect_splicing_regulation")

        # Transcription + splicing: regulatory and post-transcriptional
        tr = s.get("transcription")
        if (tr and tr.status == "completed" and tr.score > 3.0 and
                sp and sp.status == "completed" and sp.score > 3.0):
            flags.append("regulatory_and_splicing_dual_impact")

        # miRNA + transcription: post-transcriptional regulatory axis
        mi = s.get("mirna")
        if (mi and mi.status == "completed" and mi.score > 3.0 and
                tr and tr.status == "completed" and tr.score > 3.0):
            flags.append("mirna_and_transcription_regulatory_axis")

        # miRNA + splicing: altered 3'UTR binding landscape
        if (mi and mi.status == "completed" and mi.score > 3.0 and
                sp and sp.status == "completed" and sp.score > 3.0):
            flags.append("splicing_alters_mirna_binding_landscape")

        # All mechanisms elevated: systemic disruption
        active = [ms for ms in (sp, ti, fo, tr, mi)
                  if ms and ms.status == "completed" and ms.score > 2.0]
        if len(active) >= 3:
            flags.append("multi_mechanism_disruption")

        self._interaction_flags = flags
        return self._interaction_flags

    @property
    def n_concordant_mechanisms(self) -> int:
        """Number of mechanisms with score > 2.0 and non-low confidence."""
        return sum(
            1 for ms in self.scores.values()
            if ms.status == "completed" and ms.score > 2.0 and ms.confidence != "low"
        )

    # -- Direct access to key data -------------------------------------------

    # Sequences
    @property
    def reference_protein(self) -> str:
        return self.splicing.reference_protein or ""

    @property
    def variant_protein(self) -> str:
        t = self.transcript
        return getattr(t, "protein", "") or "" if t is not None else ""

    @property
    def reference_mrna(self) -> str:
        return self.splicing.reference_mrna or ""

    @property
    def variant_mrna(self) -> str:
        t = self.transcript
        mrna = getattr(t, "mature_mrna", None) if t is not None else None
        return getattr(mrna, "seq", "") or "" if mrna is not None else ""

    # Conservation
    @property
    def conservation_vector(self) -> Optional[np.ndarray]:
        return self.splicing.conservation_vector

    # Splicing
    @property
    def missplicing(self) -> float:
        return self.splicing.missplicing

    @property
    def oncosplice_score(self) -> float:
        return self.splicing.max_oncosplice_score

    @property
    def isoforms(self) -> pd.DataFrame:
        return self.splicing.isoforms

    @property
    def donor_df(self) -> Optional[pd.DataFrame]:
        return self.splicing.donor_df

    @property
    def acceptor_df(self) -> Optional[pd.DataFrame]:
        return self.splicing.acceptor_df

    @property
    def splice_events(self):
        return self.splicing.splice_events

    # Folding
    @property
    def folding_window(self) -> Optional[FoldingWindow]:
        return self.folding.central_window if self.folding else None

    @property
    def folding_structure_ref(self) -> Optional[str]:
        w = self.folding_window
        return w.ref_structure if w else None

    @property
    def folding_structure_var(self) -> Optional[str]:
        w = self.folding_window
        return w.var_structure if w else None

    @property
    def delta_mfe(self) -> Optional[float]:
        return self.folding.delta_mfe if self.folding else None

    # TIS
    @property
    def canonical_tis_intact(self) -> Optional[bool]:
        return self.tis.initiation_intact if self.tis and self.tis.status == "completed" else None

    @property
    def tis_shift_score(self) -> float:
        return self.tis.score if self.tis and self.tis.status == "completed" else 0.0

    @property
    def canonical_prob_change(self) -> float:
        return self.tis.canonical_prob_change if self.tis and self.tis.status == "completed" else 0.0

    @property
    def canonical_usage_change(self) -> float:
        return self.tis.canonical_usage_change if self.tis and self.tis.status == "completed" else 0.0

    @property
    def gained_alternative_tis(self) -> list[dict]:
        return self.tis.gained_alternative_tis if self.tis and self.tis.status == "completed" else []

    @property
    def num_alternative_tis(self) -> int:
        return self.tis.num_alternative_tis if self.tis and self.tis.status == "completed" else 0

    @property
    def top_alternative_tis(self) -> Optional[dict]:
        """Top gained alternative TIS site (position, prob, location, in_frame)."""
        if not self.tis or self.tis.status != "completed" or not self.tis.top_alternative_position:
            return None
        return {
            "position": self.tis.top_alternative_position,
            "probability": self.tis.top_alternative_prob,
            "location": self.tis.top_alternative_location,
            "in_frame": self.tis.top_alternative_in_frame,
        }

    @property
    def mutation_mrna_positions(self) -> list[int]:
        return self.tis.mutation_mrna_positions if self.tis and self.tis.status == "completed" else []

    # -- Enrichment properties (lazy-computed from existing data) -----------

    @property
    def missense_oncosplice_score(self) -> Optional[float]:
        """Standalone missense conservation impact score (from direct protein change)."""
        ms = self.splicing.missense_score
        return ms["missense_oncosplice_score"] if ms else None

    @property
    def variant_classification(self) -> dict:
        """Computational variant classification hint (LOF/GOF/VUS).

        Synthesizes all mechanism scores, NMD status, and concordance
        into a suggested functional classification. NOT a clinical call.
        """
        from .enrichment import classify_variant

        s = self.scores
        sp = s.get("splicing")
        ti = s.get("tis")
        fo = s.get("folding")
        tr = s.get("transcription")

        # Check NMD from top isoform if available
        has_ptc = False
        nmd_eligible = False
        truncation = 0.0
        if not self.splicing.isoforms.empty:
            top = self.splicing.isoforms.iloc[0]
            ref_len = len(self.reference_protein)
            var_prot = top.get("variant_protein", "")
            if ref_len > 0 and var_prot:
                var_len = len(str(var_prot).rstrip("*"))
                truncation = 1.0 - (var_len / ref_len)
                has_ptc = truncation > 0.05

        predominant = "unchanged"
        if self.transcription and self.transcription.status == "completed":
            predominant = self.transcription.predominant_direction

        return classify_variant(
            splicing_score=sp.score if sp else 0.0,
            tis_score=ti.score if ti else 0.0,
            folding_score=fo.score if fo else 0.0,
            transcription_score=tr.score if tr else 0.0,
            splicing_confidence=sp.confidence if sp else "high",
            has_ptc=has_ptc,
            nmd_eligible=nmd_eligible,
            truncation_fraction=truncation,
            n_concordant=self.n_concordant_mechanisms,
            predominant_direction=predominant,
        )
