"""Integration tests exercising flow gaps through mixlift_analyze.

Covers:
1. Memory integration — first and second run produce correct memory keys
2. Scenario mode — pro tier gets projected impact; free tier gets error
3. Free tier row limit rejection
4. Exploratory badge propagation for short datasets
"""

from __future__ import annotations

import json
from pathlib import Path
from unittest.mock import AsyncMock, 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,
    LicenseStatus,
)
from mixlift_mcp.server import mixlift_analyze


# ---------------------------------------------------------------------------
# Helpers — same pattern as test_e2e.py but with full attribute coverage
# ---------------------------------------------------------------------------


def _make_mock_model_results(channel_columns: list[str]) -> tuple[MagicMock, list[str]]:
    """Build a mock that mimics the return value of run_full_pipeline."""
    rng = np.random.default_rng(42)

    display_names = [
        c.replace("_spend", "").replace("_", " ").title() for c in channel_columns
    ]

    results = MagicMock()
    results.roas_estimates = {
        name: round(rng.uniform(0.5, 5.0), 2) for name in display_names
    }
    results.roas_ci = {
        name: (round(v - 0.5, 2), round(v + 0.5, 2))
        for name, v in results.roas_estimates.items()
    }
    results.channel_contributions = {
        name: round(rng.uniform(10000, 80000), 2) for name in display_names
    }
    results.channel_contributions_ci = {
        name: (round(v * 0.8, 2), round(v * 1.2, 2))
        for name, v in results.channel_contributions.items()
    }
    results.marginal_roas_estimates = {
        name: round(rng.uniform(0.3, 3.0), 2) for name in display_names
    }
    results.marginal_roas_ci = {
        name: (round(v - 0.3, 2), round(v + 0.3, 2))
        for name, v in results.marginal_roas_estimates.items()
    }
    results.total_spend = {
        name: round(rng.uniform(5000, 20000), 2) for name in display_names
    }
    results.convergence_warnings = []
    results.duration_secs = 12.3
    results.mmm = MagicMock()
    results.raw_contributions = MagicMock()

    return results, display_names


def _make_mock_optimization(display_names: list[str]) -> dict:
    """Build a mock optimization result."""
    rng = np.random.default_rng(99)
    current = {name: round(rng.uniform(3000, 10000), 2) for name in display_names}
    optimal = {
        name: round(v * rng.uniform(0.7, 1.4), 2) for name, v in current.items()
    }
    return {
        "current_allocation": current,
        "optimal_allocation": optimal,
        "total_budget": round(sum(current.values()), 2),
        "expected_lift_pct": round(rng.uniform(5, 20), 1),
    }


def _make_mock_saturation(display_names: list[str]) -> dict:
    """Build a mock saturation curves result."""
    return {
        name: {
            "spend": [0, 1000, 2000, 5000, 10000],
            "response": [0, 800, 1400, 2200, 2800],
        }
        for name in display_names
    }


def _make_csv(tmp_path: Path, n_weeks: int, channels: list[str]) -> Path:
    """Write a synthetic wide-format CSV and return its path."""
    rng = np.random.default_rng(42)
    data: dict = {
        "date": pd.date_range("2023-01-01", periods=n_weeks, freq="W-MON"),
    }
    for ch in channels:
        data[ch] = rng.uniform(1000, 5000, n_weeks)
    data["revenue"] = rng.uniform(30000, 80000, n_weeks)

    csv_path = tmp_path / "test_data.csv"
    pd.DataFrame(data).to_csv(csv_path, index=False)
    return csv_path


