"""Tests for the headless Python API (mixlift.api.analyze)."""

from dataclasses import dataclass
from pathlib import Path
from unittest.mock import MagicMock, patch

import numpy as np
import pandas as pd
import pytest

from mixlift_mcp.license import (
    FREE_MAX_CHANNELS,
    FREE_MAX_ROWS,
    PRO_MAX_CHANNELS,
    PRO_MAX_ROWS,
)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

CHANNELS = ["google", "meta"]


def _make_csv(tmp_path: Path, n_rows: int = 30, n_channels: int = 2) -> Path:
    """Write a minimal wide-format CSV and return the path."""
    dates = pd.date_range("2023-01-01", periods=n_rows, freq="W")
    data = {"date": dates}
    ch_names = [f"channel_{i}" for i in range(n_channels)]
    for ch in ch_names:
        data[ch] = np.random.default_rng(42).uniform(100, 500, size=n_rows)
    data["revenue"] = np.random.default_rng(42).uniform(1000, 5000, size=n_rows)
    df = pd.DataFrame(data)
    csv_path = tmp_path / "test_data.csv"
    df.to_csv(csv_path, index=False)
    return csv_path


@dataclass
class FakeRecommendation:
    rank: int
    action: str
    channel: str
    change_amount: float
    current_spend: float
    optimal_spend: float
    roas: float
    reason: str


def _fake_model_results():
    """Return a mock that mimics ModelResults with 2 channels."""
    mr = MagicMock()
    mr.roas_estimates = {"google": 3.2, "meta": 2.1}
    mr.roas_ci = {"google": (2.0, 4.5), "meta": (1.5, 3.0)}
    mr.channel_contributions = {"google": 0.6, "meta": 0.4}
    mr.marginal_roas_estimates = {"google": 2.8, "meta": 1.9}
    mr.total_spend = {"google": 5000.0, "meta": 3000.0}
    mr.convergence_warnings = []
    mr.raw_contributions = MagicMock()
    mr.mmm = MagicMock()
    return mr


def _fake_optimization():
    return {
        "current_allocation": {"google": 5000.0, "meta": 3000.0},
        "optimal_allocation": {"google": 6000.0, "meta": 2000.0},
        "total_budget": 8000.0,
        "expected_lift_pct": 12.5,
    }


def _fake_recommendations():
    recs = [
        FakeRecommendation(
            rank=1,
            action="INCREASE",
            channel="google",
            change_amount=1000.0,
            current_spend=5000.0,
            optimal_spend=6000.0,
            roas=3.2,
            reason="High ROAS",
        ),
    ]
    summary = "Shift budget from meta to google for +12.5% lift."
    return recs, summary


def _fake_saturation_curves():
    return {"google": [[0, 0], [100, 80]], "meta": [[0, 0], [100, 60]]}


# ---------------------------------------------------------------------------
# Shared patch context
# ---------------------------------------------------------------------------

def _pipeline_patches():
    """Return a dict of patches for the full modeling pipeline.

    These target the lazy imports inside mixlift.api.analyze, which import
    from their original modules.
    """
    return {
        "detect": patch(
            "mixlift.ingest.loader.detect_csv_format", return_value="wide"
        ),
        "load_wide": patch(
            "mixlift.ingest.loader.load_wide_format",
            return_value=(
                pd.DataFrame(np.ones((30, 2)), columns=CHANNELS),
                pd.Series(np.ones(30)),
                CHANNELS,
            ),
        ),
        "run_pipeline": patch(
            "mixlift.modeling.engine.run_full_pipeline",
            return_value=_fake_model_results(),
        ),
        "optimize": patch(
            "mixlift.modeling.optimizer.optimize_budget",
            return_value=_fake_optimization(),
        ),
        "saturation": patch(
            "mixlift.modeling.engine.extract_saturation_curves",
            return_value=_fake_saturation_curves(),
        ),
        "recommendations": patch(
            "mixlift.modeling.recommendations.generate_recommendations",
            return_value=_fake_recommendations(),
        ),
    }


# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------


