"""End-to-end integration tests for the MixLift MCP server.

Tests the full flow from MCP tool call through modeling to license validation,
with the actual MCMC pipeline mocked out for speed.
"""

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,
    validate_license,
)
from mixlift_mcp.server import DEMO_CSV_PATH, mixlift_analyze


# ---------------------------------------------------------------------------
# Helpers: realistic mock objects that match the real modeling output shape
# ---------------------------------------------------------------------------


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

    # Strip _spend suffix for display names (matches real behavior)
    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.total_spend = {name: round(rng.uniform(5000, 20000), 2) for name in display_names}
    results.duration_secs = 12.3
    results.mmm = 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 _patch_pipeline_and_license(license_status: LicenseStatus, channel_columns: list[str]):
    """Context manager that patches license validation and the full modeling pipeline."""
    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),
    )


# ---------------------------------------------------------------------------
# Test 1: Full free-tier flow with demo data (mocked model)
# ---------------------------------------------------------------------------


class TestFreeTierDemoFlow:
    """Full end-to-end: free tier + demo data + mocked model."""

    @pytest.mark.asyncio
    async def test_free_tier_demo_data_rejected(self):
        """Demo data has 3 channels, free tier allows 2 -- should be rejected."""
        free_status = LicenseStatus(
            is_pro=False,
            max_channels=FREE_MAX_CHANNELS,
            max_rows=FREE_MAX_ROWS,
            message="No license key provided",
        )

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

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

    @pytest.mark.asyncio
    async def test_free_tier_two_channel_data_succeeds(self):
        """Free tier allows 2 channels -- 2-channel data should succeed."""
        free_status = LicenseStatus(
            is_pro=False,
            max_channels=FREE_MAX_CHANNELS,
            max_rows=FREE_MAX_ROWS,
            message="No license key provided",
        )

        two_channels = ["google_search_spend", "meta_prospecting_spend"]
        patches = _patch_pipeline_and_license(free_status, two_channels)

        # Also patch _load_wide_format so demo CSV (3 channels) appears as 2 channels
        rng = np.random.default_rng(42)
        n = 104
        mock_X = pd.DataFrame({
            "date": pd.date_range("2023-01-01", periods=n, freq="W-MON"),
            "google_search_spend": rng.uniform(1000, 5000, n),
            "meta_prospecting_spend": rng.uniform(2000, 8000, n),
        })
        mock_y = pd.Series(rng.uniform(30000, 80000, n), name="revenue")
        mock_load = patch(
            "mixlift_mcp.server._load_wide_format",
            return_value=(mock_X, mock_y, two_channels),
        )

        with patches[0], patches[1], patches[2], patches[3], mock_load:
            result = await mixlift_analyze(csv_path="", license_key="")

        data = json.loads(result)
        assert data["status"] == "success", f"Expected success but got: {data.get('message', data)}"

        # Verify license info reflects free tier
        assert data["license"]["tier"] == "free"

        # Verify all required result sections are present
        assert "roas" in data
        assert "channel_contributions" in data
        assert "budget_optimization" in data
        assert "recommendations" in data
        assert "saturation_analysis" in data
        assert "model_info" in data

    @pytest.mark.asyncio
    async def test_free_tier_validate_license_called_with_none(self):
        """Verify validate_license is called with None when no key is provided."""
        free_status = LicenseStatus(False, FREE_MAX_CHANNELS, FREE_MAX_ROWS, "Free")

        with patch("mixlift_mcp.server.validate_license", new_callable=AsyncMock, return_value=free_status) as mock_lic:
            await mixlift_analyze(csv_path="", license_key="")
            mock_lic.assert_called_once_with(None)


# ---------------------------------------------------------------------------
# Test 2: Full pro-tier flow with demo data (mocked model)
# ---------------------------------------------------------------------------