def _standard_patches(license_status: LicenseStatus, channel_columns: list[str]):
    """Return the four standard patches for license + pipeline + optimizer + saturation."""
    mock_results, display_names = _make_mock_model_results(channel_columns)
    mock_opt = _make_mock_optimization(display_names)
    mock_sat = _make_mock_saturation(display_names)

    return (
        patch(
            "mixlift_mcp.server.validate_license",
            new_callable=AsyncMock,
            return_value=license_status,
        ),
        patch("mixlift_mcp.server._run_pipeline", return_value=mock_results),
        patch("mixlift_mcp.server._run_optimizer", return_value=mock_opt),
        patch("mixlift_mcp.server._run_saturation", return_value=mock_sat),
        mock_results,
        display_names,
        mock_opt,
    )


# ---------------------------------------------------------------------------
# Test 1: Memory integration through mixlift_analyze
# ---------------------------------------------------------------------------


class TestMemoryIntegration:
    """Run mixlift_analyze twice and verify the memory key evolves."""

    @pytest.fixture()
    def _csv_and_patches(self, tmp_path):
        """Create a CSV and patches shared by memory tests."""
        channels = ["google_spend", "meta_spend"]
        csv_path = _make_csv(tmp_path, n_weeks=52, channels=channels)

        pro_status = LicenseStatus(
            is_pro=True,
            max_channels=PRO_MAX_CHANNELS,
            max_rows=PRO_MAX_ROWS,
            message="Pro license",
        )
        return csv_path, channels, pro_status

    async def test_first_run_has_memory_note(self, tmp_path, _csv_and_patches):
        """First analysis should produce memory.note = 'First analysis...'."""
        csv_path, channels, pro_status = _csv_and_patches

        patches = _standard_patches(pro_status, channels)
        p_lic, p_pipe, p_opt, p_sat, _, _, _ = patches

        # Redirect MEMORY_DIR to tmp_path so we do not write to ~/.mixlift/
        mem_dir = tmp_path / "memory"
        with p_lic, p_pipe, p_opt, p_sat, patch(
            "mixlift_mcp.memory.MEMORY_DIR", mem_dir
        ):
            result = await mixlift_analyze(
                csv_path=str(csv_path), license_key="pro_key"
            )

        data = json.loads(result)
        assert data["status"] == "success", data.get("message")
        assert "memory" in data, "Result must contain a 'memory' key"
        assert "note" in data["memory"], f"First run memory should have 'note', got: {data['memory']}"
        assert "First analysis" in data["memory"]["note"]

    async def test_second_run_has_previous_analysis(self, tmp_path, _csv_and_patches):
        """Second analysis on the same CSV should show previous_analysis and changes."""
        csv_path, channels, pro_status = _csv_and_patches
        mem_dir = tmp_path / "memory"

        # --- Run 1 ---
        patches1 = _standard_patches(pro_status, channels)
        p_lic1, p_pipe1, p_opt1, p_sat1, _, _, _ = patches1

        with p_lic1, p_pipe1, p_opt1, p_sat1, patch(
            "mixlift_mcp.memory.MEMORY_DIR", mem_dir
        ):
            await mixlift_analyze(csv_path=str(csv_path), license_key="pro_key")

        # --- Run 2 ---
        patches2 = _standard_patches(pro_status, channels)
        p_lic2, p_pipe2, p_opt2, p_sat2, _, _, _ = patches2

        with p_lic2, p_pipe2, p_opt2, p_sat2, patch(
            "mixlift_mcp.memory.MEMORY_DIR", mem_dir
        ):
            result2 = await mixlift_analyze(
                csv_path=str(csv_path), license_key="pro_key"
            )

        data2 = json.loads(result2)
        assert data2["status"] == "success", data2.get("message")
        assert "memory" in data2
        mem = data2["memory"]
        assert "previous_analysis" in mem, f"Second run should have 'previous_analysis', got: {mem}"
        assert "changes" in mem, f"Second run should have 'changes', got: {mem}"
        assert isinstance(mem["changes"], dict)


# ---------------------------------------------------------------------------
# Test 2: Scenario mode through mixlift_analyze
# ---------------------------------------------------------------------------


