"""
Tests for Adaptive Smart Budget Phase 2: Hysteresis & Model Tier Lock.

Covers:
- compute_model_tiers(): tier derivation from model universe
- BudgetPacingCache: TTL, put/get/invalidate
- evaluate_transition(): all 5 transition rules + cooldown
- apply_transition(): state machine correctness
- End-to-end oscillation prevention scenario
"""

import time
import pytest
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock, patch

from api.services.smart_budget_state import (
    BudgetPacingCache,
    BudgetPacingState,
    ModelTier,
    TransitionDecision,
    apply_transition,
    compute_model_tiers,
    evaluate_transition,
    find_next_tier,
    find_tier_by_name,
    find_tier_for_price,
    UPGRADE_THRESHOLD_PCT,
    UPGRADE_SUSTAIN_SECONDS,
    DOWNGRADE_THRESHOLD_PCT,
    DOWNGRADE_SUSTAIN_SECONDS,
    UPGRADE_COOLDOWN_SECONDS,
    DOWNGRADE_COOLDOWN_SECONDS,
    EMERGENCY_BUDGET_PCT,
    END_OF_PERIOD_DAYS,
    END_OF_PERIOD_SURPLUS_PCT,
    CACHE_TTL_SECONDS,
)


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

SAMPLE_MODELS = [
    {"model_id": "flash", "model_name": "Flash", "price": 0.50, "hle": 20.0},
    {"model_id": "haiku", "model_name": "Haiku", "price": 0.80, "hle": 28.0},
    {"model_id": "sonnet", "model_name": "Sonnet", "price": 3.00, "hle": 38.0},
    {"model_id": "pro", "model_name": "Pro", "price": 5.00, "hle": 44.7},
    {"model_id": "opus", "model_name": "Opus", "price": 12.00, "hle": 50.0},
]


def _make_tiers() -> list[ModelTier]:
    return compute_model_tiers(SAMPLE_MODELS)


def _make_state(
    sub_id=1,
    p_max=5.0,
    tier_name="balanced",
    tier_min=3.0,
    tier_max=5.0,
    transition_dir=None,
    transition_started=None,
    last_transition_at=None,
    velocity_ema=200_000.0,
) -> BudgetPacingState:
    now = datetime.now(timezone.utc)
    return BudgetPacingState(
        subscription_id=sub_id,
        effective_p_max=p_max,
        locked_model_tier=tier_name,
        locked_tier_min_price=tier_min,
        locked_tier_max_price=tier_max,
        locked_at=now,
        lock_expires_at=None,
        velocity_ema=velocity_ema,
        last_forecast_p_max=None,
        transition_direction=transition_dir,
        transition_started_at=transition_started,
        transition_reason=None,
        last_transition_at=last_transition_at,
    )


# ===========================================================================
# compute_model_tiers
# ===========================================================================

class TestComputeModelTiers:
    def test_empty_models_returns_empty(self):
        assert compute_model_tiers([]) == []

    def test_single_model_single_tier(self):
        tiers = compute_model_tiers([{"model_id": "a", "price": 1.0}])
        assert len(tiers) == 1
        assert tiers[0].name == "economy"

    def test_creates_tiers_at_price_gaps(self):
        tiers = _make_tiers()
        assert len(tiers) >= 2, f"Expected at least 2 tiers, got {len(tiers)}"
        for t in tiers:
            assert t.min_price <= t.max_price
            assert len(t.models) > 0

    def test_tiers_sorted_by_price_ascending(self):
        tiers = _make_tiers()
        for i in range(1, len(tiers)):
            assert tiers[i].min_price >= tiers[i - 1].max_price

    def test_all_models_assigned_to_exactly_one_tier(self):
        tiers = _make_tiers()
        all_ids = set()
        for t in tiers:
            for m in t.models:
                assert m["model_id"] not in all_ids, f"Model {m['model_id']} in multiple tiers"
                all_ids.add(m["model_id"])
        assert all_ids == {m["model_id"] for m in SAMPLE_MODELS}

    def test_tier_names_are_unique(self):
        tiers = _make_tiers()
        names = [t.name for t in tiers]
        assert len(names) == len(set(names))

    def test_closely_priced_models_same_tier(self):
        models = [
            {"model_id": "a", "price": 1.00},
            {"model_id": "b", "price": 1.10},
            {"model_id": "c", "price": 1.20},
        ]
        tiers = compute_model_tiers(models)
        assert len(tiers) == 1


