"""Tests for MixLift MCP server tool."""

import json
import os
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 LicenseStatus
from mixlift_mcp.server import (
    DEMO_CSV_PATH,
    _build_result,
    _detect_csv_format,
    _enforce_tier_limits,
    _load_wide_format,
    _run_pipeline,
    _run_optimizer,
    _run_saturation,
    _validate_csv_path,
    mixlift_analyze,
)


# --- Fixtures ---


@pytest.fixture
def wide_csv(tmp_path):
    """Create a small wide-format CSV for testing."""
    n_weeks = 30
    dates = pd.date_range("2023-06-01", periods=n_weeks, freq="W-MON")
    rng = np.random.default_rng(42)
    df = pd.DataFrame({
        "date": dates,
        "google_spend": rng.uniform(1000, 5000, n_weeks),
        "meta_spend": rng.uniform(2000, 8000, n_weeks),
        "revenue": rng.uniform(30000, 80000, n_weeks),
    })
    path = tmp_path / "wide_test.csv"
    df.to_csv(path, index=False)
    return str(path)


@pytest.fixture
def generic_csv(tmp_path):
    """Create a generic (long-format) CSV for testing."""
    dates = pd.date_range("2023-01-01", periods=210, freq="D")
    channels = ["Search", "Social"]
    rows = []
    for date in dates:
        for ch in channels:
            rows.append({
                "date": date,
                "channel": ch,
                "spend": 100 + hash(f"{date}{ch}") % 200,
                "impressions": 5000,
                "clicks": 100,
                "conversions": 5,
            })
    df = pd.DataFrame(rows)
    path = tmp_path / "generic_test.csv"
    df.to_csv(path, index=False)
    return str(path)


@pytest.fixture
def four_channel_csv(tmp_path):
    """Create a wide CSV with 4 channels (exceeds free tier)."""
    n_weeks = 30
    dates = pd.date_range("2023-06-01", periods=n_weeks, freq="W-MON")
    rng = np.random.default_rng(42)
    df = pd.DataFrame({
        "date": dates,
        "google_spend": rng.uniform(1000, 5000, n_weeks),
        "meta_spend": rng.uniform(2000, 8000, n_weeks),
        "tiktok_spend": rng.uniform(500, 2000, n_weeks),
        "email_spend": rng.uniform(100, 500, n_weeks),
        "revenue": rng.uniform(30000, 80000, n_weeks),
    })
    path = tmp_path / "four_channels.csv"
    df.to_csv(path, index=False)
    return str(path)


@pytest.fixture
def mock_free_license():
    return LicenseStatus(
        is_pro=False, max_channels=2, max_rows=2000, message="Free tier"
    )


@pytest.fixture
def mock_pro_license():
    return LicenseStatus(
        is_pro=True, max_channels=999, max_rows=999_999, message="Pro"
    )


# --- Format detection tests ---


class TestDetectCsvFormat:
    def test_wide_format(self):
        df = pd.DataFrame({
            "date": ["2024-01-01"],
            "google_spend": [100],
            "meta_spend": [200],
            "revenue": [5000],
        })
        assert _detect_csv_format(df) == "wide"

    def test_generic_format(self):
        df = pd.DataFrame({
            "date": ["2024-01-01"],
            "channel": ["Search"],
            "spend": [100],
        })
        assert _detect_csv_format(df) == "generic"

    def test_meta_format(self):
        df = pd.DataFrame({
            "Date": ["2024-01-01"],
            "Campaign Name": ["Test"],
            "Amount Spent (USD)": [100],
            "Impressions": [5000],
            "Link Clicks": [200],
        })
        assert _detect_csv_format(df) == "meta"

    def test_wide_needs_revenue(self):
        """Wide format requires a revenue/target column."""
        df = pd.DataFrame({
            "date": ["2024-01-01"],
            "google_spend": [100],
            "meta_spend": [200],
        })
        # Without revenue, it won't detect as wide
        result = _detect_csv_format(df)
        assert result != "wide"