class TestScenarioMode:
    """Scenario mode: pro tier gets projections; free tier gets error."""

    async def test_pro_scenario_returns_projected_impact(self, tmp_path):
        """Pro user running a scenario should get projected_impact_pct and CI."""
        channels = ["google_spend", "meta_spend"]
        csv_path = _make_csv(tmp_path, n_weeks=52, channels=channels)

        pro_status = LicenseStatus(
            is_pro=True,
            max_channels=PRO_MAX_CHANNELS,
            max_rows=PRO_MAX_ROWS,
            message="Pro license",
        )

        patches = _standard_patches(pro_status, channels)
        p_lic, p_pipe, p_opt, p_sat, mock_results, display_names, mock_opt = patches

        # We need to mock _estimate_response and estimate_scenario_impact_samples
        # because _run_scenario calls them with model_results.mmm.
        # _parse_scenario needs to find "meta" in current_allocation keys.
        # display_names includes "Meta" (from "meta_spend").
        impact_samples = np.array([-3.5, -4.0, -2.8, -5.1, -3.2, -4.5, -2.5, -3.9, -4.2, -3.0])

        mem_dir = tmp_path / "memory"
        with (
            p_lic,
            p_pipe,
            p_opt,
            p_sat,
            patch("mixlift_mcp.memory.MEMORY_DIR", mem_dir),
            patch(
                "mixlift.modeling.optimizer._estimate_response",
                side_effect=[100000.0, 96000.0],  # current, modified
            ),
            patch(
                "mixlift.modeling.optimizer.estimate_scenario_impact_samples",
                return_value=impact_samples,
            ),
        ):
            result = await mixlift_analyze(
                csv_path=str(csv_path),
                license_key="pro_key",
                scenario="cut Meta by 20%",
            )

        data = json.loads(result)
        assert data["status"] == "success", data.get("message")
        assert "scenario" in data, f"Pro scenario should produce 'scenario' key. Keys: {list(data.keys())}"

        scenario = data["scenario"]
        assert "projected_impact_pct" in scenario
        assert "impact_ci_80" in scenario
        assert "direction_note" in scenario
        assert isinstance(scenario["impact_ci_80"], dict)
        assert "low" in scenario["impact_ci_80"]
        assert "high" in scenario["impact_ci_80"]

    async def test_free_tier_scenario_returns_error(self, tmp_path):
        """Free tier user passing a scenario should get an upgrade error."""
        channels = ["google_spend", "meta_spend"]
        csv_path = _make_csv(tmp_path, n_weeks=52, channels=channels)

        free_status = LicenseStatus(
            is_pro=False,
            max_channels=FREE_MAX_CHANNELS,
            max_rows=FREE_MAX_ROWS,
            message="Free tier",
        )

        patches = _standard_patches(free_status, channels)
        p_lic, p_pipe, p_opt, p_sat, _, _, _ = patches

        mem_dir = tmp_path / "memory"
        with p_lic, p_pipe, p_opt, p_sat, patch(
            "mixlift_mcp.memory.MEMORY_DIR", mem_dir
        ):
            result = await mixlift_analyze(
                csv_path=str(csv_path),
                license_key="",
                scenario="cut Meta by 20%",
            )

        data = json.loads(result)
        assert data["status"] == "success", data.get("message")
        assert "scenario" in data, f"Free scenario should still produce 'scenario' key with error"
        assert "error" in data["scenario"]
        assert "Growth" in data["scenario"]["error"]


# ---------------------------------------------------------------------------
# Test 3: Free tier row limit rejection through mixlift_analyze
# ---------------------------------------------------------------------------