# ===========================================================================
# Tier lookup helpers
# ===========================================================================

class TestTierLookups:
    def test_find_tier_for_price_basic(self):
        tiers = _make_tiers()
        tier = find_tier_for_price(tiers, 4.0)
        assert tier is not None
        assert tier.min_price <= 4.0

    def test_find_tier_for_price_below_cheapest(self):
        tiers = _make_tiers()
        tier = find_tier_for_price(tiers, 0.01)
        assert tier is not None
        assert tier.name == tiers[0].name

    def test_find_next_tier(self):
        tiers = _make_tiers()
        first = tiers[0]
        nxt = find_next_tier(tiers, first.name)
        assert nxt is not None
        assert nxt.min_price > first.max_price or nxt.min_price >= first.min_price

    def test_find_next_tier_at_top_returns_none(self):
        tiers = _make_tiers()
        last = tiers[-1]
        assert find_next_tier(tiers, last.name) is None

    def test_find_tier_by_name(self):
        tiers = _make_tiers()
        for t in tiers:
            assert find_tier_by_name(tiers, t.name) is t

    def test_find_tier_by_name_missing(self):
        tiers = _make_tiers()
        assert find_tier_by_name(tiers, "nonexistent") is None


# ===========================================================================
# BudgetPacingCache
# ===========================================================================

class TestBudgetPacingCache:
    def test_put_and_get(self):
        cache = BudgetPacingCache(ttl=10)
        state = _make_state(sub_id=42)
        cache.put(state)
        assert cache.get(42) is state

    def test_get_missing_returns_none(self):
        cache = BudgetPacingCache()
        assert cache.get(999) is None

    def test_ttl_expiry(self):
        cache = BudgetPacingCache(ttl=0.05)
        state = _make_state(sub_id=1)
        cache.put(state)
        assert cache.get(1) is not None
        time.sleep(0.06)
        assert cache.get(1) is None

    def test_invalidate(self):
        cache = BudgetPacingCache()
        cache.put(_make_state(sub_id=1))
        cache.invalidate(1)
        assert cache.get(1) is None

    def test_invalidate_missing_is_noop(self):
        cache = BudgetPacingCache()
        cache.invalidate(999)

    def test_clear(self):
        cache = BudgetPacingCache()
        cache.put(_make_state(sub_id=1))
        cache.put(_make_state(sub_id=2))
        assert cache.size() == 2
        cache.clear()
        assert cache.size() == 0

    def test_overwrite(self):
        cache = BudgetPacingCache()
        s1 = _make_state(sub_id=1, p_max=5.0)
        s2 = _make_state(sub_id=1, p_max=3.0)
        cache.put(s1)
        cache.put(s2)
        assert cache.get(1).effective_p_max == 3.0


# ===========================================================================
# evaluate_transition — Rule 3: Emergency downgrade
# ===========================================================================

class TestEmergencyDowngrade:
    def test_triggers_below_10_percent(self):
        tiers = _make_tiers()
        state = _make_state(tier_name=tiers[-1].name, tier_min=tiers[-1].min_price, tier_max=tiers[-1].max_price)
        decision = evaluate_transition(
            current_state=state,
            raw_p_max=2.0,
            tiers=tiers,
            remaining_budget=8.0,
            budget_limit=100.0,
            days_remaining=15.0,
            projected_spend=92.0,
            velocity_ema=200_000.0,
        )
        assert decision.action == "emergency_downgrade"
        assert decision.target_tier == tiers[0].name

    def test_does_not_trigger_above_10_percent(self):
        tiers = _make_tiers()
        state = _make_state(tier_name=tiers[-1].name, tier_min=tiers[-1].min_price, tier_max=tiers[-1].max_price)
        decision = evaluate_transition(
            current_state=state,
            raw_p_max=10.0,
            tiers=tiers,
            remaining_budget=20.0,
            budget_limit=100.0,
            days_remaining=15.0,
            projected_spend=80.0,
            velocity_ema=200_000.0,
        )
        assert decision.action != "emergency_downgrade"

    def test_does_not_trigger_when_already_cheapest(self):
        tiers = _make_tiers()
        state = _make_state(tier_name=tiers[0].name, tier_min=tiers[0].min_price, tier_max=tiers[0].max_price)
        decision = evaluate_transition(
            current_state=state,
            raw_p_max=0.3,
            tiers=tiers,
            remaining_budget=5.0,
            budget_limit=100.0,
            days_remaining=15.0,
            projected_spend=95.0,
            velocity_ema=200_000.0,
        )
        assert decision.action != "emergency_downgrade"