class TestAnalyzeFreeTier:
    """Tests for analyze() without a license key (free tier)."""

    def test_no_license_key_uses_free_limits(self, tmp_path):
        """analyze() with no license key should not call the license API."""
        csv_path = _make_csv(tmp_path)
        patches = _pipeline_patches()
        with (
            patches["detect"],
            patches["load_wide"],
            patches["run_pipeline"],
            patches["optimize"],
            patches["saturation"],
            patches["recommendations"],
            patch("httpx.post") as mock_post,
        ):
            from mixlift.api import analyze

            result = analyze(str(csv_path))

        assert result["status"] == "success"
        mock_post.assert_not_called()

    def test_too_many_channels_free_tier(self, tmp_path):
        """Exceeding FREE_MAX_CHANNELS should return an error dict."""
        n_channels = FREE_MAX_CHANNELS + 1
        channel_names = [f"ch_{i}" for i in range(n_channels)]
        csv_path = _make_csv(tmp_path, n_channels=n_channels)

        patches = _pipeline_patches()
        # Override load_wide to return more channels than the free tier allows
        wide_patch = patch(
            "mixlift.ingest.loader.load_wide_format",
            return_value=(
                pd.DataFrame(np.ones((30, n_channels)), columns=channel_names),
                pd.Series(np.ones(30)),
                channel_names,
            ),
        )

        with patches["detect"], wide_patch, patch("httpx.post"):
            from mixlift.api import analyze

            result = analyze(str(csv_path))

        assert result["status"] == "error"
        assert f"{n_channels} channels" in result["message"]
        assert "free" in result["message"]
        assert "Upgrade" in result["message"]

    def test_too_many_rows_free_tier(self, tmp_path):
        """Exceeding FREE_MAX_ROWS should return an error dict."""
        n_rows = FREE_MAX_ROWS + 1
        csv_path = _make_csv(tmp_path, n_rows=100)  # actual file doesn't matter

        patches = _pipeline_patches()
        # Override load_wide to return more rows than allowed
        wide_patch = patch(
            "mixlift.ingest.loader.load_wide_format",
            return_value=(
                pd.DataFrame(np.ones((n_rows, 2)), columns=CHANNELS),
                pd.Series(np.ones(n_rows)),
                CHANNELS,
            ),
        )

        with patches["detect"], wide_patch, patch("httpx.post"):
            from mixlift.api import analyze

            result = analyze(str(csv_path))

        assert result["status"] == "error"
        assert f"{n_rows:,} rows" in result["message"]
        assert "free" in result["message"]