class TestFreeTierRowLimitRejection:
    """Mock a CSV with >2000 rows and verify free tier rejects it."""

    async def test_large_csv_rejected_on_free_tier(self, tmp_path):
        """A CSV with >2000 rows should be rejected for free tier users."""
        # Create a CSV with 2100 rows (exceeds FREE_MAX_ROWS = 2000)
        n_rows = 2100
        channels = ["google_spend", "meta_spend"]
        rng = np.random.default_rng(42)
        data = {
            "date": pd.date_range("2020-01-01", periods=n_rows, freq="D"),
        }
        for ch in channels:
            data[ch] = rng.uniform(100, 500, n_rows)
        data["revenue"] = rng.uniform(5000, 15000, n_rows)

        csv_path = tmp_path / "large_data.csv"
        pd.DataFrame(data).to_csv(csv_path, index=False)

        free_status = LicenseStatus(
            is_pro=False,
            max_channels=FREE_MAX_CHANNELS,
            max_rows=FREE_MAX_ROWS,
            message="Free tier",
        )

        with patch(
            "mixlift_mcp.server.validate_license",
            new_callable=AsyncMock,
            return_value=free_status,
        ):
            result = await mixlift_analyze(csv_path=str(csv_path))

        data_out = json.loads(result)
        assert data_out["status"] == "error"
        assert "row" in data_out["message"].lower()
        assert str(FREE_MAX_ROWS) in data_out["message"]


# ---------------------------------------------------------------------------
# Test 4: Exploratory badge propagation through mixlift_analyze
# ---------------------------------------------------------------------------


class TestExploratoryBadge:
    """Short datasets (<26 weeks) should produce an exploratory badge."""

    async def test_short_dataset_gets_exploratory_badge(self, tmp_path):
        """A CSV spanning <26 weeks should result in analysis_badge.type == 'exploratory'."""
        channels = ["google_spend", "meta_spend"]
        # 20 weeks of data — well under the 26-week threshold
        csv_path = _make_csv(tmp_path, n_weeks=20, channels=channels)

        pro_status = LicenseStatus(
            is_pro=True,
            max_channels=PRO_MAX_CHANNELS,
            max_rows=PRO_MAX_ROWS,
            message="Pro license",
        )

        patches = _standard_patches(pro_status, channels)
        p_lic, p_pipe, p_opt, p_sat, _, _, _ = patches

        mem_dir = tmp_path / "memory"
        with p_lic, p_pipe, p_opt, p_sat, patch(
            "mixlift_mcp.memory.MEMORY_DIR", mem_dir
        ):
            result = await mixlift_analyze(
                csv_path=str(csv_path), license_key="pro_key"
            )

        data = json.loads(result)
        assert data["status"] == "success", data.get("message")
        badge = data.get("analysis_badge")
        assert badge is not None, f"Short dataset should produce an analysis_badge. Keys: {list(data.keys())}"
        assert badge["type"] == "exploratory"
        assert "label" in badge
        assert "note" in badge
        assert "26 weeks" in badge["note"]

    async def test_long_dataset_has_no_exploratory_badge(self, tmp_path):
        """A CSV spanning >=26 weeks should NOT have an exploratory badge."""
        channels = ["google_spend", "meta_spend"]
        csv_path = _make_csv(tmp_path, n_weeks=52, channels=channels)

        pro_status = LicenseStatus(
            is_pro=True,
            max_channels=PRO_MAX_CHANNELS,
            max_rows=PRO_MAX_ROWS,
            message="Pro license",
        )

        patches = _standard_patches(pro_status, channels)
        p_lic, p_pipe, p_opt, p_sat, _, _, _ = patches

        mem_dir = tmp_path / "memory"
        with p_lic, p_pipe, p_opt, p_sat, patch(
            "mixlift_mcp.memory.MEMORY_DIR", mem_dir
        ):
            result = await mixlift_analyze(
                csv_path=str(csv_path), license_key="pro_key"
            )

        data = json.loads(result)
        assert data["status"] == "success", data.get("message")
        assert data.get("analysis_badge") is None, (
            f"52-week dataset should NOT have an exploratory badge, got: {data.get('analysis_badge')}"
        )