# ===========================================================================
# evaluate_transition — Rule 4: End-of-period lock
# ===========================================================================

class TestEndOfPeriodLock:
    def test_triggers_last_2_days_high_projected(self):
        tiers = _make_tiers()
        state = _make_state(tier_name=tiers[-1].name, tier_min=tiers[-1].min_price, tier_max=tiers[-1].max_price)
        decision = evaluate_transition(
            current_state=state,
            raw_p_max=10.0,
            tiers=tiers,
            remaining_budget=20.0,
            budget_limit=100.0,
            days_remaining=1.5,
            projected_spend=96.0,
            velocity_ema=200_000.0,
        )
        assert decision.action == "lock_cheapest"

    def test_does_not_trigger_with_days_remaining(self):
        tiers = _make_tiers()
        state = _make_state(tier_name=tiers[-1].name, tier_min=tiers[-1].min_price, tier_max=tiers[-1].max_price)
        decision = evaluate_transition(
            current_state=state,
            raw_p_max=10.0,
            tiers=tiers,
            remaining_budget=20.0,
            budget_limit=100.0,
            days_remaining=5.0,
            projected_spend=96.0,
            velocity_ema=200_000.0,
        )
        assert decision.action != "lock_cheapest"


# ===========================================================================
# evaluate_transition — Rule 5: Use-it-or-lose-it
# ===========================================================================

class TestUseItOrLoseIt:
    def test_triggers_surplus_near_end(self):
        tiers = _make_tiers()
        state = _make_state(tier_name=tiers[0].name, tier_min=tiers[0].min_price, tier_max=tiers[0].max_price)
        decision = evaluate_transition(
            current_state=state,
            raw_p_max=15.0,
            tiers=tiers,
            remaining_budget=60.0,
            budget_limit=100.0,
            days_remaining=3.0,
            projected_spend=40.0,
            velocity_ema=200_000.0,
        )
        assert decision.action == "upgrade"
        assert decision.target_tier == tiers[-1].name

    def test_does_not_trigger_when_budget_tight(self):
        tiers = _make_tiers()
        state = _make_state(tier_name=tiers[0].name, tier_min=tiers[0].min_price, tier_max=tiers[0].max_price)
        decision = evaluate_transition(
            current_state=state,
            raw_p_max=2.0,
            tiers=tiers,
            remaining_budget=30.0,
            budget_limit=100.0,
            days_remaining=3.0,
            projected_spend=70.0,
            velocity_ema=200_000.0,
        )
        assert decision.action != "upgrade" or decision.reason != "use_it_or_lose_it"


# ===========================================================================
# evaluate_transition — Rule 1: Sustained upgrade
# ===========================================================================