class TestProTierDemoFlow:
    """Full end-to-end: pro tier + demo data + mocked model."""

    @pytest.mark.asyncio
    async def test_pro_tier_demo_data_succeeds(self):
        """Pro tier with a license key -- full results returned."""
        pro_status = LicenseStatus(
            is_pro=True,
            max_channels=PRO_MAX_CHANNELS,
            max_rows=PRO_MAX_ROWS,
            message="Pro license validated",
        )

        demo_channels = ["google_search_spend", "meta_prospecting_spend", "tiktok_spend"]
        patches = _patch_pipeline_and_license(pro_status, demo_channels)

        with patches[0], patches[1], patches[2], patches[3]:
            result = await mixlift_analyze(csv_path="", license_key="MIXLIFT-test-pro-key")

        data = json.loads(result)
        assert data["status"] == "success"
        assert data["license"]["tier"] == "pro"
        assert data["license"]["message"] == "Pro license validated"

        # All top-level result keys must be present
        expected_keys = {
            "status", "license", "data", "roas", "channel_contributions",
            "budget_optimization", "recommendations", "saturation_analysis", "model_info",
        }
        assert expected_keys.issubset(data.keys())

    @pytest.mark.asyncio
    async def test_pro_tier_validate_license_called_with_key(self):
        """Verify validate_license is called with the actual key string."""
        pro_status = LicenseStatus(True, PRO_MAX_CHANNELS, PRO_MAX_ROWS, "Pro")
        demo_channels = ["google_search_spend", "meta_prospecting_spend", "tiktok_spend"]
        patches = _patch_pipeline_and_license(pro_status, demo_channels)

        with patches[0] as mock_lic, patches[1], patches[2], patches[3]:
            await mixlift_analyze(csv_path="", license_key="MIXLIFT-abc-123")
            mock_lic.assert_called_once_with("MIXLIFT-abc-123")

    @pytest.mark.asyncio
    async def test_pro_tier_no_tier_limit_issues(self):
        """Pro tier should never hit channel or row limits with demo data."""
        pro_status = LicenseStatus(True, PRO_MAX_CHANNELS, PRO_MAX_ROWS, "Pro")
        demo_channels = ["google_search_spend", "meta_prospecting_spend", "tiktok_spend"]
        patches = _patch_pipeline_and_license(pro_status, demo_channels)

        with patches[0], patches[1], patches[2], patches[3]:
            result = await mixlift_analyze(csv_path="", license_key="key")

        data = json.loads(result)
        # If tier limits were hit, status would be "error"
        assert data["status"] == "success"


# ---------------------------------------------------------------------------
# Test 3: Free tier rejection -- too many channels
# ---------------------------------------------------------------------------


class TestFreeTierChannelRejection:
    """Free tier must reject data with more channels than allowed."""

    @pytest.mark.asyncio
    async def test_five_channel_csv_rejected(self, tmp_path):
        """5-channel wide CSV exceeds free tier limit of 2."""
        n_weeks = 30
        rng = np.random.default_rng(42)
        df = pd.DataFrame({
            "date": pd.date_range("2023-06-01", periods=n_weeks, freq="W-MON"),
            "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),
            "radio_spend": rng.uniform(200, 800, n_weeks),
            "revenue": rng.uniform(30000, 80000, n_weeks),
        })
        csv_path = tmp_path / "five_channels.csv"
        df.to_csv(csv_path, index=False)

        free_status = LicenseStatus(False, FREE_MAX_CHANNELS, FREE_MAX_ROWS, "Free")

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

        data = json.loads(result)
        assert data["status"] == "error"
        assert "channel" in data["message"].lower()
        assert str(FREE_MAX_CHANNELS) in data["message"]
        assert "5" in data["message"]

    @pytest.mark.asyncio
    async def test_error_includes_upgrade_link(self, tmp_path):
        """Channel limit error must mention the upgrade URL."""
        n_weeks = 30
        rng = np.random.default_rng(42)
        df = pd.DataFrame({
            "date": pd.date_range("2023-06-01", periods=n_weeks, freq="W-MON"),
            "a_spend": rng.uniform(100, 500, n_weeks),
            "b_spend": rng.uniform(100, 500, n_weeks),
            "c_spend": rng.uniform(100, 500, n_weeks),
            "d_spend": rng.uniform(100, 500, n_weeks),
            "revenue": rng.uniform(5000, 15000, n_weeks),
        })
        csv_path = tmp_path / "four_channels.csv"
        df.to_csv(csv_path, index=False)

        free_status = LicenseStatus(False, FREE_MAX_CHANNELS, FREE_MAX_ROWS, "Free")

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

        data = json.loads(result)
        assert data["status"] == "error"
        assert "mixlift.io/pricing" in data["message"]

    @pytest.mark.asyncio
    async def test_exactly_two_channels_allowed(self, tmp_path):
        """Exactly 2 channels should pass free tier limits (boundary test)."""
        n_weeks = 30
        rng = np.random.default_rng(42)
        df = pd.DataFrame({
            "date": pd.date_range("2023-06-01", periods=n_weeks, freq="W-MON"),
            "a_spend": rng.uniform(100, 500, n_weeks),
            "b_spend": rng.uniform(100, 500, n_weeks),
            "revenue": rng.uniform(5000, 15000, n_weeks),
        })
        csv_path = tmp_path / "two_channels.csv"
        df.to_csv(csv_path, index=False)

        free_status = LicenseStatus(False, FREE_MAX_CHANNELS, FREE_MAX_ROWS, "Free")
        channels = ["a_spend", "b_spend"]
        patches = _patch_pipeline_and_license(free_status, channels)

        with patches[0], patches[1], patches[2], patches[3]:
            result = await mixlift_analyze(csv_path=str(csv_path))

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


