"""MixLift MCP Server — Marketing Mix Modeling for AI assistants.

Provides a single tool `mixlift_analyze` that runs full Bayesian MMM analysis
on marketing CSV data and returns ROAS, budget optimization, and saturation curves.
"""

from __future__ import annotations

import asyncio
import json
import logging
import os
from pathlib import Path

import pandas as pd
from mcp.server.fastmcp import Context, FastMCP

from mixlift_mcp.license import LicenseStatus, free_tier, validate_license

logger = logging.getLogger(__name__)

DEMO_CSV_PATH = Path(__file__).parent / "data" / "demo.csv"

mcp = FastMCP("mixlift")

# ---------------------------------------------------------------------------
# Path validation guardrails
# ---------------------------------------------------------------------------

#: File extensions that are allowed as CSV inputs.
_ALLOWED_EXTENSIONS = {".csv", ".tsv", ".txt"}

#: Path components that indicate sensitive locations. Checked against every
#: part of the resolved path (case-insensitive) so that tricks like
#: ``../../.ssh/id_rsa`` are caught after resolution.
_BLOCKED_PATH_FRAGMENTS = {
    ".ssh",
    ".env",
    ".git",
    "credentials",
    "secrets",
    ".aws",
    ".config",
    ".gnupg",
    ".gpg",
    "keychain",
    "id_rsa",
    "id_ed25519",
    "id_ecdsa",
    "id_dsa",
    "authorized_keys",
    "known_hosts",
}


def _validate_csv_path(csv_path: str) -> tuple[Path, str | None]:
    """Validate and resolve a user-supplied CSV path.

    Returns ``(resolved_path, None)`` on success, or ``(Path(), error_message)``
    on failure.

    Guardrails applied (in order):
    1. Extension must be in ``_ALLOWED_EXTENSIONS``.
    2. Resolved absolute path must not contain any ``_BLOCKED_PATH_FRAGMENTS``.
    3. If the environment variable ``MIXLIFT_ALLOWED_DIRS`` is set (colon-separated
       list of directories), the resolved path must fall within one of them.
    """
    resolved = Path(csv_path).expanduser().resolve()

    # 1. Extension check
    if resolved.suffix.lower() not in _ALLOWED_EXTENSIONS:
        return Path(), (
            f"Invalid file extension '{resolved.suffix}'. "
            f"Only {', '.join(sorted(_ALLOWED_EXTENSIONS))} files are allowed."
        )

    # 2. Sensitive-path check — inspect every part of the resolved path
    path_str_lower = str(resolved).lower()
    for fragment in _BLOCKED_PATH_FRAGMENTS:
        if fragment in path_str_lower:
            logger.warning("Blocked sensitive path access attempt: %s", resolved)
            return Path(), (
                f"Access denied: the path contains a restricted component ('{fragment}'). "
                "Please use a file outside sensitive system directories."
            )

    # 3. Allowed-directories check (opt-in via environment variable)
    allowed_dirs_env = os.environ.get("MIXLIFT_ALLOWED_DIRS", "").strip()
    if allowed_dirs_env:
        allowed_dirs = [
            Path(d).expanduser().resolve()
            for d in allowed_dirs_env.split(":")
            if d.strip()
        ]
        if not any(
            resolved == allowed or allowed in resolved.parents
            for allowed in allowed_dirs
        ):
            allowed_display = ", ".join(str(d) for d in allowed_dirs)
            logger.warning(
                "Blocked path outside allowed directories: %s (allowed: %s)",
                resolved,
                allowed_display,
            )
            return Path(), (
                f"Access denied: '{resolved}' is not inside an allowed directory. "
                f"Allowed directories: {allowed_display}"
            )

    logger.info("Resolved CSV path: %s", resolved)
    return resolved, None


# ---------------------------------------------------------------------------
# CSV loading helpers — canonical implementations live in mixlift.ingest.loader;
# private aliases kept here for backward compatibility within the MCP server.
# ---------------------------------------------------------------------------
from mixlift.ingest.loader import (
    detect_csv_format as _detect_csv_format,
    validate_wide_format as _validate_wide_format,
    check_date_range_warnings as _check_date_range_warnings,
    load_wide_format as _load_wide_format,
    load_platform_csv as _load_platform_csv,
)