class TestSustainedUpgrade:
    def test_starts_tracking_when_threshold_crossed(self):
        tiers = _make_tiers()
        economy = tiers[0]
        next_tier = tiers[1]
        threshold_price = next_tier.min_price * (1 + UPGRADE_THRESHOLD_PCT)

        state = _make_state(
            tier_name=economy.name,
            tier_min=economy.min_price,
            tier_max=economy.max_price,
        )
        decision = evaluate_transition(
            current_state=state,
            raw_p_max=threshold_price + 1.0,
            tiers=tiers,
            remaining_budget=80.0,
            budget_limit=100.0,
            days_remaining=20.0,
            projected_spend=20.0,
            velocity_ema=200_000.0,
        )
        assert decision.action == "hold"
        assert "tracking_started" in decision.reason

    def test_upgrades_after_sustained_period(self):
        tiers = _make_tiers()
        economy = tiers[0]
        next_tier = tiers[1]
        threshold_price = next_tier.min_price * (1 + UPGRADE_THRESHOLD_PCT)
        now = datetime.now(timezone.utc)

        state = _make_state(
            tier_name=economy.name,
            tier_min=economy.min_price,
            tier_max=economy.max_price,
            transition_dir="upgrade",
            transition_started=now - timedelta(seconds=UPGRADE_SUSTAIN_SECONDS + 10),
        )
        decision = evaluate_transition(
            current_state=state,
            raw_p_max=threshold_price + 1.0,
            tiers=tiers,
            remaining_budget=80.0,
            budget_limit=100.0,
            days_remaining=20.0,
            projected_spend=20.0,
            velocity_ema=200_000.0,
            now=now,
        )
        assert decision.action == "upgrade"
        assert decision.target_tier == next_tier.name

    def test_holds_during_sustain_period(self):
        tiers = _make_tiers()
        economy = tiers[0]
        next_tier = tiers[1]
        threshold_price = next_tier.min_price * (1 + UPGRADE_THRESHOLD_PCT)
        now = datetime.now(timezone.utc)

        state = _make_state(
            tier_name=economy.name,
            tier_min=economy.min_price,
            tier_max=economy.max_price,
            transition_dir="upgrade",
            transition_started=now - timedelta(seconds=30),
        )
        decision = evaluate_transition(
            current_state=state,
            raw_p_max=threshold_price + 1.0,
            tiers=tiers,
            remaining_budget=80.0,
            budget_limit=100.0,
            days_remaining=20.0,
            projected_spend=20.0,
            velocity_ema=200_000.0,
            now=now,
        )
        assert decision.action == "hold"
        assert "pending" in decision.reason


# ===========================================================================
# evaluate_transition — Rule 2: Sustained downgrade
# ===========================================================================

class TestSustainedDowngrade:
    def test_starts_tracking_when_threshold_crossed(self):
        tiers = _make_tiers()
        premium = tiers[-1]
        threshold_price = premium.min_price * (1 - DOWNGRADE_THRESHOLD_PCT)

        state = _make_state(
            tier_name=premium.name,
            tier_min=premium.min_price,
            tier_max=premium.max_price,
        )
        decision = evaluate_transition(
            current_state=state,
            raw_p_max=threshold_price - 1.0,
            tiers=tiers,
            remaining_budget=50.0,
            budget_limit=100.0,
            days_remaining=20.0,
            projected_spend=50.0,
            velocity_ema=200_000.0,
        )
        assert decision.action == "hold"
        assert "tracking_started" in decision.reason

    def test_downgrades_after_sustained_period(self):
        tiers = _make_tiers()
        premium = tiers[-1]
        threshold_price = premium.min_price * (1 - DOWNGRADE_THRESHOLD_PCT)
        now = datetime.now(timezone.utc)

        state = _make_state(
            tier_name=premium.name,
            tier_min=premium.min_price,
            tier_max=premium.max_price,
            transition_dir="downgrade",
            transition_started=now - timedelta(seconds=DOWNGRADE_SUSTAIN_SECONDS + 10),
        )
        decision = evaluate_transition(
            current_state=state,
            raw_p_max=threshold_price - 1.0,
            tiers=tiers,
            remaining_budget=50.0,
            budget_limit=100.0,
            days_remaining=20.0,
            projected_spend=50.0,
            velocity_ema=200_000.0,
            now=now,
        )
        assert decision.action == "downgrade"


# ===========================================================================
# Cooldown enforcement
# ===========================================================================