# ---------------------------------------------------------------------------
# Test 4: License API contract test
# ---------------------------------------------------------------------------


class TestLicenseAPIContract:
    """Verify the JSON contract between the MCP license.py client and the API server.

    The MCP client (license.py) expects:
      POST /v1/license/validate
      Request:  {"license_key": "..."}
      Response: {"valid": bool, "tier": str, "message": str}

    The client checks:
      - data.get("valid") is truthy
      - data.get("tier") == "pro"

    These tests validate that a response matching the API's actual schema
    is correctly interpreted by the MCP client code.
    """

    @pytest.mark.asyncio
    async def test_valid_pro_response_grants_pro(self):
        """API response with valid=True, tier=pro should grant pro access."""
        api_response = {"valid": True, "tier": "pro", "message": "License active"}

        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = api_response
        mock_response.raise_for_status = MagicMock()

        mock_client = AsyncMock()
        mock_client.post = AsyncMock(return_value=mock_response)
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=False)

        with patch("mixlift_mcp.license.httpx.AsyncClient", return_value=mock_client):
            status = await validate_license("MIXLIFT-test-key")

        assert status.is_pro is True
        assert status.max_channels == PRO_MAX_CHANNELS
        assert status.max_rows == PRO_MAX_ROWS

    @pytest.mark.asyncio
    async def test_invalid_key_response_returns_free(self):
        """API response with valid=False should return free tier."""
        api_response = {"valid": False, "tier": "free", "message": "Invalid license key"}

        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = api_response
        mock_response.raise_for_status = MagicMock()

        mock_client = AsyncMock()
        mock_client.post = AsyncMock(return_value=mock_response)
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=False)

        with patch("mixlift_mcp.license.httpx.AsyncClient", return_value=mock_client):
            status = await validate_license("MIXLIFT-bad-key")

        assert status.is_pro is False
        assert status.max_channels == FREE_MAX_CHANNELS
        assert status.message == "Invalid license key"

    @pytest.mark.asyncio
    async def test_cancelled_license_returns_free(self):
        """API response for cancelled license should return free tier."""
        api_response = {"valid": False, "tier": "free", "message": "License has been cancelled"}

        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = api_response
        mock_response.raise_for_status = MagicMock()

        mock_client = AsyncMock()
        mock_client.post = AsyncMock(return_value=mock_response)
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=False)

        with patch("mixlift_mcp.license.httpx.AsyncClient", return_value=mock_client):
            status = await validate_license("MIXLIFT-cancelled")

        assert status.is_pro is False

    @pytest.mark.asyncio
    async def test_api_unreachable_falls_back_to_free(self):
        """When the license API is unreachable, gracefully fall back to free tier."""
        mock_client = AsyncMock()
        mock_client.post = AsyncMock(side_effect=Exception("Connection refused"))
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=False)

        with patch("mixlift_mcp.license.httpx.AsyncClient", return_value=mock_client):
            status = await validate_license("MIXLIFT-any-key")

        assert status.is_pro is False
        assert "unavailable" in status.message.lower() or "free" in status.message.lower()

    @pytest.mark.asyncio
    async def test_no_key_skips_api_call(self):
        """No license key should skip the API call entirely."""
        with patch("mixlift_mcp.license.httpx.AsyncClient") as mock_cls:
            status = await validate_license(None)
            mock_cls.assert_not_called()

        assert status.is_pro is False
        assert status.max_channels == FREE_MAX_CHANNELS

    @pytest.mark.asyncio
    async def test_request_sends_correct_payload(self):
        """Verify the MCP client sends the exact JSON payload the API expects."""
        api_response = {"valid": True, "tier": "pro", "message": "License active"}

        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = api_response
        mock_response.raise_for_status = MagicMock()

        mock_client = AsyncMock()
        mock_client.post = AsyncMock(return_value=mock_response)
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=False)

        with patch("mixlift_mcp.license.httpx.AsyncClient", return_value=mock_client):
            await validate_license("MIXLIFT-check-payload")

        # Verify the POST was made with the correct URL and JSON body
        mock_client.post.assert_called_once_with(
            "https://api.mixlift.io/v1/license/validate",
            json={"license_key": "MIXLIFT-check-payload"},
        )

    @pytest.mark.asyncio
    async def test_response_missing_tier_field_returns_free(self):
        """If the API response is missing 'tier', client should treat as free.

        This catches contract mismatches where the API schema changes.
        """
        api_response = {"valid": True, "message": "License active"}  # missing "tier"

        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = api_response
        mock_response.raise_for_status = MagicMock()

        mock_client = AsyncMock()
        mock_client.post = AsyncMock(return_value=mock_response)
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=False)

        with patch("mixlift_mcp.license.httpx.AsyncClient", return_value=mock_client):
            status = await validate_license("MIXLIFT-no-tier")

        # Without tier=="pro", should fall back to free
        assert status.is_pro is False

    @pytest.mark.asyncio
    async def test_response_missing_valid_field_returns_free(self):
        """If the API response is missing 'valid', client should treat as free."""
        api_response = {"tier": "pro", "message": "License active"}  # missing "valid"

        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = api_response
        mock_response.raise_for_status = MagicMock()

        mock_client = AsyncMock()
        mock_client.post = AsyncMock(return_value=mock_response)
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=False)

        with patch("mixlift_mcp.license.httpx.AsyncClient", return_value=mock_client):
            status = await validate_license("MIXLIFT-no-valid")

        # data.get("valid") is None which is falsy -> free tier
        assert status.is_pro is False


