"""Unit tests for Phase 1A output functions in MCP server and metering."""

from __future__ import annotations

import json
from dataclasses import dataclass
from unittest.mock import MagicMock

import pytest

from mixlift_mcp.server import (
    _compute_confidence_summary,
    _compute_dollar_headline,
    _compute_model_fit,
    _compute_saturation_traffic_lights,
    _parse_scenario,
)


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


@dataclass
class FakeLicenseStatus:
    is_pro: bool
    max_channels: int
    max_rows: int
    message: str


def _make_model_results(
    roas_estimates: dict,
    roas_ci: dict,
    channel_contributions: dict | None = None,
    channel_contributions_ci: dict | None = None,
    marginal_roas_estimates: dict | None = None,
    marginal_roas_ci: dict | None = None,
    convergence_warnings: list | None = None,
    duration_secs: float = 30.0,
    total_spend: float = 50000.0,
) -> MagicMock:
    """Build a MagicMock that quacks like ModelResults."""
    mr = MagicMock()
    mr.roas_estimates = roas_estimates
    mr.roas_ci = roas_ci
    mr.channel_contributions = channel_contributions or {k: 0.5 for k in roas_estimates}
    mr.channel_contributions_ci = channel_contributions_ci or {
        k: (0.3, 0.7) for k in roas_estimates
    }
    mr.marginal_roas_estimates = marginal_roas_estimates or roas_estimates
    mr.marginal_roas_ci = marginal_roas_ci or roas_ci
    mr.convergence_warnings = convergence_warnings or []
    mr.duration_secs = duration_secs
    mr.total_spend = total_spend
    return mr


# ---------------------------------------------------------------------------
# _compute_dollar_headline tests
# ---------------------------------------------------------------------------


class TestComputeDollarHeadline:
    def test_basic_headline(self):
        optimization = {
            "current_allocation": {"Meta": 10000, "Google": 8000},
            "optimal_allocation": {"Meta": 7000, "Google": 11000},
            "expected_lift_pct": 12.5,
            "total_budget": 18000,
        }
        result = _compute_dollar_headline(optimization)

        assert "headline" in result
        assert "$" in result["headline"]
        assert "12.5" in result["headline"]
        assert result["lift_pct"] == 12.5
        assert result["dollar_opportunity"] == round(18000 * 12.5 / 100, 0)

        # Reallocations should contain both channels (delta > 1)
        assert len(result["reallocations"]) == 2
        channels = {r["channel"] for r in result["reallocations"]}
        assert channels == {"Meta", "Google"}

        # Meta should be Decrease, Google should be Increase
        for r in result["reallocations"]:
            if r["channel"] == "Meta":
                assert r["action"] == "Decrease"
                assert r["change_dollars"] == 3000
            elif r["channel"] == "Google":
                assert r["action"] == "Increase"
                assert r["change_dollars"] == 3000

    def test_zero_lift(self):
        optimization = {
            "current_allocation": {"Meta": 5000, "Google": 5000},
            "optimal_allocation": {"Meta": 5000, "Google": 5000},
            "expected_lift_pct": 0.0,
            "total_budget": 10000,
        }
        result = _compute_dollar_headline(optimization)

        assert result["dollar_opportunity"] == 0
        assert result["lift_pct"] == 0.0
        assert "headline" in result
        # No reallocations when nothing changes
        assert len(result["reallocations"]) == 0


# ---------------------------------------------------------------------------
# _compute_saturation_traffic_lights tests
# ---------------------------------------------------------------------------


class TestComputeSaturationTrafficLights:
    """Test traffic-light status based on saturation percentage."""

    @staticmethod
    def _make_curve(max_spend=10000, n_points=100) -> dict:
        """Linear saturation curve for predictable interpolation."""
        spend = [i * (max_spend / n_points) for i in range(n_points + 1)]
        response = [s * 0.5 for s in spend]  # response = 0.5 * spend (linear)
        return {"spend": spend, "response": response}

    def test_green_status(self):
        """Current spend at 30% of max -> saturation ~30% -> green."""
        curve = self._make_curve(max_spend=10000)
        saturation_curves = {"Meta": curve}
        optimization = {"current_allocation": {"Meta": 3000}}

        lights = _compute_saturation_traffic_lights(saturation_curves, optimization)

        assert "Meta" in lights
        assert lights["Meta"]["status"] == "green"
        assert lights["Meta"]["saturation_pct"] < 50

    def test_yellow_status(self):
        """Current spend at 60% of max -> saturation ~60% -> yellow."""
        curve = self._make_curve(max_spend=10000)
        saturation_curves = {"Meta": curve}
        optimization = {"current_allocation": {"Meta": 6000}}

        lights = _compute_saturation_traffic_lights(saturation_curves, optimization)

        assert lights["Meta"]["status"] == "yellow"
        assert 50 <= lights["Meta"]["saturation_pct"] < 75

    def test_red_status(self):
        """Current spend at 90% of max -> saturation ~90% -> red."""
        curve = self._make_curve(max_spend=10000)
        saturation_curves = {"Meta": curve}
        optimization = {"current_allocation": {"Meta": 9000}}

        lights = _compute_saturation_traffic_lights(saturation_curves, optimization)

        assert lights["Meta"]["status"] == "red"
        assert lights["Meta"]["saturation_pct"] >= 75