def _enforce_tier_limits(
    license_status: LicenseStatus,
    n_channels: int,
    n_rows: int,
) -> list[str]:
    """Raise ValueError if data exceeds tier limits.

    Returns a list of warning strings (e.g. high channel count for Pro tier).
    """
    tier_name = "Pro" if license_status.is_pro else "Free"
    warnings: list[str] = []

    if n_channels > license_status.max_channels:
        raise ValueError(
            f"{tier_name} tier allows up to {license_status.max_channels} channels, "
            f"but your data has {n_channels}. "
            f"Upgrade to Growth ($299/mo) at https://mixlift.io/pricing"
        )
    if n_rows > license_status.max_rows:
        raise ValueError(
            f"{tier_name} tier allows up to {license_status.max_rows} rows, "
            f"but your data has {n_rows}. "
            f"Upgrade to Growth ($299/mo) at https://mixlift.io/pricing"
        )

    # A4: Soft warning for Pro tier with many channels
    if n_channels > 15:
        msg = (
            f"Your data has {n_channels} channels. Models with >15 channels "
            f"may produce less precise estimates with default MCMC settings. "
            f"Consider increasing draws/tune for better posterior quality."
        )
        logger.warning(msg)
        warnings.append(msg)

    return warnings


def _compute_saturation_traffic_lights(
    saturation_curves: dict,
    optimization: dict,
) -> dict:
    """Compute per-channel saturation percentage and traffic light status.

    Uses the saturation curve: at current spend, how far along the curve are we?
    - Green (< 50%): Room to scale
    - Yellow (50-75%): Approaching saturation
    - Red (> 75%): Diminishing returns
    """
    lights = {}
    current = optimization.get("current_allocation", {})

    for channel, curve_data in saturation_curves.items():
        spend_points = curve_data.get("spend", [])
        response_points = curve_data.get("response", [])
        if not spend_points or not response_points:
            continue

        current_spend = current.get(channel, 0)
        max_response = max(response_points) if response_points else 1.0

        # Find response at current spend by interpolation
        import bisect
        idx = bisect.bisect_left(spend_points, current_spend)
        if idx >= len(response_points):
            current_response = response_points[-1]
        elif idx == 0:
            current_response = response_points[0]
        else:
            # Linear interpolation
            x0, x1 = spend_points[idx - 1], spend_points[idx]
            y0, y1 = response_points[idx - 1], response_points[idx]
            t = (current_spend - x0) / (x1 - x0) if x1 != x0 else 0
            current_response = y0 + t * (y1 - y0)

        saturation_pct = round((current_response / max_response) * 100, 0) if max_response > 0 else 0

        if saturation_pct < 50:
            status = "green"
            label = f"{channel} has {100 - saturation_pct:.0f}% headroom (room to scale)"
        elif saturation_pct < 75:
            status = "yellow"
            label = f"{channel} is {saturation_pct:.0f}% saturated (approaching diminishing returns)"
        else:
            status = "red"
            label = f"{channel} is {saturation_pct:.0f}% saturated (diminishing returns)"

        lights[channel] = {
            "saturation_pct": saturation_pct,
            "status": status,
            "label": label,
        }

    return lights


def _redact_dollars(text: str) -> str:
    """Strip dollar amounts from text for free-tier output."""
    import re
    return re.sub(r'\$[\d,]+(?:\.\d+)?(?:/week)?', '$XXX', text)


def _compute_dollar_headline(optimization: dict) -> dict:
    """Compute the dollar-value headline from budget optimization results.

    Returns headline data. Dollar values only shown to paid tier.
    """
    current = optimization.get("current_allocation", {})
    optimal = optimization.get("optimal_allocation", {})
    lift_pct = optimization.get("expected_lift_pct", 0)
    total_budget = optimization.get("total_budget", 0)

    # Total dollar opportunity = lift percentage applied to total budget
    dollar_opportunity = round(total_budget * abs(lift_pct) / 100, 0)

    # Per-channel reallocation in dollars and percentages
    reallocations = []
    for channel in current:
        curr = current.get(channel, 0)
        opt = optimal.get(channel, 0)
        delta = opt - curr
        if abs(delta) > 1:  # Ignore trivial changes
            pct_change = round((delta / curr) * 100, 1) if curr > 0 else 0
            action = "Increase" if delta > 0 else "Decrease"
            reallocations.append({
                "channel": channel,
                "action": action,
                "current_spend": round(curr, 0),
                "optimal_spend": round(opt, 0),
                "change_dollars": round(abs(delta), 0),
                "change_pct": abs(pct_change),
                "label": f"{action} {channel} by ${abs(delta):,.0f}/week ({abs(pct_change):.0f}%)",
            })

    reallocations.sort(key=lambda x: x["change_dollars"], reverse=True)

    return {
        "dollar_opportunity": dollar_opportunity,
        "lift_pct": lift_pct,
        "headline": f"MixLift found ${dollar_opportunity:,.0f}/week in budget optimization opportunity ({lift_pct:+.1f}% expected lift)",
        "reallocations": reallocations,
    }