class TestAnalyzeProTier:
    """Tests for analyze() with a valid pro license key."""

    def _mock_license_response(self, valid=True, tier="pro"):
        resp = MagicMock()
        resp.raise_for_status = MagicMock()
        resp.json.return_value = {"valid": valid, "tier": tier}
        return resp

    def test_valid_pro_license_uses_pro_limits(self, tmp_path):
        """A valid pro key should allow more channels/rows."""
        csv_path = _make_csv(tmp_path)
        patches = _pipeline_patches()

        with (
            patches["detect"],
            patches["load_wide"],
            patches["run_pipeline"],
            patches["optimize"],
            patches["saturation"],
            patches["recommendations"],
            patch("httpx.post", return_value=self._mock_license_response()),
        ):
            from mixlift.api import analyze

            result = analyze(str(csv_path), license_key="pro-key-123")

        assert result["status"] == "success"

    def test_pro_tier_allows_more_channels(self, tmp_path):
        """Pro tier should allow up to PRO_MAX_CHANNELS."""
        n_channels = PRO_MAX_CHANNELS
        channel_names = [f"ch_{i}" for i in range(n_channels)]
        csv_path = _make_csv(tmp_path, n_channels=n_channels)

        patches = _pipeline_patches()
        wide_patch = patch(
            "mixlift.ingest.loader.load_wide_format",
            return_value=(
                pd.DataFrame(np.ones((30, n_channels)), columns=channel_names),
                pd.Series(np.ones(30)),
                channel_names,
            ),
        )

        # Build a fake model results with the right number of channels
        mr = _fake_model_results()
        mr.roas_estimates = {ch: 2.0 for ch in channel_names}
        mr.roas_ci = {ch: (1.0, 3.0) for ch in channel_names}
        mr.channel_contributions = {ch: 1.0 / n_channels for ch in channel_names}
        mr.marginal_roas_estimates = {ch: 1.5 for ch in channel_names}
        mr.total_spend = {ch: 1000.0 for ch in channel_names}

        run_patch = patch(
            "mixlift.modeling.engine.run_full_pipeline", return_value=mr
        )
        opt_result = {
            "current_allocation": {ch: 1000.0 for ch in channel_names},
            "optimal_allocation": {ch: 1000.0 for ch in channel_names},
            "total_budget": 1000.0 * n_channels,
            "expected_lift_pct": 5.0,
        }
        opt_patch = patch(
            "mixlift.modeling.optimizer.optimize_budget", return_value=opt_result
        )

        with (
            patches["detect"],
            wide_patch,
            run_patch,
            opt_patch,
            patches["saturation"],
            patches["recommendations"],
            patch("httpx.post", return_value=self._mock_license_response()),
        ):
            from mixlift.api import analyze

            result = analyze(str(csv_path), license_key="pro-key-123")

        assert result["status"] == "success"
        assert result["data"]["n_channels"] == n_channels

    def test_invalid_license_falls_back_to_free(self, tmp_path):
        """An invalid license key should fall back to free tier limits."""
        n_channels = FREE_MAX_CHANNELS + 1
        channel_names = [f"ch_{i}" for i in range(n_channels)]
        csv_path = _make_csv(tmp_path, n_channels=n_channels)

        patches = _pipeline_patches()
        wide_patch = patch(
            "mixlift.ingest.loader.load_wide_format",
            return_value=(
                pd.DataFrame(np.ones((30, n_channels)), columns=channel_names),
                pd.Series(np.ones(30)),
                channel_names,
            ),
        )

        resp = self._mock_license_response(valid=False, tier="free")

        with (
            patches["detect"],
            wide_patch,
            patch("httpx.post", return_value=resp),
        ):
            from mixlift.api import analyze

            result = analyze(str(csv_path), license_key="bad-key")

        assert result["status"] == "error"
        assert "free" in result["message"]

    def test_growth_tier_treated_as_pro(self, tmp_path):
        """A growth-tier license should also get pro limits."""
        csv_path = _make_csv(tmp_path)
        patches = _pipeline_patches()

        with (
            patches["detect"],
            patches["load_wide"],
            patches["run_pipeline"],
            patches["optimize"],
            patches["saturation"],
            patches["recommendations"],
            patch(
                "httpx.post",
                return_value=self._mock_license_response(tier="growth"),
            ),
        ):
            from mixlift.api import analyze

            result = analyze(str(csv_path), license_key="growth-key-123")

        assert result["status"] == "success"


class TestAnalyzeLicenseAPIErrors:
    """Tests for license API failure scenarios."""

    def test_license_api_unreachable_falls_back_to_free(self, tmp_path):
        """If the license API is unreachable, analyze should use free tier."""
        csv_path = _make_csv(tmp_path)
        patches = _pipeline_patches()

        import httpx

        with (
            patches["detect"],
            patches["load_wide"],
            patches["run_pipeline"],
            patches["optimize"],
            patches["saturation"],
            patches["recommendations"],
            patch("httpx.post", side_effect=httpx.ConnectError("Connection refused")),
        ):
            from mixlift.api import analyze

            result = analyze(str(csv_path), license_key="some-key")

        # Should still succeed because free-tier limits are met (2 channels, 30 rows)
        assert result["status"] == "success"

    def test_license_api_timeout_falls_back_to_free(self, tmp_path):
        """If the license API times out, analyze should use free tier."""
        csv_path = _make_csv(tmp_path)
        patches = _pipeline_patches()

        import httpx

        with (
            patches["detect"],
            patches["load_wide"],
            patches["run_pipeline"],
            patches["optimize"],
            patches["saturation"],
            patches["recommendations"],
            patch("httpx.post", side_effect=httpx.TimeoutException("timeout")),
        ):
            from mixlift.api import analyze

            result = analyze(str(csv_path), license_key="some-key")

        assert result["status"] == "success"

    def test_license_api_500_falls_back_to_free(self, tmp_path):
        """If the license API returns 500, analyze should use free tier."""
        csv_path = _make_csv(tmp_path)
        patches = _pipeline_patches()

        resp = MagicMock()
        resp.raise_for_status.side_effect = Exception("500 Server Error")

        with (
            patches["detect"],
            patches["load_wide"],
            patches["run_pipeline"],
            patches["optimize"],
            patches["saturation"],
            patches["recommendations"],
            patch("httpx.post", return_value=resp),
        ):
            from mixlift.api import analyze

            result = analyze(str(csv_path), license_key="some-key")

        assert result["status"] == "success"