# ---------------------------------------------------------------------------
# Test 5: Path security in full flow
# ---------------------------------------------------------------------------


class TestPathSecurityE2E:
    """Verify dangerous paths are blocked before reaching the model."""

    @pytest.mark.asyncio
    async def test_etc_passwd_blocked(self):
        """Attempting to read /etc/passwd must fail at path validation."""
        with patch("mixlift_mcp.server.validate_license", new_callable=AsyncMock) as mock_lic:
            mock_lic.return_value = LicenseStatus(False, 2, 2000, "Free")

            # _run_pipeline should never be called
            with patch("mixlift_mcp.server._run_pipeline") as mock_pipeline:
                result = await mixlift_analyze(csv_path="/etc/passwd")
                mock_pipeline.assert_not_called()

        data = json.loads(result)
        assert data["status"] == "error"
        # /etc/passwd has no .csv extension
        assert "extension" in data["message"].lower() or "denied" in data["message"].lower()

    @pytest.mark.asyncio
    async def test_ssh_key_path_blocked(self):
        """Path containing .ssh must be blocked."""
        with patch("mixlift_mcp.server.validate_license", new_callable=AsyncMock) as mock_lic:
            mock_lic.return_value = LicenseStatus(False, 2, 2000, "Free")

            with patch("mixlift_mcp.server._run_pipeline") as mock_pipeline:
                result = await mixlift_analyze(csv_path="/home/user/.ssh/keys.csv")
                mock_pipeline.assert_not_called()

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

    @pytest.mark.asyncio
    async def test_traversal_attack_blocked(self):
        """Path traversal attempts must be caught after resolution."""
        with patch("mixlift_mcp.server.validate_license", new_callable=AsyncMock) as mock_lic:
            mock_lic.return_value = LicenseStatus(False, 2, 2000, "Free")

            with patch("mixlift_mcp.server._run_pipeline") as mock_pipeline:
                result = await mixlift_analyze(csv_path="/tmp/safe/../../.env/data.csv")
                mock_pipeline.assert_not_called()

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

    @pytest.mark.asyncio
    async def test_credentials_path_blocked(self):
        """Path containing 'credentials' must be blocked."""
        with patch("mixlift_mcp.server.validate_license", new_callable=AsyncMock) as mock_lic:
            mock_lic.return_value = LicenseStatus(False, 2, 2000, "Free")

            with patch("mixlift_mcp.server._run_pipeline") as mock_pipeline:
                result = await mixlift_analyze(csv_path="/tmp/credentials/data.csv")
                mock_pipeline.assert_not_called()

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