# ---------------------------------------------------------------------------
# _compute_confidence_summary tests
# ---------------------------------------------------------------------------


class TestComputeConfidenceSummary:
    def test_high_confidence(self):
        """Tight CI (relative width < 0.5) -> high confidence."""
        mr = _make_model_results(
            roas_estimates={"Meta": 2.0},
            roas_ci={"Meta": (1.8, 2.2)},  # width=0.4, rel=0.2
        )
        result = _compute_confidence_summary(mr)

        assert result["Meta"]["confidence"] == "high"

    def test_moderate_confidence(self):
        """Moderate CI (0.5 <= relative width < 1.0) -> moderate."""
        mr = _make_model_results(
            roas_estimates={"Google": 3.0},
            roas_ci={"Google": (2.0, 4.0)},  # width=2.0, rel=0.667
        )
        result = _compute_confidence_summary(mr)

        assert result["Google"]["confidence"] == "moderate"

    def test_low_confidence(self):
        """Wide CI (relative width >= 1.0) -> low."""
        mr = _make_model_results(
            roas_estimates={"TikTok": 1.0},
            roas_ci={"TikTok": (0.1, 2.5)},  # width=2.4, rel=2.4
        )
        result = _compute_confidence_summary(mr)

        assert result["TikTok"]["confidence"] == "low"

    def test_multiple_channels(self):
        """Multiple channels each with different confidence levels."""
        mr = _make_model_results(
            roas_estimates={"Meta": 4.0, "Google": 3.0, "TikTok": 1.0},
            roas_ci={
                "Meta": (3.9, 4.1),      # high
                "Google": (2.0, 4.0),     # moderate
                "TikTok": (0.1, 2.5),     # low
            },
        )
        result = _compute_confidence_summary(mr)

        assert result["Meta"]["confidence"] == "high"
        assert result["Google"]["confidence"] == "moderate"
        assert result["TikTok"]["confidence"] == "low"


# ---------------------------------------------------------------------------
# _compute_model_fit tests (light — relies on mmm internals)
# ---------------------------------------------------------------------------


class TestComputeModelFit:
    def test_fallback_when_no_posterior(self):
        """When model_results.mmm has no posterior_predictive, return fallback."""
        mr = MagicMock()
        mr.mmm.idata = MagicMock(spec=[])  # no posterior_predictive attr
        result = _compute_model_fit(mr)

        assert "label" in result
        assert "not available" in result["label"]


# ---------------------------------------------------------------------------
# _parse_scenario tests
# ---------------------------------------------------------------------------


class TestParseScenario:
    CURRENT = {"Meta": 10000, "Google": 8000, "TikTok": 5000}

    def test_cut_by_percentage(self):
        result = _parse_scenario("cut Meta by 20%", self.CURRENT)
        assert result is not None
        assert result["Meta"] == 8000.0  # 10000 * 0.8
        # Other channels unchanged
        assert result["Google"] == 8000
        assert result["TikTok"] == 5000

    def test_increase_by_dollar(self):
        result = _parse_scenario("increase Google by $5000", self.CURRENT)
        assert result is not None
        assert result["Google"] == 13000.0

    def test_move_between_channels(self):
        result = _parse_scenario("move $3000 from Meta to Google", self.CURRENT)
        assert result is not None
        assert result["Meta"] == 7000.0
        assert result["Google"] == 11000.0

    def test_unparseable_returns_none(self):
        result = _parse_scenario("do something weird", self.CURRENT)
        assert result is None

    def test_increase_by_percentage(self):
        result = _parse_scenario("increase Google by 50%", self.CURRENT)
        assert result is not None
        assert result["Google"] == 12000.0  # 8000 * 1.5

    def test_reduce_by_dollar(self):
        result = _parse_scenario("reduce TikTok by $2000", self.CURRENT)
        assert result is not None
        assert result["TikTok"] == 3000.0


# ---------------------------------------------------------------------------
# Metering tests
# ---------------------------------------------------------------------------