# --- Wide format loading ---


class TestLoadWideFormat:
    def test_loads_correctly(self, wide_csv):
        df = pd.read_csv(wide_csv)
        X, y, channels = _load_wide_format(df)

        assert len(X) == 30
        assert len(y) == 30
        assert len(channels) == 2
        assert all(c.endswith("_spend") for c in channels)
        assert "trend" in X.columns
        assert "date" in X.columns

    def test_missing_revenue_raises(self):
        df = pd.DataFrame({
            "date": ["2024-01-01"],
            "google_spend": [100],
            "meta_spend": [200],
        })
        with pytest.raises(ValueError, match="revenue"):
            _load_wide_format(df)


# --- Tier limit enforcement ---


class TestEnforceTierLimits:
    def test_within_limits(self, mock_free_license):
        # Should not raise
        _enforce_tier_limits(mock_free_license, n_channels=2, n_rows=500)

    def test_exceeds_channels(self, mock_free_license):
        with pytest.raises(ValueError, match="channels"):
            _enforce_tier_limits(mock_free_license, n_channels=5, n_rows=100)

    def test_exceeds_rows(self, mock_free_license):
        with pytest.raises(ValueError, match="rows"):
            _enforce_tier_limits(mock_free_license, n_channels=2, n_rows=3000)

    def test_pro_allows_more(self, mock_pro_license):
        # Should not raise
        _enforce_tier_limits(mock_pro_license, n_channels=10, n_rows=50000)

    def test_error_includes_upgrade_link(self, mock_free_license):
        with pytest.raises(ValueError, match="mixlift.io/pricing"):
            _enforce_tier_limits(mock_free_license, n_channels=5, n_rows=100)


# --- MCP tool integration tests ---