class TestCooldown:
    def test_upgrade_blocked_during_cooldown(self):
        tiers = _make_tiers()
        economy = tiers[0]
        next_tier = tiers[1]
        threshold_price = next_tier.min_price * (1 + UPGRADE_THRESHOLD_PCT)
        now = datetime.now(timezone.utc)

        state = _make_state(
            tier_name=economy.name,
            tier_min=economy.min_price,
            tier_max=economy.max_price,
            last_transition_at=now - timedelta(seconds=30),
        )
        decision = evaluate_transition(
            current_state=state,
            raw_p_max=threshold_price + 1.0,
            tiers=tiers,
            remaining_budget=80.0,
            budget_limit=100.0,
            days_remaining=20.0,
            projected_spend=20.0,
            velocity_ema=200_000.0,
            now=now,
        )
        assert decision.action == "hold"
        assert "cooldown" in decision.reason

    def test_downgrade_blocked_during_cooldown(self):
        tiers = _make_tiers()
        premium = tiers[-1]
        threshold_price = premium.min_price * (1 - DOWNGRADE_THRESHOLD_PCT)
        now = datetime.now(timezone.utc)

        state = _make_state(
            tier_name=premium.name,
            tier_min=premium.min_price,
            tier_max=premium.max_price,
            last_transition_at=now - timedelta(seconds=30),
        )
        decision = evaluate_transition(
            current_state=state,
            raw_p_max=threshold_price - 1.0,
            tiers=tiers,
            remaining_budget=50.0,
            budget_limit=100.0,
            days_remaining=20.0,
            projected_spend=50.0,
            velocity_ema=200_000.0,
            now=now,
        )
        assert decision.action == "hold"
        assert "cooldown" in decision.reason

    def test_upgrade_allowed_after_cooldown(self):
        tiers = _make_tiers()
        economy = tiers[0]
        next_tier = tiers[1]
        threshold_price = next_tier.min_price * (1 + UPGRADE_THRESHOLD_PCT)
        now = datetime.now(timezone.utc)

        state = _make_state(
            tier_name=economy.name,
            tier_min=economy.min_price,
            tier_max=economy.max_price,
            last_transition_at=now - timedelta(seconds=UPGRADE_COOLDOWN_SECONDS + 10),
            transition_dir="upgrade",
            transition_started=now - timedelta(seconds=UPGRADE_SUSTAIN_SECONDS + 10),
        )
        decision = evaluate_transition(
            current_state=state,
            raw_p_max=threshold_price + 1.0,
            tiers=tiers,
            remaining_budget=80.0,
            budget_limit=100.0,
            days_remaining=20.0,
            projected_spend=20.0,
            velocity_ema=200_000.0,
            now=now,
        )
        assert decision.action == "upgrade"


# ===========================================================================
# apply_transition
# ===========================================================================

class TestApplyTransition:
    def test_upgrade_creates_new_state(self):
        tiers = _make_tiers()
        target = tiers[1]
        decision = TransitionDecision("upgrade", target.name, "test", 5.0)
        state = apply_transition(
            current_state=None,
            decision=decision,
            subscription_id=1,
            raw_p_max=5.0,
            velocity_ema=200_000.0,
            forecast_p_max=None,
            tiers=tiers,
        )
        assert state.locked_model_tier == target.name
        assert state.transition_direction is None
        assert state.last_transition_at is not None

    def test_hold_preserves_lock(self):
        tiers = _make_tiers()
        old_state = _make_state(tier_name=tiers[0].name, tier_min=tiers[0].min_price, tier_max=tiers[0].max_price)
        decision = TransitionDecision("hold", tiers[0].name, "stable", old_state.effective_p_max)
        new_state = apply_transition(
            current_state=old_state,
            decision=decision,
            subscription_id=1,
            raw_p_max=old_state.effective_p_max,
            velocity_ema=200_000.0,
            forecast_p_max=None,
            tiers=tiers,
        )
        assert new_state.locked_model_tier == old_state.locked_model_tier
        assert new_state.last_transition_at == old_state.last_transition_at

    def test_tracking_started_sets_direction(self):
        tiers = _make_tiers()
        old_state = _make_state(tier_name=tiers[0].name, tier_min=tiers[0].min_price, tier_max=tiers[0].max_price)
        decision = TransitionDecision("hold", tiers[0].name, "upgrade_tracking_started", old_state.effective_p_max)
        new_state = apply_transition(
            current_state=old_state,
            decision=decision,
            subscription_id=1,
            raw_p_max=old_state.effective_p_max,
            velocity_ema=200_000.0,
            forecast_p_max=None,
            tiers=tiers,
        )
        assert new_state.transition_direction == "upgrade"
        assert new_state.transition_started_at is not None

    def test_transition_cleared_resets_direction(self):
        tiers = _make_tiers()
        old_state = _make_state(
            tier_name=tiers[0].name, tier_min=tiers[0].min_price, tier_max=tiers[0].max_price,
            transition_dir="upgrade",
            transition_started=datetime.now(timezone.utc) - timedelta(seconds=10),
        )
        decision = TransitionDecision("hold", tiers[0].name, "transition_cleared", old_state.effective_p_max)
        new_state = apply_transition(
            current_state=old_state,
            decision=decision,
            subscription_id=1,
            raw_p_max=old_state.effective_p_max,
            velocity_ema=200_000.0,
            forecast_p_max=None,
            tiers=tiers,
        )
        assert new_state.transition_direction is None
        assert new_state.transition_started_at is None


