"""
tests/test_phase6_dynamic_weights.py — Phase 6: Dynamic Weighting

WATERFALL_PLAN §10 — Replace equal 1/7 lens weights with content-adaptive
weights.  Addresses BS-12 (equal weights) and BS-18 (weight insensitivity).

Test IDs: 6.01 – 6.08
"""

from __future__ import annotations

import math
import unittest
from typing import Dict

from bileshke.engine import (
    DEFAULT_WEIGHTS,
    compute_dynamic_weights,
    validate_weights,
    bileshke,
    MIN_WEIGHT,
    DEFAULT_BOOST_FACTOR,
)
from bileshke.types import LensId, LensResult, LENS_ORDER, NUM_LENSES


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

def _all_lens_ids_str() -> list[str]:
    """Return all lens string ids in canonical order."""
    return [lid.value for lid in LENS_ORDER]


def _make_uniform_scores(value: float = 0.5) -> Dict[str, float]:
    """Return domain scores with every lens at *value*."""
    return {lid.value: value for lid in LENS_ORDER}


def _make_dummy_results(score: float = 0.3) -> list[LensResult]:
    """Create 7 dummy LensResult objects for bileshke testing."""
    results = []
    for lid in LENS_ORDER:
        results.append(LensResult(
            lens_id=lid,
            score=score,
        ))
    return results


# ════════════════════════════════════════════════════════════════════
# 6.01 — Equal domain scores → equal weights
# ════════════════════════════════════════════════════════════════════

class TestEqualDomainScoresEqualWeights(unittest.TestCase):
    """When every lens has the same domain score, dynamic weights
    should be equal (each ≈ 1/7)."""

    def test_all_same_score_yields_equal_weights(self):
        scores = _make_uniform_scores(0.5)
        weights = compute_dynamic_weights(scores)
        expected = 1.0 / NUM_LENSES
        for lid in LENS_ORDER:
            self.assertAlmostEqual(weights[lid], expected, places=6,
                                   msg=f"{lid.value} weight diverges from 1/7")

    def test_all_zero_yields_equal_weights(self):
        """All domain scores 0.0 → boosted equally → still equal."""
        scores = _make_uniform_scores(0.0)
        weights = compute_dynamic_weights(scores)
        expected = 1.0 / NUM_LENSES
        for lid in LENS_ORDER:
            self.assertAlmostEqual(weights[lid], expected, places=6)

    def test_all_one_yields_equal_weights(self):
        """All domain scores 1.0 → boosted equally → still equal."""
        scores = _make_uniform_scores(1.0)
        weights = compute_dynamic_weights(scores)
        expected = 1.0 / NUM_LENSES
        for lid in LENS_ORDER:
            self.assertAlmostEqual(weights[lid], expected, places=6)


# ════════════════════════════════════════════════════════════════════
# 6.02 — Dominant domain gets most weight
# ════════════════════════════════════════════════════════════════════

class TestDominantDomainGetsMoreWeight(unittest.TestCase):
    """A lens with a high domain score must receive the largest weight."""

    def test_bayes_dominant(self):
        scores = _make_uniform_scores(0.1)
        scores["Bayes"] = 0.9
        weights = compute_dynamic_weights(scores)
        bayes_w = weights[LensId.BAYES]
        for lid in LENS_ORDER:
            if lid != LensId.BAYES:
                self.assertGreater(bayes_w, weights[lid],
                                   f"Bayes should outweigh {lid.value}")

    def test_fol_dominant(self):
        scores = _make_uniform_scores(0.05)
        scores["FOL"] = 0.8
        weights = compute_dynamic_weights(scores)
        fol_w = weights[LensId.FOL]
        for lid in LENS_ORDER:
            if lid != LensId.FOL:
                self.assertGreater(fol_w, weights[lid],
                                   f"FOL should outweigh {lid.value}")

    def test_holografik_dominant(self):
        scores = _make_uniform_scores(0.0)
        scores["Topoloji + Holografik"] = 0.7
        weights = compute_dynamic_weights(scores)
        holo_w = weights[LensId.TOPOLOJI_HOLOGRAFIK]
        for lid in LENS_ORDER:
            if lid != LensId.TOPOLOJI_HOLOGRAFIK:
                self.assertGreater(holo_w, weights[lid],
                                   f"Holografik should outweigh {lid.value}")


# ════════════════════════════════════════════════════════════════════
# 6.03 — Minimum weight enforced
# ════════════════════════════════════════════════════════════════════