class TestMixliftAnalyze:
    @pytest.mark.asyncio
    async def test_missing_file_returns_error(self):
        with patch("mixlift_mcp.server.validate_license", new_callable=AsyncMock) as mock_lic:
            mock_lic.return_value = LicenseStatus(False, 2, 2000, "Free")
            result = await mixlift_analyze(csv_path="/nonexistent/file.csv")

        data = json.loads(result)
        assert data["status"] == "error"
        assert "not found" in data["message"].lower()

    @pytest.mark.asyncio
    async def test_demo_data_loads(self):
        """Test that calling with no path uses demo data (only checks loading, not modeling)."""
        # We mock the modeling to avoid running actual MCMC
        mock_model_results = MagicMock()
        mock_model_results.roas_estimates = {"Google Search": 3.0, "Meta": 1.5}
        mock_model_results.roas_ci = {
            "Google Search": (2.0, 4.0), "Meta": (1.0, 2.0)
        }
        mock_model_results.channel_contributions = {"Google Search": 50000, "Meta": 30000}
        mock_model_results.channel_contributions_ci = {
            "Google Search": (40000, 60000), "Meta": (20000, 40000)
        }
        mock_model_results.total_spend = {"Google Search": 15000, "Meta": 20000}
        mock_model_results.duration_secs = 10.5
        mock_model_results.mmm = MagicMock()

        mock_optimization = {
            "current_allocation": {"Google Search": 5000, "Meta": 8000},
            "optimal_allocation": {"Google Search": 7000, "Meta": 6000},
            "total_budget": 13000,
            "expected_lift_pct": 12.5,
        }

        mock_saturation = {
            "Google Search": {"spend": [0, 100], "response": [0, 50]},
            "Meta": {"spend": [0, 100], "response": [0, 30]},
        }

        with (
            patch("mixlift_mcp.server.validate_license", new_callable=AsyncMock) as mock_lic,
            patch("mixlift_mcp.server._run_pipeline") as mock_pipeline,
            patch("mixlift_mcp.server._run_optimizer") as mock_opt,
            patch("mixlift_mcp.server._run_saturation") as mock_sat,
        ):
            mock_lic.return_value = LicenseStatus(True, 999, 999_999, "Pro")
            mock_pipeline.return_value = mock_model_results
            mock_opt.return_value = mock_optimization
            mock_sat.return_value = mock_saturation

            result = await mixlift_analyze(csv_path="", license_key="pro-key")

        data = json.loads(result)
        assert data["status"] == "success"
        assert "roas" in data
        assert "budget_optimization" in data
        assert "saturation_analysis" in data
        assert "recommendations" in data

    @pytest.mark.asyncio
    async def test_free_tier_channel_limit(self, four_channel_csv):
        """Free tier should reject >2 channels."""
        with patch("mixlift_mcp.server.validate_license", new_callable=AsyncMock) as mock_lic:
            mock_lic.return_value = LicenseStatus(False, 2, 2000, "Free")
            result = await mixlift_analyze(csv_path=four_channel_csv)

        data = json.loads(result)
        assert data["status"] == "error"
        assert "channels" in data["message"].lower()

    @pytest.mark.asyncio
    async def test_pro_tier_allows_many_channels(self, four_channel_csv):
        """Pro tier should allow >2 channels."""
        mock_model_results = MagicMock()
        mock_model_results.roas_estimates = {"A": 1.0, "B": 2.0, "C": 3.0, "D": 4.0}
        mock_model_results.roas_ci = {
            "A": (0.5, 1.5), "B": (1.5, 2.5), "C": (2.5, 3.5), "D": (3.5, 4.5)
        }
        mock_model_results.channel_contributions = {"A": 1000, "B": 2000, "C": 3000, "D": 4000}
        mock_model_results.channel_contributions_ci = {
            "A": (500, 1500), "B": (1500, 2500), "C": (2500, 3500), "D": (3500, 4500)
        }
        mock_model_results.total_spend = {"A": 1000, "B": 2000, "C": 3000, "D": 4000}
        mock_model_results.duration_secs = 5.0
        mock_model_results.mmm = MagicMock()

        mock_optimization = {
            "current_allocation": {"A": 1000, "B": 2000, "C": 3000, "D": 4000},
            "optimal_allocation": {"A": 1500, "B": 2500, "C": 2500, "D": 3500},
            "total_budget": 10000,
            "expected_lift_pct": 8.0,
        }

        mock_saturation = {
            "A": {"spend": [0], "response": [0]},
            "B": {"spend": [0], "response": [0]},
            "C": {"spend": [0], "response": [0]},
            "D": {"spend": [0], "response": [0]},
        }

        with (
            patch("mixlift_mcp.server.validate_license", new_callable=AsyncMock) as mock_lic,
            patch("mixlift_mcp.server._run_pipeline") as mock_pipeline,
            patch("mixlift_mcp.server._run_optimizer") as mock_opt,
            patch("mixlift_mcp.server._run_saturation") as mock_sat,
        ):
            mock_lic.return_value = LicenseStatus(True, 999, 999_999, "Pro")
            mock_pipeline.return_value = mock_model_results
            mock_opt.return_value = mock_optimization
            mock_sat.return_value = mock_saturation

            result = await mixlift_analyze(csv_path=four_channel_csv, license_key="pro")

        data = json.loads(result)
        assert data["status"] == "success"

    @pytest.mark.asyncio
    async def test_invalid_csv_returns_error(self, tmp_path):
        """Test that a malformed CSV returns an error."""
        bad_file = tmp_path / "bad.csv"
        bad_file.write_text("not,valid,csv\nwith,no,dates\n")

        with patch("mixlift_mcp.server.validate_license", new_callable=AsyncMock) as mock_lic:
            mock_lic.return_value = LicenseStatus(False, 2, 2000, "Free")
            result = await mixlift_analyze(csv_path=str(bad_file))

        data = json.loads(result)
        assert data["status"] == "error"

    @pytest.mark.asyncio
    async def test_demo_csv_exists(self):
        """Verify the bundled demo CSV is present."""
        assert DEMO_CSV_PATH.exists(), f"Demo CSV not found at {DEMO_CSV_PATH}"
        df = pd.read_csv(DEMO_CSV_PATH)
        assert len(df) > 0
        spend_cols = [c for c in df.columns if c.endswith("_spend")]
        assert len(spend_cols) >= 2
        assert "revenue" in df.columns