# ===========================================================================
# Transition clears when P_max returns to safe zone
# ===========================================================================

class TestTransitionClearing:
    def test_pending_upgrade_clears_when_p_max_drops(self):
        """If P_max drops back below the upgrade threshold, the pending upgrade is cleared."""
        tiers = _make_tiers()
        economy = tiers[0]
        now = datetime.now(timezone.utc)

        state = _make_state(
            tier_name=economy.name,
            tier_min=economy.min_price,
            tier_max=economy.max_price,
            transition_dir="upgrade",
            transition_started=now - timedelta(seconds=60),
        )
        decision = evaluate_transition(
            current_state=state,
            raw_p_max=economy.max_price,
            tiers=tiers,
            remaining_budget=80.0,
            budget_limit=100.0,
            days_remaining=20.0,
            projected_spend=20.0,
            velocity_ema=200_000.0,
            now=now,
        )
        assert decision.action == "hold"
        assert decision.reason == "transition_cleared"


# ===========================================================================
# End-to-end: oscillation prevention
# ===========================================================================

class TestOscillationPrevention:
    """
    Simulates the exact scenario from Section 1.2 of the design doc:
    P_max oscillating around a model boundary.  The system should stay
    on one tier instead of flipping every request.
    """

    def test_p_max_oscillating_around_boundary_stays_locked(self):
        tiers = _make_tiers()
        assert len(tiers) >= 2

        boundary_tier = tiers[1]
        boundary_price = boundary_tier.min_price

        now = datetime.now(timezone.utc)
        state = None

        locked_tiers = []

        for i in range(20):
            # Alternate P_max slightly above and below the boundary
            if i % 2 == 0:
                raw_p_max = boundary_price * 1.02
            else:
                raw_p_max = boundary_price * 0.98

            tick = now + timedelta(seconds=i * 5)

            decision = evaluate_transition(
                current_state=state,
                raw_p_max=raw_p_max,
                tiers=tiers,
                remaining_budget=50.0,
                budget_limit=100.0,
                days_remaining=15.0,
                projected_spend=50.0,
                velocity_ema=200_000.0,
                now=tick,
            )

            state = apply_transition(
                current_state=state,
                decision=decision,
                subscription_id=1,
                raw_p_max=raw_p_max,
                velocity_ema=200_000.0,
                forecast_p_max=None,
                tiers=tiers,
                now=tick,
            )

            locked_tiers.append(state.locked_model_tier)

        unique_tiers = set(locked_tiers)
        assert len(unique_tiers) == 1, (
            f"System oscillated between tiers {unique_tiers} over 20 ticks. "
            "Hysteresis should have prevented this."
        )

    def test_genuine_sustained_change_does_transition(self):
        """A real, sustained P_max increase should eventually trigger an upgrade."""
        tiers = _make_tiers()
        economy = tiers[0]
        next_tier = tiers[1]
        upgrade_price = next_tier.min_price * (1 + UPGRADE_THRESHOLD_PCT) + 1.0

        now = datetime.now(timezone.utc)
        state = _make_state(
            tier_name=economy.name,
            tier_min=economy.min_price,
            tier_max=economy.max_price,
        )

        # Simulate 30-second poll ticks for 4+ minutes
        upgraded = False
        for i in range(15):
            tick = now + timedelta(seconds=i * 30)
            decision = evaluate_transition(
                current_state=state,
                raw_p_max=upgrade_price,
                tiers=tiers,
                remaining_budget=80.0,
                budget_limit=100.0,
                days_remaining=20.0,
                projected_spend=20.0,
                velocity_ema=200_000.0,
                now=tick,
            )
            state = apply_transition(
                current_state=state,
                decision=decision,
                subscription_id=1,
                raw_p_max=upgrade_price,
                velocity_ema=200_000.0,
                forecast_p_max=None,
                tiers=tiers,
                now=tick,
            )
            if decision.action == "upgrade":
                upgraded = True
                break

        assert upgraded, "Sustained P_max above threshold should have triggered upgrade within 4+ minutes"


