"""
Test suite for enrichment pipelines: protein analysis, miRNA disruption,
miBSIM prediction, confidence framework, and enrichment utilities.

These tests do NOT require seqmat/ViennaRNA/Borzoi — they test the
pure-Python enrichment logic using synthetic inputs.

Run with:  pytest tests/test_enrichments.py -v
"""

import math

import numpy as np
import pytest

from geney.results import (
    SplicingResult,
    ProteinResult,
    TISResult,
    FoldingResult,
    FoldingWindow,
    TranscriptionResult,
    MiRNAResult,
    MutationResult,
    MechanismScore,
)
from geney.enrichment import (
    predict_nmd_eligibility,
    score_kozak_context,
    base_pair_distance,
    detect_structural_motifs,
    classify_variant,
)
from geney.protein.analysis import analyze_protein_change, _classify_mutation
from geney.mirna.seed import find_seed_matches, SeedMatch
from geney.mirna.disruption import analyze_mirna_disruption
from geney.mirna.mibsim import (
    predict_site_repression,
    compute_ds_correction,
    predict_transcript_repression,
    predict_occupancy,
    get_feature_names,
)
from geney.mirna.features import (
    compute_site_features,
    calc_au_content,
    calc_utr_pos,
    calc_3p_pairing_sw,
    calc_pum,
    calc_prob_binom,
    calc_codon_features,
    is_watson_crick,
)


# ── 1. Protein Pipeline ─────────────────────────────────────────────────────

class TestProteinClassification:
    """_classify_mutation should correctly identify mutation types."""

    def test_synonymous(self):
        mt, desc, _ = _classify_mutation("MKTAYIAK*", "MKTAYIAK*")
        assert mt == "synonymous"

    def test_missense_single(self):
        mt, desc, pos = _classify_mutation("MKTAYIAK", "MKTAYIAL")
        assert mt == "missense"
        assert "K8L" in desc or "8" in desc
        assert pos == 7

    def test_missense_multi(self):
        mt, desc, _ = _classify_mutation("MKTAYIAK", "MLTAYIAL")
        assert mt == "missense"
        assert "2" in desc  # 2 substitutions

    def test_nonsense(self):
        mt, desc, _ = _classify_mutation("MKTAYIAK*", "MKT*")
        assert mt in ("nonsense", "in_frame_deletion")

    def test_start_loss(self):
        mt, desc, _ = _classify_mutation("MKTAYIAK", "LKTAYIAK")
        assert mt == "start_loss"

    def test_no_protein(self):
        mt, desc, _ = _classify_mutation("MKTAYIAK", "")
        assert mt == "no_protein"

    def test_stop_loss(self):
        mt, desc, _ = _classify_mutation("MKTAYIAK*", "MKTAYIAK")
        assert mt == "stop_loss"

    def test_in_frame_deletion(self):
        mt, desc, _ = _classify_mutation("MKTAYIAKQRQ", "MKTAYIAK")
        assert "deletion" in mt or "truncation" in desc.lower() or mt == "in_frame_deletion"


class TestProteinAnalysis:
    """analyze_protein_change end-to-end."""

    def test_synonymous_scores_zero(self):
        r = analyze_protein_change("MKTAYIAK", "MKTAYIAK")
        assert r.status == "completed"
        assert r.mutation_type == "synonymous"
        assert r.score == 0.0

    def test_missense_with_conservation(self):
        cons = np.random.rand(8)
        r = analyze_protein_change("MKTAYIAK", "MKTAYIAL", conservation_vector=cons)
        assert r.status == "completed"
        assert r.mutation_type == "missense"
        assert r.n_mismatches == 1
        assert r.first_mismatch == 7
        assert r.score > 0

    def test_missense_without_conservation(self):
        r = analyze_protein_change("MKTAYIAK", "MKTAYIAL")
        assert r.mutation_type == "missense"
        assert r.has_conservation_data is False
        assert r.score >= 2.0  # Base missense score

    def test_no_protein_scores_ten(self):
        r = analyze_protein_change("MKTAYIAK", "")
        assert r.mutation_type == "no_protein"
        assert r.score == 10.0

    def test_start_loss_high_score(self):
        r = analyze_protein_change("MKTAYIAK", "LKTAYIAK")
        assert r.mutation_type == "start_loss"
        assert r.score >= 8.0

    def test_empty_ref_is_error(self):
        r = analyze_protein_change("", "MKTAYIAK")
        assert r.status == "error"

    def test_metadata_passthrough(self):
        r = analyze_protein_change("MKT", "MLT", mut_id="TEST:1:100:A:G", gene="TEST")
        assert r.mut_id == "TEST:1:100:A:G"
        assert r.gene == "TEST"