# --- Path validation / security tests ---


class TestValidateCsvPath:
    """Unit tests for _validate_csv_path guardrails."""

    # -- Extension checks --

    def test_csv_extension_allowed(self, tmp_path):
        f = tmp_path / "data.csv"
        f.touch()
        resolved, err = _validate_csv_path(str(f))
        assert err is None
        assert resolved == f.resolve()

    def test_tsv_extension_allowed(self, tmp_path):
        f = tmp_path / "data.tsv"
        f.touch()
        resolved, err = _validate_csv_path(str(f))
        assert err is None

    def test_txt_extension_allowed(self, tmp_path):
        f = tmp_path / "data.txt"
        f.touch()
        resolved, err = _validate_csv_path(str(f))
        assert err is None

    def test_disallowed_extension_py(self, tmp_path):
        f = tmp_path / "evil.py"
        f.touch()
        _, err = _validate_csv_path(str(f))
        assert err is not None
        assert ".py" in err or "extension" in err.lower()

    def test_disallowed_extension_json(self, tmp_path):
        f = tmp_path / "data.json"
        f.touch()
        _, err = _validate_csv_path(str(f))
        assert err is not None
        assert "extension" in err.lower()

    def test_disallowed_extension_no_extension(self, tmp_path):
        f = tmp_path / "passwd"
        f.touch()
        _, err = _validate_csv_path(str(f))
        assert err is not None

    # -- Sensitive-path checks --

    def test_blocks_ssh_path(self, tmp_path):
        # Simulate a path that, after resolution, contains '.ssh'
        ssh_dir = tmp_path / ".ssh"
        ssh_dir.mkdir()
        key = ssh_dir / "id_rsa.csv"
        key.touch()
        _, err = _validate_csv_path(str(key))
        assert err is not None
        assert "restricted" in err.lower() or "denied" in err.lower()

    def test_blocks_env_file(self, tmp_path):
        env_dir = tmp_path / ".env"
        env_dir.mkdir()
        f = env_dir / "secrets.csv"
        f.touch()
        _, err = _validate_csv_path(str(f))
        assert err is not None

    def test_blocks_aws_credentials(self, tmp_path):
        aws_dir = tmp_path / ".aws"
        aws_dir.mkdir()
        f = aws_dir / "credentials.csv"
        f.touch()
        _, err = _validate_csv_path(str(f))
        assert err is not None

    def test_blocks_git_dir(self, tmp_path):
        git_dir = tmp_path / ".git"
        git_dir.mkdir()
        f = git_dir / "config.csv"
        f.touch()
        _, err = _validate_csv_path(str(f))
        assert err is not None

    def test_blocks_file_named_credentials(self, tmp_path):
        f = tmp_path / "credentials.csv"
        f.touch()
        _, err = _validate_csv_path(str(f))
        assert err is not None

    def test_blocks_file_named_secrets(self, tmp_path):
        f = tmp_path / "secrets.csv"
        f.touch()
        _, err = _validate_csv_path(str(f))
        assert err is not None

    def test_normal_csv_not_blocked(self, tmp_path):
        f = tmp_path / "marketing_data.csv"
        f.touch()
        resolved, err = _validate_csv_path(str(f))
        assert err is None
        assert resolved.name == "marketing_data.csv"

    # -- Allowed-directories enforcement --

    def test_allowed_dirs_accepts_file_inside(self, tmp_path, monkeypatch):
        allowed = tmp_path / "uploads"
        allowed.mkdir()
        f = allowed / "data.csv"
        f.touch()
        monkeypatch.setenv("MIXLIFT_ALLOWED_DIRS", str(allowed))
        resolved, err = _validate_csv_path(str(f))
        assert err is None

    def test_allowed_dirs_rejects_file_outside(self, tmp_path, monkeypatch):
        allowed = tmp_path / "uploads"
        allowed.mkdir()
        outside = tmp_path / "other"
        outside.mkdir()
        f = outside / "data.csv"
        f.touch()
        monkeypatch.setenv("MIXLIFT_ALLOWED_DIRS", str(allowed))
        _, err = _validate_csv_path(str(f))
        assert err is not None
        assert "allowed" in err.lower()

    def test_allowed_dirs_multiple_dirs(self, tmp_path, monkeypatch):
        dir_a = tmp_path / "a"
        dir_b = tmp_path / "b"
        dir_a.mkdir()
        dir_b.mkdir()
        f = dir_b / "data.csv"
        f.touch()
        monkeypatch.setenv("MIXLIFT_ALLOWED_DIRS", f"{dir_a}:{dir_b}")
        resolved, err = _validate_csv_path(str(f))
        assert err is None

    def test_allowed_dirs_empty_env_skips_check(self, tmp_path, monkeypatch):
        monkeypatch.setenv("MIXLIFT_ALLOWED_DIRS", "")
        f = tmp_path / "data.csv"
        f.touch()
        resolved, err = _validate_csv_path(str(f))
        assert err is None

    def test_allowed_dirs_not_set_skips_check(self, tmp_path, monkeypatch):
        monkeypatch.delenv("MIXLIFT_ALLOWED_DIRS", raising=False)
        f = tmp_path / "data.csv"
        f.touch()
        resolved, err = _validate_csv_path(str(f))
        assert err is None