# ===========================================================================
# Rule priority: emergency overrides normal transitions
# ===========================================================================

class TestRulePriority:
    def test_emergency_overrides_upgrade_tracking(self):
        tiers = _make_tiers()
        now = datetime.now(timezone.utc)
        state = _make_state(
            tier_name=tiers[-1].name,
            tier_min=tiers[-1].min_price,
            tier_max=tiers[-1].max_price,
            transition_dir="upgrade",
            transition_started=now - timedelta(seconds=300),
        )
        decision = evaluate_transition(
            current_state=state,
            raw_p_max=15.0,
            tiers=tiers,
            remaining_budget=5.0,
            budget_limit=100.0,
            days_remaining=10.0,
            projected_spend=95.0,
            velocity_ema=200_000.0,
            now=now,
        )
        assert decision.action == "emergency_downgrade"

    def test_end_of_period_overrides_normal_upgrade(self):
        tiers = _make_tiers()
        state = _make_state(
            tier_name=tiers[-1].name,
            tier_min=tiers[-1].min_price,
            tier_max=tiers[-1].max_price,
        )
        decision = evaluate_transition(
            current_state=state,
            raw_p_max=15.0,
            tiers=tiers,
            remaining_budget=15.0,
            budget_limit=100.0,
            days_remaining=1.0,
            projected_spend=96.0,
            velocity_ema=200_000.0,
        )
        assert decision.action == "lock_cheapest"


# ===========================================================================
# No tiers edge case
# ===========================================================================

class TestEdgeCases:
    def test_no_tiers_returns_hold(self):
        decision = evaluate_transition(
            current_state=None,
            raw_p_max=5.0,
            tiers=[],
            remaining_budget=50.0,
            budget_limit=100.0,
            days_remaining=15.0,
            projected_spend=50.0,
            velocity_ema=200_000.0,
        )
        assert decision.action == "hold"

    def test_first_evaluation_without_prior_state(self):
        tiers = _make_tiers()
        decision = evaluate_transition(
            current_state=None,
            raw_p_max=3.0,
            tiers=tiers,
            remaining_budget=50.0,
            budget_limit=100.0,
            days_remaining=15.0,
            projected_spend=50.0,
            velocity_ema=200_000.0,
        )
        assert decision.action == "hold"
        state = apply_transition(
            current_state=None,
            decision=decision,
            subscription_id=1,
            raw_p_max=3.0,
            velocity_ema=200_000.0,
            forecast_p_max=None,
            tiers=tiers,
        )
        assert state.locked_model_tier is not None
        assert state.subscription_id == 1


# ===========================================================================
# Constants sanity
# ===========================================================================

class TestPhase2Constants:
    def test_upgrade_threshold_positive(self):
        assert UPGRADE_THRESHOLD_PCT > 0

    def test_downgrade_threshold_positive(self):
        assert DOWNGRADE_THRESHOLD_PCT > 0

    def test_downgrade_faster_than_upgrade(self):
        assert DOWNGRADE_SUSTAIN_SECONDS < UPGRADE_SUSTAIN_SECONDS

    def test_downgrade_cooldown_shorter_than_upgrade(self):
        assert DOWNGRADE_COOLDOWN_SECONDS < UPGRADE_COOLDOWN_SECONDS

    def test_emergency_threshold_below_50_percent(self):
        assert EMERGENCY_BUDGET_PCT < 0.50

    def test_cache_ttl_reasonable(self):
        assert 10 <= CACHE_TTL_SECONDS <= 300