def _compute_confidence_summary(model_results) -> dict:
    """Generate plain-English confidence summary."""
    ci_widths = {}
    for channel, ci in model_results.roas_ci.items():
        width = ci[1] - ci[0]
        mean = model_results.roas_estimates.get(channel, 1)
        relative_width = width / mean if mean > 0 else float("inf")
        ci_widths[channel] = relative_width

    summaries = {}
    for channel, rel_width in ci_widths.items():
        if rel_width < 0.5:
            summaries[channel] = {
                "confidence": "high",
                "label": f"{channel}: High confidence (tight uncertainty bounds)",
            }
        elif rel_width < 1.0:
            summaries[channel] = {
                "confidence": "moderate",
                "label": f"{channel}: Moderate confidence (consider more data for precision)",
            }
        else:
            summaries[channel] = {
                "confidence": "low",
                "label": f"{channel}: Use with caution (wide uncertainty — more data recommended)",
            }

    return summaries


def _compute_model_fit(model_results) -> dict:
    """Compute model fit summary: R-squared and MAPE equivalent."""
    import numpy as np

    try:
        mmm = model_results.mmm
        idata = mmm.idata

        # Posterior predictive mean
        if hasattr(idata, "posterior_predictive"):
            pp = idata.posterior_predictive
            # Get the target variable name
            var_names = list(pp.data_vars)
            if var_names:
                y_pred = pp[var_names[0]].mean(dim=["chain", "draw"]).values
                y_actual = mmm.y.values if hasattr(mmm, "y") else None

                if y_actual is not None and len(y_pred) == len(y_actual):
                    ss_res = np.sum((y_actual - y_pred) ** 2)
                    ss_tot = np.sum((y_actual - np.mean(y_actual)) ** 2)
                    r_squared = 1 - (ss_res / ss_tot) if ss_tot > 0 else 0

                    mape = np.mean(np.abs((y_actual - y_pred) / y_actual)) * 100 if np.all(y_actual != 0) else None

                    r2_pct = round(r_squared * 100, 0)
                    return {
                        "r_squared": round(r_squared, 3),
                        "mape": round(mape, 1) if mape is not None else None,
                        "label": f"This model explains {r2_pct:.0f}% of your revenue variation.",
                    }
    except Exception:
        pass

    return {"label": "Model fit metrics not available for this run."}