class TestMinimumWeightEnforced(unittest.TestCase):
    """Even when a domain score is 0.0, the weight must not fall
    below ``min_weight`` (before renormalization)."""

    def test_zero_domain_still_above_min_after_renormalize(self):
        scores = {lid.value: 0.0 for lid in LENS_ORDER}
        scores["Bayes"] = 1.0  # one lens extreme
        weights = compute_dynamic_weights(scores, min_weight=0.05)
        for lid in LENS_ORDER:
            self.assertGreater(weights[lid], 0.0,
                               f"{lid.value} weight is zero — AX50 violation")

    def test_explicit_min_weight(self):
        scores = {lid.value: 0.0 for lid in LENS_ORDER}
        weights = compute_dynamic_weights(scores, min_weight=0.10)
        # All zero scores → all at clamp floor → equal → 1/7
        expected = 1.0 / NUM_LENSES
        for lid in LENS_ORDER:
            self.assertAlmostEqual(weights[lid], expected, places=6)

    def test_high_min_weight_dominates(self):
        """With a very high min_weight, all lenses converge toward equal."""
        scores = _make_uniform_scores(0.0)
        scores["Bayes"] = 1.0
        # min_weight = 0.50 → clamp dominates boosting
        weights = compute_dynamic_weights(scores, min_weight=0.50)
        # Bayes boosted = 0.1429*(1+2*1)=0.4286 < 0.50, so also clamped
        # All clamped to 0.50 → equal after renorm
        expected = 1.0 / NUM_LENSES
        for lid in LENS_ORDER:
            self.assertAlmostEqual(weights[lid], expected, places=6)


# ════════════════════════════════════════════════════════════════════
# 6.04 — Weights sum to 1.0
# ════════════════════════════════════════════════════════════════════

class TestWeightsSumToOne(unittest.TestCase):
    """For any valid input, output weights must sum to 1.0 and pass
    ``validate_weights()``."""

    def _check_valid(self, scores, **kw):
        weights = compute_dynamic_weights(scores, **kw)
        total = sum(weights.values())
        self.assertAlmostEqual(total, 1.0, places=6,
                               msg=f"Sum={total}, expected 1.0")
        ok, msg = validate_weights(weights)
        self.assertTrue(ok, msg)

    def test_uniform(self):
        self._check_valid(_make_uniform_scores(0.5))

    def test_skewed(self):
        scores = _make_uniform_scores(0.1)
        scores["Bayes"] = 0.95
        scores["FOL"] = 0.70
        self._check_valid(scores)

    def test_sparse(self):
        """Only two lenses have non-zero domain scores."""
        scores = {lid.value: 0.0 for lid in LENS_ORDER}
        scores["Ontoloji"] = 0.3
        scores["Mereoloji"] = 0.6
        self._check_valid(scores)

    def test_various_boost_factors(self):
        scores = _make_uniform_scores(0.3)
        for bf in [0.0, 0.5, 1.0, 3.0, 10.0]:
            self._check_valid(scores, boost_factor=bf)

    def test_extreme_values(self):
        scores = {lid.value: 1.0 for lid in LENS_ORDER}
        self._check_valid(scores, boost_factor=100.0)


# ════════════════════════════════════════════════════════════════════
# 6.05 — Dynamic weights used correctly in bileshke
# ════════════════════════════════════════════════════════════════════

class TestDynamicWeightsInBileshke(unittest.TestCase):
    """bileshke() must accept dynamic weights and produce
    different composites than default weights."""

    def test_bileshke_accepts_dynamic_weights(self):
        results = _make_dummy_results(0.3)
        scores = _make_uniform_scores(0.1)
        scores["Bayes"] = 0.9
        dw = compute_dynamic_weights(scores)
        composite = bileshke(results, weights=dw)
        self.assertGreater(composite, 0.0)
        self.assertLess(composite, 1.0)

    def test_bileshke_default_vs_dynamic_differ_with_varying_lens_scores(self):
        """When lens scores differ AND weights differ, composites diverge."""
        results = []
        for i, lid in enumerate(LENS_ORDER):
            # Most lenses get 0.1, but Bayes gets 0.9
            s = 0.9 if lid == LensId.BAYES else 0.1
            results.append(LensResult(lens_id=lid, score=s))
        # Skewed domain scores — boost Bayes
        scores = _make_uniform_scores(0.0)
        scores["Bayes"] = 0.9

        dw = compute_dynamic_weights(scores)
        default_composite = bileshke(results, weights=DEFAULT_WEIGHTS)
        dynamic_composite = bileshke(results, weights=dw)

        # Dynamic should push composite UP because Bayes (high score)
        # gets more weight
        self.assertGreater(dynamic_composite, default_composite)


# ════════════════════════════════════════════════════════════════════
# 6.06 — Full dusun() uses dynamic weights
# ════════════════════════════════════════════════════════════════════