# ── 2. Enrichment Utilities ──────────────────────────────────────────────────

class TestNMDPrediction:
    """predict_nmd_eligibility edge cases."""

    def test_no_truncation(self):
        r = predict_nmd_eligibility("MKTAYIAK", "MKTAYIAK")
        assert r["has_ptc"] is False

    def test_severe_truncation(self):
        r = predict_nmd_eligibility("MKT", "MKTAYIAKQRQISFVK")
        assert r["has_ptc"] is True
        assert r["truncation_fraction"] > 0.5

    def test_with_exon_boundaries(self):
        r = predict_nmd_eligibility("MKTAY", "MKTAYIAKQRQISFVK",
                                    exon_boundaries=[3, 8, 12])
        assert r["has_ptc"] is True

    def test_near_start_escape(self):
        """Very early PTC → possible reinitiation → NMD escape."""
        r = predict_nmd_eligibility("MK", "MKTAYIAKQRQISFVKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
        assert r["has_ptc"] is True
        assert r["nmd_escape"] is True


class TestKozakScoring:
    """score_kozak_context correctness."""

    def test_strong_kozak(self):
        # gccAccATGG — strong context: A at -3, G at +4
        r = score_kozak_context("GCCACCATGG", 6)
        assert r["kozak_strength"] == "strong"
        assert r["critical_minus3"] is True
        assert r["critical_plus4"] is True

    def test_weak_kozak(self):
        # tttCccATGT — weak: C at -3, T at +4
        r = score_kozak_context("TTTCCCATGT", 6)
        assert r["kozak_strength"] == "weak"

    def test_score_range(self):
        r = score_kozak_context("AAAAAAAAATGAAAA", 8)
        assert 0.0 <= r["kozak_score"] <= 1.0

    def test_edge_of_sequence(self):
        # ATG at very start — limited context
        r = score_kozak_context("ATGGCC", 0)
        assert isinstance(r["kozak_score"], float)


class TestBasePairDistance:
    """base_pair_distance from dot-bracket notation."""

    def test_identical_structures(self):
        r = base_pair_distance("(((...)))", "(((...)))")
        assert r["bp_distance"] == 0
        assert r["jaccard_similarity"] == 1.0

    def test_completely_different(self):
        r = base_pair_distance("(((...)))", ".........")
        assert r["bp_distance"] > 0
        assert r["jaccard_similarity"] == 0.0

    def test_partial_overlap(self):
        # Two structures that share some base pairs but not all
        r = base_pair_distance("(((....)))", "((......))")
        assert r["bp_distance"] > 0
        assert 0.0 < r["jaccard_similarity"] < 1.0

    def test_empty_structures(self):
        r = base_pair_distance("........", "........")
        assert r["bp_distance"] == 0
        assert r["jaccard_similarity"] == 1.0  # Both empty → trivially similar


class TestStructuralMotifs:
    """detect_structural_motifs for G-quadruplex patterns."""

    def test_gquad_detected(self):
        # Classic G-quadruplex: 4 runs of GG separated by short loops
        r = detect_structural_motifs("GGGTTGGGTTGGGTTGGG")
        assert r["has_g_quadruplex"] is True
        assert r["g_quadruplex_count"] >= 1

    def test_no_gquad(self):
        r = detect_structural_motifs("AAACCCUUUAAACCCUUU")
        assert r["has_g_quadruplex"] is False

    def test_handles_rna(self):
        r = detect_structural_motifs("GGGCUGGGAUGGGCUGGG")
        assert r["has_g_quadruplex"] is True


class TestVariantClassification:
    """classify_variant logic."""

    def test_likely_lof(self):
        r = classify_variant(7.0, 5.5, 3.0, 5.0, has_ptc=True,
                             nmd_eligible=True, truncation_fraction=0.4,
                             n_concordant=3, predominant_direction="downregulated")
        assert r["classification"] == "likely_lof"
        assert r["lof_score"] >= 5.0

    def test_vus(self):
        r = classify_variant(1.0, 1.0, 1.0, 1.0)
        assert r["classification"] == "vus"

    def test_regulatory_effect(self):
        r = classify_variant(1.0, 1.0, 1.0, 5.5,
                             predominant_direction="downregulated")
        assert r["classification"] == "regulatory_effect"

    def test_possible_gof(self):
        r = classify_variant(1.0, 1.0, 1.0, 6.0,
                             predominant_direction="upregulated")
        assert r["classification"] == "possible_gof"


# ── 3. miRNA Seed Matching ───────────────────────────────────────────────────

MIR21_MATURE = "UAGCUUAUCAGACUGAUGUUGA"

class TestSeedMatching:
    """find_seed_matches correctness."""

    def test_finds_seed_in_utr3(self):
        # The rev-comp of miR-21 seed (pos 2-8: AGCUUAU) is ATAAGCT
        mrna = "AAACCC" + "ATAAGCTAAAA"  # 7mer-m8 at pos 6
        matches = find_seed_matches(mrna, {"miR-21": MIR21_MATURE}, utr3_start=0)
        assert len(matches) >= 1
        types = {m.seed_type for m in matches}
        assert "7mer-m8" in types or "8mer" in types or "6mer" in types

    def test_8mer_requires_downstream_A(self):
        # 8mer: 7-nt match + A immediately downstream
        seed_rc = "ATAAGCT"
        mrna = "CCC" + seed_rc + "A" + "CCCC"  # A after the match
        matches = find_seed_matches(mrna, {"miR-21": MIR21_MATURE}, utr3_start=0)
        eightmers = [m for m in matches if m.seed_type == "8mer"]
        assert len(eightmers) >= 1

    def test_no_match_in_random_seq(self):
        mrna = "CCCCCCCCCCCCCCCCCCCCCCCC"
        matches = find_seed_matches(mrna, {"miR-21": MIR21_MATURE})
        assert len(matches) == 0

    def test_region_classification(self):
        # Site in UTR3
        mrna = "A" * 100 + "ATAAGCT" + "A" * 20
        matches = find_seed_matches(mrna, {"miR-21": MIR21_MATURE}, utr3_start=50)
        utr3_matches = [m for m in matches if m.region == "utr3"]
        assert len(utr3_matches) >= 1

    def test_multiple_mirnas(self):
        seeds = {
            "miR-21": MIR21_MATURE,
            "miR-dummy": "AAAAAAUUUUUUUUUUUUUUUUU",
        }
        mrna = "A" * 20 + "ATAAGCT" + "A" * 20
        matches = find_seed_matches(mrna, seeds, utr3_start=0)
        mirna_ids = {m.mirna_id for m in matches}
        assert "miR-21" in mirna_ids

    def test_seed_match_strength(self):
        matches = find_seed_matches(
            "AAAATAAGCTAAAA", {"miR-21": MIR21_MATURE}, utr3_start=0)
        for m in matches:
            assert 0.0 < m.strength <= 1.0


# ── 4. miRNA Disruption Analysis ─────────────────────────────────────────────

class TestMiRNADisruption:
    """analyze_mirna_disruption differential binding."""

    @pytest.fixture
    def ref_var_pair(self):
        ref = "AAACCCATAAGCTGGGAAACCC"
        var = "AAACCCATCCGCTGGGAAACCC"  # Destroyed the seed
        return ref, var

    def test_detects_lost_sites(self, ref_var_pair):
        ref, var = ref_var_pair
        r = analyze_mirna_disruption(ref, var, {"miR-21": MIR21_MATURE}, utr3_start=0)
        assert r.status == "completed"
        assert r.n_lost > 0

    def test_no_gained_sites(self, ref_var_pair):
        ref, var = ref_var_pair
        r = analyze_mirna_disruption(ref, var, {"miR-21": MIR21_MATURE}, utr3_start=0)
        assert r.n_gained == 0

    def test_repression_delta_positive_on_loss(self, ref_var_pair):
        """Losing a binding site → less repression → positive delta."""
        ref, var = ref_var_pair
        r = analyze_mirna_disruption(ref, var, {"miR-21": MIR21_MATURE}, utr3_start=0)
        assert r.repression_delta >= 0

    def test_identical_sequences_no_changes(self):
        seq = "AAACCCATAAGCTGGGAAACCC"
        r = analyze_mirna_disruption(seq, seq, {"miR-21": MIR21_MATURE}, utr3_start=0)
        assert r.n_lost == 0
        assert r.n_gained == 0
        assert r.n_modified == 0
        assert r.repression_delta == 0.0

    def test_gained_site(self):
        ref = "AAACCCTTTTTTGGGAAACCC"
        var = "AAACCCATAAGCTGGGAAACCC"  # Created a seed match
        r = analyze_mirna_disruption(ref, var, {"miR-21": MIR21_MATURE}, utr3_start=0)
        assert r.n_gained > 0

    def test_empty_mirna_seeds_skipped(self):
        r = analyze_mirna_disruption("AAAA", "AAAA", {})
        assert r.status == "skipped"

    def test_score_range(self, ref_var_pair):
        ref, var = ref_var_pair
        r = analyze_mirna_disruption(ref, var, {"miR-21": MIR21_MATURE}, utr3_start=0)
        assert 0.0 <= r.score <= 10.0


# ── 5. miBSIM Prediction Engine ──────────────────────────────────────────────

class TestMiBSIMEngine:
    """Elastic net prediction + DS correction + occupancy model."""

    def test_feature_names_152(self):
        names = get_feature_names("utr3", "8mer")
        assert len(names) == 152

    def test_all_models_have_152_features(self):
        for region in ("orf", "utr3"):
            for seed in ("6mer", "7mer-A1", "7mer-m8", "8mer"):
                names = get_feature_names(region, seed)
                assert len(names) == 152, f"{region}_{seed} has {len(names)} features"

    def test_zero_features_gives_intercept(self):
        """With all-zero features, prediction is just the intercept (negative)."""
        feats = {f: 0.0 for f in get_feature_names("utr3", "8mer")}
        S = predict_site_repression(feats, "utr3", "8mer")
        assert S <= 0.0  # Capped at 0 (repression = negative)
        assert S < -0.5  # 8mer intercept should be meaningfully negative

    def test_repression_capped_at_zero(self):
        """No model should predict upregulation (positive S)."""
        feats = {f: 0.0 for f in get_feature_names("orf", "6mer")}
        S = predict_site_repression(feats, "orf", "6mer")
        assert S <= 0.0

    def test_invalid_model_raises(self):
        with pytest.raises(ValueError):
            predict_site_repression({}, "invalid", "8mer")

    def test_ds_single_site(self):
        """DS = 1.0 when there's only one site."""
        ds = compute_ds_correction(-0.5, [], [], 1)
        assert ds == 1.0

    def test_ds_nearby_sites_reduce(self):
        """Nearby sites should reduce DS below 1.0."""
        ds = compute_ds_correction(-0.5, [-0.5, -0.3], [50, 100], 3)
        assert 0.0 < ds < 1.0

    def test_ds_distant_sites_less_effect(self):
        """Very distant sites should have minimal DS effect."""
        ds_near = compute_ds_correction(-0.5, [-0.5], [50], 2)
        ds_far = compute_ds_correction(-0.5, [-0.5], [5000], 2)
        assert ds_far > ds_near  # Distant sites interact less

    def test_occupancy_returns_valid(self):
        r = predict_occupancy(log_kd=-5.0, in_orf=False)
        assert 0.0 <= r["occ"] <= 1.0
        assert 0.0 <= r["occ_init"] <= 1.0
        assert isinstance(r["pred"], float)

    def test_occupancy_varies_with_affinity(self):
        """Different Kd values → different occupancy predictions."""
        low_kd = predict_occupancy(log_kd=-7.0)   # Very strong binder
        high_kd = predict_occupancy(log_kd=-3.0)   # Weak binder
        assert low_kd["occ"] != high_kd["occ"]
        # Both should produce valid predictions
        assert isinstance(low_kd["pred"], float)
        assert isinstance(high_kd["pred"], float)

    def test_transcript_repression_empty(self):
        r = predict_transcript_repression([])
        assert r["total_repression"] == 0.0
        assert r["per_site_results"] == []

    def test_transcript_repression_single_site(self):
        feats = {f: 0.0 for f in get_feature_names("utr3", "8mer")}
        sites = [{"features": feats, "position": 100, "region": "utr3", "seed_type": "8mer"}]
        r = predict_transcript_repression(sites)
        assert r["n_sites"] == 1
        assert r["total_repression"] < 0  # Should be negative (repression)
        assert r["per_site_results"][0]["ds"] == 1.0  # Single site → no correction

    def test_transcript_repression_multi_site(self):
        feats = {f: 0.0 for f in get_feature_names("utr3", "7mer-m8")}
        sites = [
            {"features": feats, "position": 100, "region": "utr3", "seed_type": "7mer-m8"},
            {"features": feats, "position": 200, "region": "utr3", "seed_type": "7mer-m8"},
        ]
        r = predict_transcript_repression(sites)
        assert r["n_sites"] == 2
        # DS correction should reduce each site's contribution
        for s in r["per_site_results"]:
            assert s["ds"] < 1.0


# ── 6. miBSIM Feature Computation ────────────────────────────────────────────

class TestFeatureComputation:
    """compute_site_features returns correct 152-feature vectors."""

    @pytest.fixture
    def transcript_parts(self):
        utr5 = "GCGCGCAUCCUAGCUG"
        orf = "AUGAAAGCUUACAUUAAACAGCGCCAGAUCAGCUUUGUUAAAUCACUUCUUUUCCGUUUAU" * 3
        utr3 = "AACCCAUAAGCUGGGAAACCCAUAAGCUAAAAUUUUUGUUUAUUUAUUUAU" * 2
        return utr5, orf, utr3

    def test_produces_152_model_features(self, transcript_parts):
        utr5, orf, utr3 = transcript_parts
        feats = compute_site_features(
            utr5=utr5, orf=orf, utr3=utr3, mirna=MIR21_MATURE,
            rna_start=len(utr5) + len(orf) + 6,
            seed_length=7, seed_type="7mer-m8", region="UTR3",
            with_conservation=False, with_thermo=False,
        )
        model_names = get_feature_names("utr3", "7mer-m8")
        missing = [n for n in model_names if n not in feats]
        assert missing == [], f"Missing features: {missing}"

    def test_au_content_range(self, transcript_parts):
        utr5, orf, utr3 = transcript_parts
        rna = utr5 + orf + utr3
        au = calc_au_content(rna, len(utr5) + len(orf) + 6, 7, "7mer-m8")
        assert 0.0 <= au <= 1.0

    def test_utr_pos_log_distances(self, transcript_parts):
        min_d, d_start, d_end = calc_utr_pos(100, 50, 200, 22, 7)
        assert d_start >= 0
        assert d_end >= 0
        assert min_d == min(d_start, d_end)

    def test_watson_crick_pairing(self):
        assert is_watson_crick("A", "U") == [True]
        assert is_watson_crick("G", "C") == [True]
        assert is_watson_crick("A", "A") == [False]
        assert is_watson_crick("AUGC", "GCAU") == [True, True, True, True]

    def test_pum_counts(self, transcript_parts):
        utr5, orf, utr3 = transcript_parts
        rna = utr5 + orf + utr3
        orf_p, utr3_p, nei_p = calc_pum(rna, len(utr5) + 10, 7, len(utr5), len(utr5) + len(orf))
        assert len(orf_p) == 2
        assert len(utr3_p) == 2
        assert all(v >= 0 for v in orf_p + utr3_p + nei_p)

    def test_codon_features_orf_region(self, transcript_parts):
        utr5, orf, utr3 = transcript_parts
        rna = utr5 + orf + utr3
        feats = calc_codon_features(
            rna.replace("U", "T"), len(utr5) + 10, 7,
            len(utr5), len(utr5) + len(orf), "ORF")
        assert "cub_global_orf" in feats
        assert "cub_global_win" in feats
        assert feats["cub_global_orf"] > 0

    def test_codon_features_utr3_sentinel(self, transcript_parts):
        utr5, orf, utr3 = transcript_parts
        rna = utr5 + orf + utr3
        feats = calc_codon_features(
            rna.replace("U", "T"), len(utr5) + len(orf) + 10, 7,
            len(utr5), len(utr5) + len(orf), "UTR3")
        assert feats["cub_global_win"] == 999.0  # Sentinel for non-ORF

    def test_features_feed_into_model(self, transcript_parts):
        """End-to-end: features → elastic net → repression prediction."""
        utr5, orf, utr3 = transcript_parts
        feats = compute_site_features(
            utr5=utr5, orf=orf, utr3=utr3, mirna=MIR21_MATURE,
            rna_start=len(utr5) + len(orf) + 6,
            seed_length=7, seed_type="7mer-m8", region="UTR3",
            with_conservation=False, with_thermo=False,
        )
        S = predict_site_repression(feats, "utr3", "7mer-m8")
        assert isinstance(S, float)
        assert S <= 0.0
        assert S > -5.0  # Shouldn't be absurdly negative

    def test_prob_binom_range(self, transcript_parts):
        utr5, orf, utr3 = transcript_parts
        rna = utr5 + orf + utr3
        p = calc_prob_binom(rna, len(utr5) + 10, 7)
        assert 0.0 <= p <= 1.0


# ── 7. Confidence Framework ──────────────────────────────────────────────────

class TestConfidenceFramework:
    """MechanismScore confidence tiers from normalizers."""

    def test_splicing_no_conservation_is_low(self):
        sr = SplicingResult(max_oncosplice_score=5.0, conservation_vector=np.ones(200))
        mr = MutationResult(splicing=sr)
        assert mr.scores["splicing"].confidence == "low"
        assert "conservation_data_missing_using_fallback" in mr.scores["splicing"].confidence_reasons

    def test_splicing_none_conservation_is_low(self):
        sr = SplicingResult(max_oncosplice_score=5.0, conservation_vector=None)
        mr = MutationResult(splicing=sr)
        assert mr.scores["splicing"].confidence == "low"

    def test_splicing_real_conservation_is_high(self):
        sr = SplicingResult(max_oncosplice_score=5.0,
                            conservation_vector=np.random.rand(200))
        mr = MutationResult(splicing=sr)
        assert mr.scores["splicing"].confidence == "high"

    def test_tis_weak_alternative_is_medium(self):
        tis = TISResult(status="completed", score=0.5,
                        canonical_prob_var=0.3, canonical_prob_ref=0.7,
                        top_alternative_prob=0.08)
        mr = MutationResult(splicing=SplicingResult(), tis=tis)
        assert mr.scores["tis"].confidence == "medium"

    def test_folding_not_significant_annotated(self):
        """Non-significant p-value is annotated but doesn't downgrade confidence
        (confidence reflects data quality, not significance)."""
        fo = FoldingResult(status="completed", score=1.0, delta_mfe=-0.5, empirical_p=0.35)
        mr = MutationResult(splicing=SplicingResult(), folding=fo)
        # Confidence stays high (data quality is fine)
        assert mr.scores["folding"].confidence == "high"
        # But the non-significance is noted in reasons
        assert any("not_significant" in r for r in mr.scores["folding"].confidence_reasons)

    def test_folding_significant_is_high(self):
        fo = FoldingResult(status="completed", score=6.0, delta_mfe=-4.0, empirical_p=0.005)
        mr = MutationResult(splicing=SplicingResult(), folding=fo)
        assert mr.scores["folding"].confidence == "high"

    def test_protein_no_cons_missense_is_low(self):
        pr = ProteinResult(status="completed", score=2.0,
                           mutation_type="missense", has_conservation_data=False)
        mr = MutationResult(splicing=SplicingResult(), protein=pr)
        assert mr.scores["protein"].confidence == "low"

    def test_mirna_limited_panel_is_medium(self):
        mi = MiRNAResult(status="completed", score=3.0, n_mirnas_scanned=10)
        mr = MutationResult(splicing=SplicingResult(), mirna=mi)
        assert mr.scores["mirna"].confidence == "medium"


# ── 8. Concordance Scoring & Interaction Flags ───────────────────────────────

class TestConcordanceScoring:
    """overall_score, n_concordant_mechanisms, interaction_flags."""

    def test_concordance_boosts_score(self):
        sr = SplicingResult(max_oncosplice_score=5.0, conservation_vector=np.random.rand(100))
        mr = MutationResult(
            splicing=sr,
            tis=TISResult(status="completed", score=0.4, canonical_prob_var=0.5,
                          canonical_prob_ref=0.7, top_alternative_prob=0.3),
            folding=FoldingResult(status="completed", score=4.0, delta_mfe=-3.0, empirical_p=0.01),
        )
        assert mr.n_concordant_mechanisms >= 3
        assert mr.overall_score > 5.0  # Base is 5.0, concordance should boost

    def test_single_mechanism_no_boost(self):
        sr = SplicingResult(max_oncosplice_score=5.0, conservation_vector=np.random.rand(100))
        mr = MutationResult(splicing=sr)
        # Only splicing has score > 2.0
        assert mr.overall_score == 5.0  # No boost

    def test_multi_mechanism_disruption_flag(self):
        sr = SplicingResult(max_oncosplice_score=5.0, conservation_vector=np.random.rand(100))
        mr = MutationResult(
            splicing=sr,
            tis=TISResult(status="completed", score=0.4, canonical_prob_var=0.5,
                          canonical_prob_ref=0.7, top_alternative_prob=0.3),
            folding=FoldingResult(status="completed", score=4.0, delta_mfe=-3.0, empirical_p=0.01),
            transcription=TranscriptionResult(status="completed", score=5.0),
        )
        assert "multi_mechanism_disruption" in mr.interaction_flags

    def test_splicing_tis_flag(self):
        sr = SplicingResult(max_oncosplice_score=5.0, conservation_vector=np.random.rand(100))
        tis = TISResult(status="completed", score=0.5, canonical_prob_var=0.3,
                        canonical_prob_ref=0.7, top_alternative_prob=0.4)
        mr = MutationResult(splicing=sr, tis=tis)
        assert "splicing_tis_compound_translational_impact" in mr.interaction_flags

    def test_mirna_transcription_flag(self):
        mr = MutationResult(
            splicing=SplicingResult(),
            mirna=MiRNAResult(status="completed", score=4.0, n_mirnas_scanned=200),
            transcription=TranscriptionResult(status="completed", score=4.0),
        )
        assert "mirna_and_transcription_regulatory_axis" in mr.interaction_flags

    def test_low_confidence_excluded_from_concordance(self):
        sr = SplicingResult(max_oncosplice_score=5.0, conservation_vector=np.ones(200))
        mr = MutationResult(splicing=sr)
        # Conservation fallback → low confidence → not concordant
        assert mr.scores["splicing"].confidence == "low"
        # Low-confidence mechanisms don't count toward concordance
        concordant_scores = [
            ms for ms in mr.scores.values()
            if ms.status == "completed" and ms.score > 2.0 and ms.confidence != "low"
        ]
        assert len(concordant_scores) == 0


# ── 9. MutationResult Integration ────────────────────────────────────────────

class TestMutationResultIntegration:
    """Full MutationResult with all 6 mechanisms."""

    @pytest.fixture
    def full_result(self):
        return MutationResult(
            splicing=SplicingResult(max_oncosplice_score=5.0,
                                    conservation_vector=np.random.rand(100)),
            protein=ProteinResult(status="completed", score=7.0,
                                  mutation_type="frameshift", has_ptc=True),
            tis=TISResult(status="completed", score=0.3, canonical_prob_var=0.5,
                          canonical_prob_ref=0.7, top_alternative_prob=0.3),
            folding=FoldingResult(status="completed", score=4.0, delta_mfe=-2.5,
                                  empirical_p=0.02, bp_distance=4, jaccard_similarity=0.6),
            transcription=TranscriptionResult(status="completed", score=5.0,
                                              predominant_direction="downregulated"),
            mirna=MiRNAResult(status="completed", score=3.0, n_lost=1,
                              n_mirnas_scanned=200),
        )

    def test_six_mechanisms_in_scores(self, full_result):
        assert len(full_result.scores) == 6
        assert set(full_result.scores.keys()) == {
            "splicing", "protein", "tis", "folding", "transcription", "mirna"}

    def test_overall_score_range(self, full_result):
        assert 0.0 <= full_result.overall_score <= 10.0

    def test_variant_classification_returns_dict(self, full_result):
        vc = full_result.variant_classification
        assert "classification" in vc
        assert "evidence_summary" in vc
        assert "lof_score" in vc

    def test_empty_result_all_not_run(self):
        mr = MutationResult()
        for name, ms in mr.scores.items():
            if name == "splicing":
                assert ms.status == "completed"  # Empty splicing is "completed" with score 0
            else:
                assert ms.status == "not_run"

    def test_scores_cached(self, full_result):
        s1 = full_result.scores
        s2 = full_result.scores
        assert s1 is s2  # Same object, cached

    def test_mechanism_score_repr(self, full_result):
        for ms in full_result.scores.values():
            r = repr(ms)
            assert "MechanismScore" in r