def _build_display_text(result: dict, is_paid: bool) -> str:
    """Build pre-formatted markdown that Claude renders directly to the user.

    Assembles headline, ROAS table, saturation traffic lights, budget actions,
    and memory notes into a single markdown string suitable for executive
    consumption (VP Marketing / CMO level).
    """
    sections: list[str] = []

    # 1. Headline
    headline = result.get("headline", "")
    if headline:
        sections.append(f"## {headline}")

    # Analysis badge (if exploratory)
    badge = result.get("analysis_badge")
    if badge:
        sections.append(f"> **{badge['label']}:** {badge['note']}")

    # 2. ROAS Table
    roas_data = result.get("roas", {})
    estimates = roas_data.get("estimates", {})
    cis = roas_data.get("confidence_intervals", {})
    if estimates:
        rows = ["## Channel ROAS", "", "| Channel | ROAS | 95% CI |", "|---------|------|--------|"]
        for ch, val in estimates.items():
            ci = cis.get(ch, {})
            low = ci.get("low", "—")
            high = ci.get("high", "—")
            if isinstance(low, (int, float)) and isinstance(high, (int, float)):
                ci_str = f"{low:.2f} – {high:.2f}"
            else:
                ci_str = "—"
            rows.append(f"| {ch} | **{val:.2f}** | {ci_str} |")
        sections.append("\n".join(rows))

    # 3. Saturation Traffic Lights
    sat = result.get("saturation_analysis", {})
    if sat:
        lights_lines = ["## Saturation Analysis"]
        status_emoji = {"green": "🟢", "yellow": "🟡", "red": "🔴"}
        for ch, info in sat.items():
            icon = status_emoji.get(info.get("status", ""), "⚪")
            lights_lines.append(f"- {icon} {info.get('label', ch)}")
        sections.append("\n".join(lights_lines))

    # 4. Budget Actions
    recs = result.get("recommendations", {})
    actions = recs.get("actions", [])
    if actions:
        action_lines = ["## Budget Actions"]
        for a in actions:
            rank = a.get("rank", "")
            action = a.get("action", "")
            channel = a.get("channel", "")
            reason = a.get("reason", "")
            action_lines.append(f"{rank}. **{action} {channel}** — {reason}")
        sections.append("\n".join(action_lines))

    # 5. Model fit
    model_fit = result.get("model_fit", {})
    fit_label = model_fit.get("label", "")
    if fit_label:
        sections.append(f"## Model Quality\n\n{fit_label}")

    # 6. Memory note
    memory = result.get("memory", {})
    if memory:
        note = memory.get("note", "")
        deltas = memory.get("roas_deltas", {})
        if note:
            sections.append(f"---\n_{note}_")
        elif deltas:
            delta_lines = ["---", "_Compared to previous analysis:_"]
            for ch, d in deltas.items():
                delta_lines.append(f"- {ch} ROAS moved {d:+.2f}" if isinstance(d, (int, float)) else f"- {ch}: {d}")
            sections.append("\n".join(delta_lines))

    return "\n\n".join(sections)