class TestDusunUsesDynamicWeights(unittest.TestCase):
    """Integration test: dusun() should produce a result with a
    composite score (verifying dynamic weights are wired in)."""

    def test_dusun_returns_composite(self):
        """dusun with Bayesian text should run without error and
        return a composite score."""
        try:
            from fw_server.dusun import dusun
        except ImportError:
            self.skipTest("dusun not importable")

        text = (
            "The posterior probability P(H|E) is computed via Bayes' "
            "theorem.  The prior P(H) is updated by the likelihood "
            "ratio P(E|H)/P(E) to yield the posterior."
        )
        result = dusun(text)
        self.assertIn("composite_score", result)
        self.assertGreater(result["composite_score"], 0.0)
        self.assertLess(result["composite_score"], 1.0)


# ════════════════════════════════════════════════════════════════════
# 6.07 — Fallback to DEFAULT_WEIGHTS
# ════════════════════════════════════════════════════════════════════

class TestFallbackToDefault(unittest.TestCase):
    """When no domain scores are available, compute_dynamic_weights
    must return DEFAULT_WEIGHTS."""

    def test_empty_dict_returns_default(self):
        weights = compute_dynamic_weights({})
        for lid in LENS_ORDER:
            self.assertAlmostEqual(weights[lid], DEFAULT_WEIGHTS[lid],
                                   places=10)

    def test_none_like_empty(self):
        """Passing no known keys → all treated as 0.0 → fallback-like."""
        weights = compute_dynamic_weights({"unknown_lens": 0.5})
        # None of the keys match → all get 0.0 boost → equal weights
        expected = 1.0 / NUM_LENSES
        for lid in LENS_ORDER:
            self.assertAlmostEqual(weights[lid], expected, places=6)


# ════════════════════════════════════════════════════════════════════
# 6.08 — Regression baseline
# ════════════════════════════════════════════════════════════════════

class TestRegressionBaseline(unittest.TestCase):
    """Sanity checks to ensure dynamic weighting doesn't break
    pre-existing invariants."""

    def test_validate_weights_accepts_dynamic(self):
        scores = _make_uniform_scores(0.3)
        dw = compute_dynamic_weights(scores)
        ok, msg = validate_weights(dw)
        self.assertTrue(ok, msg)

    def test_all_weights_positive(self):
        scores = _make_uniform_scores(0.0)
        dw = compute_dynamic_weights(scores)
        for lid in LENS_ORDER:
            self.assertGreater(dw[lid], 0.0)

    def test_deterministic(self):
        """Same input → same output (no randomness)."""
        scores = _make_uniform_scores(0.4)
        scores["FOL"] = 0.8
        w1 = compute_dynamic_weights(scores)
        w2 = compute_dynamic_weights(scores)
        for lid in LENS_ORDER:
            self.assertEqual(w1[lid], w2[lid])

    def test_compute_text_scores_returns_seven(self):
        """compute_text_scores must return exactly 7 entries."""
        from fw_server.adapters import compute_text_scores
        ts = compute_text_scores("Whatever text input")
        self.assertEqual(len(ts), NUM_LENSES)
        for lid in _all_lens_ids_str():
            self.assertIn(lid, ts)
            self.assertGreaterEqual(ts[lid], 0.0)


# ════════════════════════════════════════════════════════════════════
# Extra edge-case coverage
# ════════════════════════════════════════════════════════════════════

class TestEdgeCases(unittest.TestCase):
    """Additional boundary and edge-case tests."""

    def test_domain_score_clamped_above_one(self):
        """Domain scores > 1.0 are clamped to 1.0 internally."""
        scores = _make_uniform_scores(0.1)
        scores["Bayes"] = 5.0  # way above 1
        weights = compute_dynamic_weights(scores)
        total = sum(weights.values())
        self.assertAlmostEqual(total, 1.0, places=6)
        # Bayes still highest
        self.assertGreater(weights[LensId.BAYES],
                           weights[LensId.ONTOLOJI])

    def test_negative_domain_score_treated_as_zero(self):
        """Negative domain scores are clamped to 0.0."""
        scores = _make_uniform_scores(0.1)
        scores["Ontoloji"] = -0.5
        weights = compute_dynamic_weights(scores)
        total = sum(weights.values())
        self.assertAlmostEqual(total, 1.0, places=6)
        self.assertGreater(weights[LensId.ONTOLOJI], 0.0)

    def test_boost_factor_zero_yields_equal(self):
        """boost_factor=0 → no amplification → equal weights."""
        scores = _make_uniform_scores(0.0)
        scores["Bayes"] = 1.0
        weights = compute_dynamic_weights(scores, boost_factor=0.0)
        expected = 1.0 / NUM_LENSES
        for lid in LENS_ORDER:
            self.assertAlmostEqual(weights[lid], expected, places=6)


if __name__ == "__main__":
    unittest.main()