class TestAnalyzeFileErrors:
    """Tests for file-related error handling."""

    def test_file_not_found(self):
        """analyze() with a nonexistent path should return an error dict."""
        from mixlift.api import analyze

        result = analyze("/nonexistent/path/data.csv")
        assert result["status"] == "error"
        assert "File not found" in result["message"]

    def test_csv_read_failure(self, tmp_path):
        """analyze() with an unreadable/corrupt CSV should return an error dict."""
        bad_csv = tmp_path / "bad.csv"
        bad_csv.write_text("not,a,valid\x00csv\x01with\x02binary")

        # Make read_csv raise an error
        with patch("pandas.read_csv", side_effect=Exception("Could not parse")):
            from mixlift.api import analyze

            result = analyze(str(bad_csv))

        assert result["status"] == "error"
        assert "Failed to read CSV" in result["message"]

    def test_format_detection_value_error(self, tmp_path):
        """If format detection/loading raises ValueError, return error dict."""
        csv_path = _make_csv(tmp_path)

        with (
            patch(
                "mixlift.ingest.loader.detect_csv_format", return_value="wide"
            ),
            patch(
                "mixlift.ingest.loader.load_wide_format",
                side_effect=ValueError("No revenue column found"),
            ),
        ):
            from mixlift.api import analyze

            result = analyze(str(csv_path))

        assert result["status"] == "error"
        assert "No revenue column found" in result["message"]