def _build_result(
    model_results,
    optimization: dict,
    saturation_curves: dict,
    channel_columns: list[str],
    license_status: LicenseStatus,
    csv_format: str,
    tier_warnings: list[str] | None = None,
    data_weeks: float = 52,
) -> dict:
    """Build the final result dictionary."""
    from mixlift.modeling.recommendations import (
        generate_recommendations,
        format_all_recommendations,
    )

    # Generate recommendations
    recommendations, summary = generate_recommendations(
        current_allocation=optimization["current_allocation"],
        optimal_allocation=optimization["optimal_allocation"],
        roas_estimates=model_results.roas_estimates,
        expected_lift_pct=optimization["expected_lift_pct"],
    )

    # Compute new output features
    dollar_headline = _compute_dollar_headline(optimization)
    saturation_lights = _compute_saturation_traffic_lights(saturation_curves, optimization)
    confidence_summary = _compute_confidence_summary(model_results)
    model_fit = _compute_model_fit(model_results)

    # Exploratory analysis badge for short datasets
    analysis_badge = None
    if 0 < data_weeks < 26:
        analysis_badge = {
            "type": "exploratory",
            "label": "Exploratory Analysis",
            "note": (
                f"This analysis covers {data_weeks:.0f} weeks of data. "
                f"MixLift recommends at least 26 weeks for production-grade estimates. "
                f"Results with shorter datasets are influenced more by prior assumptions "
                f"than by your data. Treat ROAS estimates as directional, not precise."
            ),
        }

    is_paid = license_status.is_pro

    result = {
        "status": "success",
        "license": {
            "tier": "pro" if is_paid else "free",
            "message": license_status.message,
        },
        # --- HEADLINE (the most important field) ---
        "headline": dollar_headline["headline"] if is_paid else (
            f"MixLift found a {dollar_headline['lift_pct']:+.1f}% budget optimization opportunity. "
            f"Upgrade to Growth ($299/mo) to see exact dollar values."
        ),
        "data": {
            "format_detected": csv_format,
            "channels": list(model_results.roas_estimates.keys()),
            "n_channels": len(channel_columns),
        },
        "roas": {
            "estimates": model_results.roas_estimates,
            "confidence_intervals": {
                k: {"low": v[0], "high": v[1]}
                for k, v in model_results.roas_ci.items()
            },
        },
        "confidence_summary": confidence_summary,
        "channel_contributions": {
            "estimates": model_results.channel_contributions,
            "confidence_intervals": {
                k: {"low": v[0], "high": v[1]}
                for k, v in model_results.channel_contributions_ci.items()
            },
        },
        "saturation_analysis": saturation_lights,
        "budget_optimization": {
            "current_allocation": optimization["current_allocation"] if is_paid else "Upgrade to Growth ($299/mo) to see dollar allocations",
            "optimal_allocation": optimization["optimal_allocation"] if is_paid else "Upgrade to Growth ($299/mo) to see dollar allocations",
            "total_budget": optimization["total_budget"] if is_paid else "Upgrade to Growth ($299/mo) to see dollar allocations",
            "expected_lift_pct": optimization["expected_lift_pct"],
        },
        "recommendations": {
            "summary": summary if is_paid else _redact_dollars(summary),
            "actions": [
                {
                    "rank": r.rank,
                    "action": r.action,
                    "channel": r.channel,
                    "change_amount": r.change_amount if is_paid else "Upgrade to Growth ($299/mo)",
                    "current_spend": r.current_spend if is_paid else "Upgrade to Growth ($299/mo)",
                    "optimal_spend": r.optimal_spend if is_paid else "Upgrade to Growth ($299/mo)",
                    "roas": r.roas,
                    "reason": r.reason if is_paid else _redact_dollars(r.reason),
                }
                for r in recommendations
            ],
        },
        "marginal_roas": {
            "estimates": model_results.marginal_roas_estimates,
            "confidence_intervals": {
                k: {"low": v[0], "high": v[1]}
                for k, v in model_results.marginal_roas_ci.items()
            },
        },
        "diagnostics": {
            "convergence_warnings": model_results.convergence_warnings,
            "tier_warnings": tier_warnings or [],
        },
        "model_fit": model_fit,
        "analysis_badge": analysis_badge,
        "model_info": {
            "duration_secs": round(model_results.duration_secs, 1),
            "total_spend": model_results.total_spend if is_paid else "Upgrade to Growth ($299/mo)",
        },
    }

    # Dollar-value details only for paid tier
    if is_paid:
        result["dollar_value_analysis"] = dollar_headline
    else:
        result["dollar_value_analysis"] = {
            "headline": result["headline"],
            "note": "Dollar values are available on the Growth plan ($299/mo)."
                    "Free tier shows percentage-based insights.",
        }

    # Raw saturation curves stripped from MCP output (Claude cannot render charts;
    # traffic lights in saturation_analysis provide the actionable summary).
    # Headless API users can access curves via the modeling engine directly.

    return result


def _run_pipeline(X, y, channel_columns):
    """Wrapper for run_full_pipeline (enables easy mocking in tests).

    Uses MCP_CONFIG (2 chains, 500 tune, 250 draws) for balanced
    convergence quality with ~30-60s response time.
    """
    from mixlift.modeling.defaults import MCP_CONFIG
    from mixlift.modeling.engine import run_full_pipeline

    return run_full_pipeline(X, y, channel_columns, mcmc_config=MCP_CONFIG)


def _run_optimizer(mmm, X, channel_columns, contributions=None):
    """Wrapper for optimize_budget (enables easy mocking in tests)."""
    from mixlift.modeling.optimizer import optimize_budget

    return optimize_budget(mmm, X, channel_columns, contributions=contributions)


def _run_saturation(mmm, X, channel_columns):
    """Wrapper for extract_saturation_curves (enables easy mocking in tests)."""
    from mixlift.modeling.engine import extract_saturation_curves

    return extract_saturation_curves(mmm, X, channel_columns)