# --- Security integration tests via mixlift_analyze ---


class TestMixliftAnalyzeSecurity:
    """Verify that mixlift_analyze rejects dangerous paths end-to-end."""

    @pytest.mark.asyncio
    async def test_rejects_disallowed_extension(self, tmp_path):
        evil = tmp_path / "evil.py"
        evil.write_text("import os\n")
        with patch("mixlift_mcp.server.validate_license", new_callable=AsyncMock) as mock_lic:
            mock_lic.return_value = LicenseStatus(False, 2, 2000, "Free")
            result = await mixlift_analyze(csv_path=str(evil))
        data = json.loads(result)
        assert data["status"] == "error"
        assert "extension" in data["message"].lower()

    @pytest.mark.asyncio
    async def test_rejects_ssh_path(self, tmp_path):
        ssh_dir = tmp_path / ".ssh"
        ssh_dir.mkdir()
        key = ssh_dir / "id_rsa.csv"
        key.write_text("fake,key\n1,2\n")
        with patch("mixlift_mcp.server.validate_license", new_callable=AsyncMock) as mock_lic:
            mock_lic.return_value = LicenseStatus(False, 2, 2000, "Free")
            result = await mixlift_analyze(csv_path=str(key))
        data = json.loads(result)
        assert data["status"] == "error"
        assert "denied" in data["message"].lower()

    @pytest.mark.asyncio
    async def test_rejects_path_outside_allowed_dirs(self, tmp_path, monkeypatch):
        allowed = tmp_path / "safe"
        allowed.mkdir()
        outside = tmp_path / "unsafe"
        outside.mkdir()
        f = outside / "data.csv"
        f.write_text("col\n1\n")
        monkeypatch.setenv("MIXLIFT_ALLOWED_DIRS", str(allowed))
        with patch("mixlift_mcp.server.validate_license", new_callable=AsyncMock) as mock_lic:
            mock_lic.return_value = LicenseStatus(False, 2, 2000, "Free")
            result = await mixlift_analyze(csv_path=str(f))
        data = json.loads(result)
        assert data["status"] == "error"
        assert "allowed" in data["message"].lower()