# ---------------------------------------------------------------------------
# Test 6: Demo data quality check
# ---------------------------------------------------------------------------


class TestDemoDataQuality:
    """Verify the bundled demo CSV meets all requirements."""

    @pytest.fixture(autouse=True)
    def load_demo(self):
        """Load the demo CSV once for all tests in this class."""
        assert DEMO_CSV_PATH.exists(), f"Demo CSV not found at {DEMO_CSV_PATH}"
        self.df = pd.read_csv(DEMO_CSV_PATH)

    def test_has_exactly_three_channels(self):
        """Demo data must have exactly 3 spend channels."""
        spend_cols = [c for c in self.df.columns if c.endswith("_spend")]
        assert len(spend_cols) == 3, f"Expected 3 channels, found {len(spend_cols)}: {spend_cols}"

    def test_has_104_rows(self):
        """Demo data should have 104 rows (2 years of weekly data)."""
        assert len(self.df) == 104, f"Expected 104 rows, got {len(self.df)}"

    def test_all_spend_values_positive(self):
        """All spend values must be strictly positive."""
        spend_cols = [c for c in self.df.columns if c.endswith("_spend")]
        for col in spend_cols:
            assert (self.df[col] > 0).all(), f"Column {col} has non-positive values"

    def test_no_nan_values(self):
        """No NaN values in any column."""
        nan_counts = self.df.isna().sum()
        cols_with_nan = nan_counts[nan_counts > 0]
        assert cols_with_nan.empty, f"Columns with NaN: {dict(cols_with_nan)}"

    def test_revenue_column_exists(self):
        """Must have a 'revenue' column (the target variable)."""
        assert "revenue" in self.df.columns, f"Columns: {list(self.df.columns)}"

    def test_revenue_values_positive(self):
        """Revenue must be strictly positive."""
        assert (self.df["revenue"] > 0).all(), "Revenue has non-positive values"

    def test_date_column_exists_and_parseable(self):
        """Must have a 'date' column with parseable dates."""
        assert "date" in self.df.columns
        dates = pd.to_datetime(self.df["date"])
        assert dates.notna().all(), "Some dates failed to parse"

    def test_column_names_match_server_expectations(self):
        """Column names must match what _detect_csv_format and _load_wide_format expect.

        Wide format detection requires:
        - At least 2 columns ending in '_spend'
        - A 'revenue', 'target', or 'sales' column
        """
        spend_cols = [c for c in self.df.columns if c.endswith("_spend")]
        assert len(spend_cols) >= 2, "Need at least 2 _spend columns for wide format detection"
        has_target = any(c in self.df.columns for c in ("revenue", "target", "sales"))
        assert has_target, "Need 'revenue', 'target', or 'sales' column"

    def test_format_detected_as_wide(self):
        """Demo data must be detected as 'wide' format by _detect_csv_format."""
        from mixlift_mcp.server import _detect_csv_format
        assert _detect_csv_format(self.df) == "wide"

    def test_exceeds_free_tier_channel_limit(self):
        """Demo data has 3 channels which exceeds the free tier limit of 2."""
        spend_cols = [c for c in self.df.columns if c.endswith("_spend")]
        assert len(spend_cols) > FREE_MAX_CHANNELS, (
            f"Demo has {len(spend_cols)} channels, expected > {FREE_MAX_CHANNELS}"
        )

    def test_within_pro_tier_limits(self):
        """Demo data must fit within pro tier limits."""
        spend_cols = [c for c in self.df.columns if c.endswith("_spend")]
        assert len(spend_cols) <= PRO_MAX_CHANNELS, (
            f"Demo has {len(spend_cols)} channels but pro tier allows {PRO_MAX_CHANNELS}"
        )
        assert len(self.df) <= PRO_MAX_ROWS, (
            f"Demo has {len(self.df)} rows but pro tier allows {PRO_MAX_ROWS}"
        )