@mcp.tool()
async def mixlift_analyze(
    csv_path: str = "",
    license_key: str = "",
    scenario: str = "",
    ctx: Context | None = None,
) -> str:
    """Run full Bayesian Marketing Mix Model analysis on marketing spend data.

    Accepts a CSV file path with marketing data (Meta Ads, Google Ads, TikTok Ads,
    or generic format). Auto-detects the CSV format. If no path is provided, uses
    a bundled demo dataset.

    IMPORTANT — Present the result to the user in this exact order:
    1. The `display_text` field, rendered as-is (it is pre-formatted markdown)
    2. If the user asks follow-up questions, reference the structured data fields

    Do NOT summarize, reorder, or omit sections from display_text. It is the
    canonical output format designed for marketing decision-makers.

    Args:
        csv_path: Path to a CSV file with marketing data. Leave empty to use demo data.
        license_key: Optional MixLift Growth license key. Free tier: 2 channels, 2,000 rows.
        scenario: Optional budget scenario, e.g. "cut Meta by 20%" or "move $5000 from Meta to Google".
                  Runs the model first, then projects the impact of the scenario change.
    """
    import time as _time

    _step = 0

    async def _log(msg: str) -> None:
        """Send progress to client and log locally."""
        nonlocal _step
        _step += 1
        logger.info(msg)
        if ctx:
            try:
                await ctx.report_progress(_step, 7, msg)
            except Exception:
                pass  # Progress reporting is best-effort

    t0 = _time.monotonic()

    await _log("Validating license...")
    # Validate license
    license_status = await validate_license(license_key if license_key else None)

    # Usage metering for free tier (local run counter)
    if not license_status.is_pro:
        from mixlift_mcp.metering import check_and_increment
        allowed, meter_msg = check_and_increment()
        if not allowed:
            return json.dumps({"status": "error", "message": meter_msg})
        await _log(meter_msg)

    # Load data
    use_demo = not csv_path
    if use_demo:
        if not DEMO_CSV_PATH.exists():
            return json.dumps(
                {"status": "error", "message": "Demo data not found. Please provide a csv_path."}
            )
        csv_file = DEMO_CSV_PATH
    else:
        csv_file, validation_error = _validate_csv_path(csv_path)
        if validation_error:
            return json.dumps({"status": "error", "message": validation_error})
        if not csv_file.exists():
            return json.dumps(
                {"status": "error", "message": f"File not found: {csv_file}"}
            )

    try:
        df = pd.read_csv(csv_file)
    except Exception as e:
        return json.dumps(
            {"status": "error", "message": f"Failed to read CSV: {e}"}
        )

    await _log(f"CSV loaded ({len(df)} rows). Detecting format...")
    # Detect format and load
    csv_format = _detect_csv_format(df)

    try:
        if csv_format == "wide":
            X, y, channel_columns = _load_wide_format(df)
        else:
            X, y, channel_columns = _load_platform_csv(df, csv_format)
    except ValueError as e:
        return json.dumps({"status": "error", "message": str(e)})

    # Check date range warnings (90-179 days: warn but proceed)
    date_range_warnings = _check_date_range_warnings(df)
    for w in date_range_warnings:
        logger.warning(w)
        await _log(w)

    # Compute data length for exploratory badge
    try:
        _dates = pd.to_datetime(df["date"])
        _data_weeks = (_dates.max() - _dates.min()).days / 7
    except Exception:
        _data_weeks = 52  # Default to safe value

    # Enforce tier limits
    n_channels = len(channel_columns)
    n_rows = len(X)
    try:
        tier_warnings = _enforce_tier_limits(license_status, n_channels, n_rows)
        tier_warnings.extend(date_range_warnings)
    except ValueError as e:
        return json.dumps({"status": "error", "message": str(e)})

    # Run the modeling pipeline
    try:
        await _log(
            f"Starting MMM: {n_channels} channels, {n_rows} rows, "
            f"format={csv_format}, tier={'pro' if license_status.is_pro else 'free'}"
        )

        # Run CPU-bound MCMC sampling in a thread to avoid blocking the
        # MCP server's async event loop (which would make it appear hung).
        await _log("Compiling PyMC model & running MCMC sampling (this takes ~30-60s on first call)...")
        model_results = await asyncio.to_thread(
            _run_pipeline, X, y, channel_columns
        )
        elapsed = _time.monotonic() - t0
        await _log(f"MCMC complete in {elapsed:.1f}s. Running budget optimizer...")

        optimization = await asyncio.to_thread(
            _run_optimizer, model_results.mmm, X, channel_columns,
            contributions=model_results.raw_contributions,
        )
        await _log("Budget optimization done. Computing saturation curves...")

        saturation_curves = await asyncio.to_thread(
            _run_saturation, model_results.mmm, X, channel_columns
        )
        total = _time.monotonic() - t0
        await _log(f"Analysis complete in {total:.1f}s. Building results...")

        result = _build_result(
            model_results=model_results,
            optimization=optimization,
            saturation_curves=saturation_curves,
            channel_columns=channel_columns,
            license_status=license_status,
            csv_format=csv_format,
            tier_warnings=tier_warnings,
            data_weeks=_data_weeks,
        )

        # Contextual memory: compare to previous analysis on same dataset
        try:
            from mixlift_mcp.memory import (
                build_summary,
                compute_deltas,
                compute_fingerprint,
                load_previous,
                save_analysis,
            )

            channel_names = list(model_results.roas_estimates.keys())
            fp = compute_fingerprint(channel_names, str(csv_file))
            saturation_lights = result.get("saturation_analysis", {})

            mem_summary = build_summary(
                roas_estimates=model_results.roas_estimates,
                roas_ci=model_results.roas_ci,
                saturation_lights=saturation_lights,
                optimization=optimization,
                channel_contributions=model_results.channel_contributions,
                model_fit=result.get("model_fit", {}),
                data_weeks=_data_weeks,
                analysis_badge=result.get("analysis_badge"),
            )

            previous = load_previous(fp)
            if previous:
                deltas = compute_deltas(mem_summary, previous)
                result["memory"] = deltas
            else:
                result["memory"] = {
                    "note": "First analysis for this dataset. Future runs will show trends.",
                }

            save_analysis(fp, str(csv_file), mem_summary)
        except Exception as e:
            logger.warning(f"Memory system error (non-fatal): {e}")

        # Scenario mode: project impact of budget changes
        if scenario and license_status.is_pro:
            scenario_result = _run_scenario(
                scenario, optimization, model_results, X, channel_columns
            )
            if scenario_result:
                result["scenario"] = scenario_result
        elif scenario and not license_status.is_pro:
            result["scenario"] = {
                "error": "Scenario mode requires a Growth plan ($299/mo)."
            }

        # Build pre-formatted markdown for direct rendering by Claude
        is_paid = license_status.is_pro
        result["display_text"] = _build_display_text(result, is_paid)

        return json.dumps(result, indent=2, default=str)

    except Exception as e:
        logger.exception("MMM analysis failed")
        return json.dumps(
            {"status": "error", "message": f"Analysis failed: {e}"}
        )