class TestAnalyzeResultStructure:
    """Tests verifying the shape of a successful result dict."""

    def test_result_has_expected_top_level_keys(self, tmp_path):
        """A successful result should contain all expected keys."""
        csv_path = _make_csv(tmp_path)
        patches = _pipeline_patches()

        with (
            patches["detect"],
            patches["load_wide"],
            patches["run_pipeline"],
            patches["optimize"],
            patches["saturation"],
            patches["recommendations"],
            patch("httpx.post"),
        ):
            from mixlift.api import analyze

            result = analyze(str(csv_path))

        expected_keys = {
            "status",
            "data",
            "roas",
            "channel_contributions",
            "budget_optimization",
            "recommendations",
            "saturation_curves",
            "marginal_roas",
            "diagnostics",
            "model_info",
        }
        assert set(result.keys()) == expected_keys

    def test_data_section_structure(self, tmp_path):
        """The data section should contain format, channels, n_channels, n_rows."""
        csv_path = _make_csv(tmp_path)
        patches = _pipeline_patches()

        with (
            patches["detect"],
            patches["load_wide"],
            patches["run_pipeline"],
            patches["optimize"],
            patches["saturation"],
            patches["recommendations"],
            patch("httpx.post"),
        ):
            from mixlift.api import analyze

            result = analyze(str(csv_path))

        data = result["data"]
        assert data["format_detected"] == "wide"
        assert data["n_channels"] == 2
        assert data["n_rows"] == 30
        assert set(data["channels"]) == {"google", "meta"}

    def test_roas_section_has_estimates_and_ci(self, tmp_path):
        """The roas section should have estimates and confidence intervals."""
        csv_path = _make_csv(tmp_path)
        patches = _pipeline_patches()

        with (
            patches["detect"],
            patches["load_wide"],
            patches["run_pipeline"],
            patches["optimize"],
            patches["saturation"],
            patches["recommendations"],
            patch("httpx.post"),
        ):
            from mixlift.api import analyze

            result = analyze(str(csv_path))

        roas = result["roas"]
        assert "estimates" in roas
        assert "confidence_intervals" in roas
        for ch in CHANNELS:
            assert ch in roas["estimates"]
            ci = roas["confidence_intervals"][ch]
            assert "low" in ci
            assert "high" in ci

    def test_recommendations_section_structure(self, tmp_path):
        """Recommendations should have a summary and actions list."""
        csv_path = _make_csv(tmp_path)
        patches = _pipeline_patches()

        with (
            patches["detect"],
            patches["load_wide"],
            patches["run_pipeline"],
            patches["optimize"],
            patches["saturation"],
            patches["recommendations"],
            patch("httpx.post"),
        ):
            from mixlift.api import analyze

            result = analyze(str(csv_path))

        recs = result["recommendations"]
        assert "summary" in recs
        assert "actions" in recs
        assert isinstance(recs["actions"], list)
        if recs["actions"]:
            action = recs["actions"][0]
            assert set(action.keys()) == {
                "rank",
                "action",
                "channel",
                "change_amount",
                "current_spend",
                "optimal_spend",
                "roas",
                "reason",
            }

    def test_model_info_has_duration(self, tmp_path):
        """model_info should include duration_secs and total_spend."""
        csv_path = _make_csv(tmp_path)
        patches = _pipeline_patches()

        with (
            patches["detect"],
            patches["load_wide"],
            patches["run_pipeline"],
            patches["optimize"],
            patches["saturation"],
            patches["recommendations"],
            patch("httpx.post"),
        ):
            from mixlift.api import analyze

            result = analyze(str(csv_path))

        info = result["model_info"]
        assert "duration_secs" in info
        assert isinstance(info["duration_secs"], float)
        assert "total_spend" in info

    def test_diagnostics_has_convergence_warnings(self, tmp_path):
        """diagnostics should include convergence_warnings list."""
        csv_path = _make_csv(tmp_path)
        patches = _pipeline_patches()

        with (
            patches["detect"],
            patches["load_wide"],
            patches["run_pipeline"],
            patches["optimize"],
            patches["saturation"],
            patches["recommendations"],
            patch("httpx.post"),
        ):
            from mixlift.api import analyze

            result = analyze(str(csv_path))

        assert isinstance(result["diagnostics"]["convergence_warnings"], list)


class TestAnalyzeMCMCConfig:
    """Tests for MCMC config handling."""

    def test_custom_mcmc_config_is_passed_through(self, tmp_path):
        """A custom mcmc_config should be forwarded to run_full_pipeline."""
        csv_path = _make_csv(tmp_path)
        patches = _pipeline_patches()
        custom_config = {"chains": 4, "tune": 1000, "draws": 500}

        with (
            patches["detect"],
            patches["load_wide"],
            patches["run_pipeline"] as mock_pipeline,
            patches["optimize"],
            patches["saturation"],
            patches["recommendations"],
            patch("httpx.post"),
        ):
            from mixlift.api import analyze

            analyze(str(csv_path), mcmc_config=custom_config)

        _, kwargs = mock_pipeline.call_args
        assert kwargs["mcmc_config"] == custom_config

    def test_default_mcmc_config_uses_mcp_config(self, tmp_path):
        """Without mcmc_config, MCP_CONFIG default should be used."""
        csv_path = _make_csv(tmp_path)
        patches = _pipeline_patches()

        with (
            patches["detect"],
            patches["load_wide"],
            patches["run_pipeline"] as mock_pipeline,
            patches["optimize"],
            patches["saturation"],
            patches["recommendations"],
            patch("httpx.post"),
            patch("mixlift.modeling.defaults.MCP_CONFIG", {"chains": 2, "tune": 500}),
        ):
            from mixlift.api import analyze

            analyze(str(csv_path))

        _, kwargs = mock_pipeline.call_args
        assert kwargs["mcmc_config"] == {"chains": 2, "tune": 500}