@pytest.mark.real_metering
class TestMetering:
    def test_fresh_usage_allowed(self, tmp_path, monkeypatch):
        """First run on fresh usage file -> allowed."""
        import mixlift_mcp.metering as metering_mod

        usage_file = tmp_path / "usage.json"
        monkeypatch.setattr(metering_mod, "USAGE_FILE", usage_file)

        allowed, msg = metering_mod.check_and_increment()
        assert allowed is True
        assert "1/2" in msg

    def test_at_limit_blocked(self, tmp_path, monkeypatch):
        """When runs == max_free_runs, next call is blocked."""
        import mixlift_mcp.metering as metering_mod
        from datetime import datetime

        usage_file = tmp_path / "usage.json"
        monkeypatch.setattr(metering_mod, "USAGE_FILE", usage_file)

        current_month = datetime.now().strftime("%Y-%m")
        usage_file.write_text(json.dumps({"month": current_month, "runs": 2}))

        allowed, msg = metering_mod.check_and_increment()
        assert allowed is False
        assert "limit reached" in msg.lower()

    def test_month_rollover_resets(self, tmp_path, monkeypatch):
        """Old month data gets reset, allowing new runs."""
        import mixlift_mcp.metering as metering_mod

        usage_file = tmp_path / "usage.json"
        monkeypatch.setattr(metering_mod, "USAGE_FILE", usage_file)

        # Write usage from a previous month
        usage_file.write_text(json.dumps({"month": "1999-01", "runs": 99}))

        allowed, msg = metering_mod.check_and_increment()
        assert allowed is True
        assert "1/2" in msg


# ---------------------------------------------------------------------------
# Free tier redaction in _build_result tests
# ---------------------------------------------------------------------------


class TestBuildResultTierRedaction:
    """Test that _build_result redacts dollar values for free tier."""

    @staticmethod
    def _call_build_result(is_pro: bool, data_weeks: float = 52) -> dict:
        from mixlift_mcp.server import _build_result

        mr = _make_model_results(
            roas_estimates={"Meta": 2.0, "Google": 3.0},
            roas_ci={"Meta": (1.5, 2.5), "Google": (2.5, 3.5)},
        )
        optimization = {
            "current_allocation": {"Meta": 10000, "Google": 8000},
            "optimal_allocation": {"Meta": 7000, "Google": 11000},
            "expected_lift_pct": 10.0,
            "total_budget": 18000,
        }
        saturation_curves = {
            "Meta": {"spend": [0, 5000, 10000], "response": [0, 400, 700]},
            "Google": {"spend": [0, 5000, 10000], "response": [0, 500, 800]},
        }
        license_status = FakeLicenseStatus(
            is_pro=is_pro,
            max_channels=10 if is_pro else 2,
            max_rows=100000 if is_pro else 2000,
            message="Pro" if is_pro else "Free tier",
        )

        import unittest.mock as um

        fake_rec = MagicMock()
        fake_rec.rank = 1
        fake_rec.action = "Increase"
        fake_rec.channel = "Google"
        fake_rec.change_amount = 3000
        fake_rec.current_spend = 8000
        fake_rec.optimal_spend = 11000
        fake_rec.roas = 3.0
        fake_rec.reason = "High ROAS"

        # Patch at the source module where _build_result imports from
        with um.patch(
            "mixlift.modeling.recommendations.generate_recommendations",
            return_value=([fake_rec], "Increase Google"),
        ), um.patch(
            "mixlift.modeling.recommendations.format_all_recommendations",
            return_value="formatted",
        ), um.patch(
            "mixlift_mcp.server._compute_model_fit",
            return_value={"label": "Model explains 85% of variation."},
        ):
            return _build_result(
                model_results=mr,
                optimization=optimization,
                saturation_curves=saturation_curves,
                channel_columns=["Meta", "Google"],
                license_status=license_status,
                csv_format="wide",
                data_weeks=data_weeks,
            )

    def test_free_tier_redacts_dollar_allocations(self):
        result = self._call_build_result(is_pro=False)

        bo = result["budget_optimization"]
        assert isinstance(bo["current_allocation"], str)
        assert "Upgrade" in bo["current_allocation"]
        assert isinstance(bo["optimal_allocation"], str)
        assert "Upgrade" in bo["optimal_allocation"]

        # dollar_value_analysis should not contain reallocations
        dva = result["dollar_value_analysis"]
        assert "reallocations" not in dva
        assert "note" in dva

    def test_pro_tier_shows_dollar_values(self):
        result = self._call_build_result(is_pro=True)

        bo = result["budget_optimization"]
        assert isinstance(bo["current_allocation"], dict)
        assert bo["current_allocation"]["Meta"] == 10000

        dva = result["dollar_value_analysis"]
        assert "reallocations" in dva
        assert dva["dollar_opportunity"] > 0


# ---------------------------------------------------------------------------
# Exploratory analysis badge tests
# ---------------------------------------------------------------------------


class TestExploratoryBadge:
    def test_short_data_has_badge(self):
        result = TestBuildResultTierRedaction._call_build_result(
            is_pro=True, data_weeks=10
        )
        badge = result["analysis_badge"]
        assert badge is not None
        assert badge["type"] == "exploratory"
        assert "10" in badge["note"]
        assert "26 weeks" in badge["note"]

    def test_long_data_no_badge(self):
        result = TestBuildResultTierRedaction._call_build_result(
            is_pro=True, data_weeks=52
        )
        assert result["analysis_badge"] is None