def _parse_scenario(scenario: str, current_allocation: dict) -> dict | None:
    """Parse a natural-language budget scenario into allocation changes.

    Supports patterns like:
    - "cut Meta by 20%"
    - "increase Google by $5000"
    - "move $3000 from Meta to Google"

    Returns modified allocation dict, or None if unparseable.
    """
    import re

    scenario_lower = scenario.lower().strip()
    modified = dict(current_allocation)
    channels_lower = {k.lower(): k for k in current_allocation}

    def _find_channel(text: str) -> str | None:
        # Exact match first, then substring (text in key, not key in text)
        for key, name in channels_lower.items():
            if key == text:
                return name
        for key, name in channels_lower.items():
            if text in key:
                return name
        return None

    # Pattern: "cut/reduce/decrease X by Y%"
    m = re.search(r'(?:cut|reduce|decrease)\s+(\w+)\s+by\s+(\d+)%', scenario_lower)
    if m:
        ch = _find_channel(m.group(1))
        pct = float(m.group(2)) / 100
        if ch and ch in modified:
            modified[ch] = round(modified[ch] * (1 - pct), 2)
            return modified

    # Pattern: "increase/boost X by Y%"
    m = re.search(r'(?:increase|boost|raise)\s+(\w+)\s+by\s+(\d+)%', scenario_lower)
    if m:
        ch = _find_channel(m.group(1))
        pct = float(m.group(2)) / 100
        if ch and ch in modified:
            modified[ch] = round(modified[ch] * (1 + pct), 2)
            return modified

    # Pattern: "cut/reduce X by $Y"
    m = re.search(r'(?:cut|reduce|decrease)\s+(\w+)\s+by\s+\$?([\d,]+)', scenario_lower)
    if m:
        ch = _find_channel(m.group(1))
        amount = float(m.group(2).replace(",", ""))
        if ch and ch in modified:
            modified[ch] = round(max(0, modified[ch] - amount), 2)
            return modified

    # Pattern: "increase X by $Y"
    m = re.search(r'(?:increase|boost|raise)\s+(\w+)\s+by\s+\$?([\d,]+)', scenario_lower)
    if m:
        ch = _find_channel(m.group(1))
        amount = float(m.group(2).replace(",", ""))
        if ch and ch in modified:
            modified[ch] = round(modified[ch] + amount, 2)
            return modified

    # Pattern: "move $Y from X to Z"
    m = re.search(r'move\s+\$?([\d,]+)\s+from\s+(\w+)\s+to\s+(\w+)', scenario_lower)
    if m:
        amount = float(m.group(1).replace(",", ""))
        from_ch = _find_channel(m.group(2))
        to_ch = _find_channel(m.group(3))
        if from_ch and to_ch and from_ch in modified and to_ch in modified:
            modified[from_ch] = round(max(0, modified[from_ch] - amount), 2)
            modified[to_ch] = round(modified[to_ch] + amount, 2)
            return modified

    return None


