"""Weighted-random advertisement selection tests.

Covers the shared :func:`select_weighted` primitive (edge cases) and the
banner/homepage selector :func:`get_active_advertisement` — proving that
priority is now a *display weight* (probability) rather than an ordering, that
every eligible advertisement can appear, that higher weights appear more often,
and that eligibility filtering (active status + schedule window) is unchanged.
"""

import os
import types
import unittest
from datetime import timedelta

os.environ["TEST_DATABASE_URL"] = "sqlite:///:memory:"

from app import create_app
from app.advertisements.services import (
    DEFAULT_DISPLAY_WEIGHT,
    get_active_advertisement,
    select_weighted,
)
from app.extensions import db
from app.models import Advertisement, AdvertisementPlacement, AdvertisementType
from app.models.base import utcnow


def _stub(weight):
    """A duck-typed advertisement with just the attribute select_weighted reads."""
    return types.SimpleNamespace(priority=weight)


class SelectWeightedUnitTests(unittest.TestCase):
    """Pure-function edge cases (no database needed)."""

    def test_empty_pool_returns_none(self):
        self.assertIsNone(select_weighted([]))

    def test_single_always_returned(self):
        only = _stub(0)  # even weight 0 is returned when it's the only option
        self.assertIs(select_weighted([only]), only)

    def test_negative_weight_treated_as_zero(self):
        neg, pos = _stub(-100), _stub(10)
        picks = {id(select_weighted([neg, pos])) for _ in range(300)}
        self.assertEqual(picks, {id(pos)})  # negative never selected beside positive

    def test_missing_weight_defaults(self):
        # A None weight falls back to DEFAULT_DISPLAY_WEIGHT and still participates.
        missing, small = _stub(None), _stub(1)
        picks = {id(select_weighted([missing, small])) for _ in range(400)}
        self.assertIn(id(missing), picks)  # default (100) dominates 1, but appears
        self.assertGreater(DEFAULT_DISPLAY_WEIGHT, 1)

    def test_all_zero_equal(self):
        a, b = _stub(0), _stub(0)
        picks = {id(select_weighted([a, b])) for _ in range(400)}
        self.assertEqual(picks, {id(a), id(b)})


class WeightedBannerSelectionTests(unittest.TestCase):
    def setUp(self):
        self.app = create_app("testing")
        self.ctx = self.app.app_context()
        self.ctx.push()
        db.create_all()

    def tearDown(self):
        db.session.remove()
        db.drop_all()
        self.ctx.pop()

    def _ad(self, title, *, weight=100, active=True, start_at=None, end_at=None,
            placement=AdvertisementPlacement.LANDING):
        ad = Advertisement(
            title=title, advertisement_type=AdvertisementType.BANNER, placement=placement,
            image_path=f"/static/uploads/advertisements/{title}.png",
            is_active=active, priority=weight, start_at=start_at, end_at=end_at,
        )
        db.session.add(ad)
        db.session.commit()
        return ad

    def _titles(self, runs=200):
        return {get_active_advertisement("landing").title for _ in range(runs)}

    # --- basic behaviour -------------------------------------------------------
    def test_none_when_empty(self):
        self.assertIsNone(get_active_advertisement("landing"))

    def test_single_ad_always_returned(self):
        self._ad("Solo")
        self.assertEqual(self._titles(), {"Solo"})

    def test_all_eligible_ads_can_appear(self):
        # The old ORDER BY priority DESC only ever returned the top ad; now every
        # eligible ad appears over many selections.
        self._ad("SkillUp", weight=100)
        self._ad("Laptop", weight=50)
        self._ad("Dominos", weight=20)
        self.assertEqual(self._titles(runs=600), {"SkillUp", "Laptop", "Dominos"})

    def test_no_ad_always_first(self):
        # Two equal-weight ads: both must lead across runs (not one fixed winner).
        self._ad("A", weight=100)
        self._ad("B", weight=100)
        self.assertEqual(self._titles(runs=200), {"A", "B"})

    # --- weighting influences frequency ---------------------------------------
    def test_probability_distribution_matches_weights(self):
        # Weights 100 / 50 / 20 → ≈ 59% / 29% / 12%. Loose bounds avoid flakiness
        # while proving weight drives frequency and the order of likelihood.
        self._ad("SkillUp", weight=100)
        self._ad("Laptop", weight=50)
        self._ad("Dominos", weight=20)
        runs = 3000
        counts = {"SkillUp": 0, "Laptop": 0, "Dominos": 0}
        for _ in range(runs):
            counts[get_active_advertisement("landing").title] += 1
        frac = {k: v / runs for k, v in counts.items()}
        self.assertGreater(frac["SkillUp"], frac["Laptop"])
        self.assertGreater(frac["Laptop"], frac["Dominos"])
        self.assertTrue(0.50 <= frac["SkillUp"] <= 0.68, frac)   # ≈0.588
        self.assertTrue(0.21 <= frac["Laptop"] <= 0.37, frac)    # ≈0.294
        self.assertTrue(0.06 <= frac["Dominos"] <= 0.19, frac)   # ≈0.118

    def test_equal_weights_roughly_even(self):
        self._ad("A", weight=100)
        self._ad("B", weight=100)
        runs = 2000
        counts = {"A": 0, "B": 0}
        for _ in range(runs):
            counts[get_active_advertisement("landing").title] += 1
        # Each should land near 50% (generous band).
        self.assertTrue(0.4 <= counts["A"] / runs <= 0.6, counts)

    # --- eligibility filtering unchanged --------------------------------------
    def test_inactive_never_selected(self):
        self._ad("Live", active=True)
        self._ad("Off", active=False)
        self.assertEqual(self._titles(), {"Live"})

    def test_expired_never_selected(self):
        now = utcnow()
        self._ad("Live")
        self._ad("Expired", end_at=now - timedelta(days=1))
        self.assertEqual(self._titles(), {"Live"})

    def test_not_yet_started_never_selected(self):
        now = utcnow()
        self._ad("Live")
        self._ad("Future", start_at=now + timedelta(days=1))
        self.assertEqual(self._titles(), {"Live"})

    def test_other_placement_never_selected(self):
        self._ad("Landing", placement=AdvertisementPlacement.LANDING)
        self._ad("Sidebar", placement=AdvertisementPlacement.SIDEBAR)
        self.assertEqual(self._titles(), {"Landing"})

    def test_zero_weight_ignored_beside_positive(self):
        self._ad("Zero", weight=0)
        self._ad("Normal", weight=100)
        self.assertEqual(self._titles(), {"Normal"})


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