def _run_scenario(
    scenario: str,
    optimization: dict,
    model_results,
    X,
    channel_columns: list[str],
) -> dict | None:
    """Run a what-if scenario and project impact with confidence intervals."""
    import numpy as np

    from mixlift.modeling.optimizer import (
        _estimate_response,
        estimate_scenario_impact_samples,
    )

    current = optimization["current_allocation"]
    modified = _parse_scenario(scenario, current)

    if modified is None:
        return {
            "error": f"Could not parse scenario: '{scenario}'. "
                     f"Try: 'cut Meta by 20%', 'increase Google by $5000', "
                     f"or 'move $3000 from Meta to Google'."
        }

    # Point estimate (backward-compatible)
    current_response = _estimate_response(
        model_results.mmm, X, channel_columns, current
    )
    modified_response = _estimate_response(
        model_results.mmm, X, channel_columns, modified
    )

    if current_response > 0:
        impact_pct = ((modified_response - current_response) / current_response) * 100
    else:
        impact_pct = 0.0

    # Confidence intervals via posterior sampling
    ci_data = {}
    try:
        samples = estimate_scenario_impact_samples(
            model_results.mmm, X, channel_columns, current, modified
        )
        if len(samples) > 0:
            median = float(np.median(samples))
            ci_low = float(np.percentile(samples, 10))
            ci_high = float(np.percentile(samples, 90))
            impact_pct = median  # Use median instead of point estimate

            ci_data = {
                "impact_ci_80": {"low": round(ci_low, 1), "high": round(ci_high, 1)},
                "confidence_note": "80% credible interval from Bayesian posterior",
            }

            # Direction confidence language
            if ci_low > 0:
                direction_note = (
                    f"This would almost certainly increase revenue, "
                    f"likely by {ci_low:+.1f}% to {ci_high:+.1f}%."
                )
            elif ci_high < 0:
                direction_note = (
                    f"This would almost certainly reduce revenue, "
                    f"likely by {ci_high:.1f}% to {ci_low:.1f}%."
                )
            elif abs(median) > abs(ci_high - ci_low) * 0.1:
                word = "increase" if median > 0 else "reduce"
                direction_note = (
                    f"This would probably {word} revenue (median {median:+.1f}%), "
                    f"but there's a chance it could go either way."
                )
            else:
                direction_note = (
                    "The impact is uncertain — it could go either way. "
                    "Not enough signal to recommend this change."
                )
            ci_data["direction_note"] = direction_note
    except Exception as e:
        logger.warning(f"Could not compute scenario CIs: {e}")

    total_current = sum(current.values())
    total_modified = sum(modified.values())

    changes = []
    for ch in current:
        delta = modified.get(ch, 0) - current.get(ch, 0)
        if abs(delta) > 1:
            changes.append({
                "channel": ch,
                "current": round(current[ch], 0),
                "modified": round(modified.get(ch, 0), 0),
                "change": round(delta, 0),
            })

    result = {
        "scenario_input": scenario,
        "current_budget": round(total_current, 0),
        "modified_budget": round(total_modified, 0),
        "projected_impact_pct": round(impact_pct, 2),
        "headline": (
            f"Scenario: '{scenario}' would result in a {impact_pct:+.1f}% change "
            f"in projected revenue."
        ),
        "changes": changes,
        "modified_allocation": {k: round(v, 0) for k, v in modified.items()},
    }
    result.update(ci_data)
    return result


def main():
    """Entry point for the MixLift MCP server."""
    mcp.run(transport="stdio")


if __name__ == "__main__":
    main()
