"""Tests for learning models — unit tests + integration tests against sample data.

Tests the documented requirements:
    1. Provider registry works and auto-selects backends
    2. Inventory models produce correct results (EOQ, safety stock, ABC, etc.)
    3. Forecasting models produce valid forecasts (builtin + statsforecast)
    4. Analytics models segment and analyze customers correctly
    5. Integration: all models run against sample CSV data and produce useful output
"""

import csv
import math
import os
from pathlib import Path

import pytest

from platoon.learning.providers import get_provider, list_providers

SAMPLES_DIR = Path(__file__).parent.parent / "data" / "samples"


def _load_csv(filename: str):
    """Load a sample CSV as list of dicts."""
    path = SAMPLES_DIR / filename
    if not path.exists():
        pytest.skip(f"Sample data not found: {path}. Run: uv run python data/samples/generate.py")
    with open(path) as f:
        return list(csv.DictReader(f))


# ===========================================================================
# Provider registry
# ===========================================================================


class TestProviderRegistry:
    def test_list_all_providers(self):
        """All three domains have at least one provider."""
        providers = list_providers()
        domains = {p["domain"] for p in providers}
        assert "inventory" in domains
        assert "forecasting" in domains
        assert "analytics" in domains

    def test_get_provider_auto(self):
        """Auto-select returns a working provider for each domain."""
        for domain in ["inventory", "forecasting", "analytics"]:
            provider = get_provider(domain)
            assert provider is not None

    def test_get_builtin_inventory(self):
        provider = get_provider("inventory", backend="builtin")
        assert provider.backend == "builtin"

    def test_get_stockpyl_inventory(self):
        provider = get_provider("inventory", backend="stockpyl")
        assert provider.backend == "stockpyl"

    def test_get_builtin_forecasting(self):
        provider = get_provider("forecasting", backend="builtin")
        assert provider.backend == "builtin"

    def test_get_statsforecast(self):
        provider = get_provider("forecasting", backend="statsforecast")
        assert provider.backend == "statsforecast"

    def test_invalid_domain_raises(self):
        with pytest.raises(ValueError):
            get_provider("nonexistent_domain")


# ===========================================================================
# Inventory — unit tests
# ===========================================================================


class TestInventoryBuiltin:
    def setup_method(self):
        self.inv = get_provider("inventory", backend="builtin")

    def test_eoq_textbook(self):
        """EOQ with D=10000, S=50, H=2 should give Q*~707."""
        result = self.inv.eoq(demand=10000, ordering_cost=50, holding_cost=2)
        assert 700 < result.order_quantity < 710
        # At optimal Q, holding cost == ordering cost
        assert abs(result.holding_cost - result.ordering_cost) < 1.0

    def test_eoq_cost_breakdown(self):
        result = self.inv.eoq(demand=5000, ordering_cost=100, holding_cost=5)
        assert result.total_cost == pytest.approx(result.holding_cost + result.ordering_cost, abs=0.02)
        assert result.annual_orders > 0

    def test_safety_stock_95(self):
        ss = self.inv.safety_stock(demand_std=10, lead_time=7, service_level=0.95)
        assert ss > 0
        # Z(0.95) ≈ 1.645, ss ≈ 1.645 * 10 * sqrt(7) ≈ 43.5
        assert 40 < ss < 48

    def test_safety_stock_increases_with_service_level(self):
        ss_90 = self.inv.safety_stock(demand_std=10, lead_time=7, service_level=0.90)
        ss_99 = self.inv.safety_stock(demand_std=10, lead_time=7, service_level=0.99)
        assert ss_99 > ss_90

    def test_reorder_point(self):
        rop = self.inv.reorder_point(avg_demand=20, lead_time=5, ss=50)
        assert rop == 150.0  # 20*5 + 50

    def test_abc_classification(self):
        items = [
            {"sku": "A1", "revenue": 50000},
            {"sku": "B1", "revenue": 10000},
            {"sku": "B2", "revenue": 8000},
            {"sku": "C1", "revenue": 500},
            {"sku": "C2", "revenue": 200},
        ]
        result = self.inv.abc(items)
        assert "A1" in [i["sku"] for i in result["A"]]
        assert "C2" in [i["sku"] for i in result["C"]]
        # A items should contribute ~80% of value
        total = sum(i["revenue"] for i in items)
        a_total = sum(i["revenue"] for i in result["A"])
        assert a_total / total > 0.7

    def test_stockout_risk_deterministic(self):
        # Stock > demand: safe
        assert self.inv.stockout_risk(stock=100, avg_demand=10, lead_time=5) == 0.0
        # Stock < demand: risky
        assert self.inv.stockout_risk(stock=10, avg_demand=10, lead_time=5) == 1.0

    def test_stockout_risk_stochastic(self):
        risk = self.inv.stockout_risk(stock=50, avg_demand=10, lead_time=5, std=5)
        assert 0 < risk < 1  # somewhere in between

    def test_full_report(self):
        report = self.inv.full_report(
            sku="SKU-001",
            annual_demand=3650,
            demand_std=5,
            ordering_cost=50,
            holding_cost=2,
            lead_time_days=7,
            current_stock=100,
        )
        assert report.sku == "SKU-001"
        assert report.eoq.order_quantity > 0
        assert report.safety_stock > 0
        assert report.reorder_point > 0
        assert 0 <= report.stockout_risk <= 1


class TestInventoryStockpyl:
    def setup_method(self):
        self.inv = get_provider("inventory", backend="stockpyl")

    def test_eoq_matches_builtin(self):
        """Stockpyl EOQ should match our builtin formula."""
        builtin = get_provider("inventory", backend="builtin")
        r1 = builtin.eoq(10000, 50, 2)
        r2 = self.inv.eoq(10000, 50, 2)
        assert abs(r1.order_quantity - r2.order_quantity) < 1.0

    def test_newsvendor(self):
        """Newsvendor model for perishable goods."""
        result = self.inv.newsvendor(
            selling_price=10.0,
            cost=6.0,
            demand_mean=100,
            demand_std=20,
        )
        assert result["optimal_quantity"] > 0
        assert result["critical_ratio"] == pytest.approx(0.4, abs=0.01)
        # Critical ratio = (10-6)/(10-0) = 0.4


# ===========================================================================
# Forecasting — unit tests
# ===========================================================================


class TestForecastingBuiltin:
    def setup_method(self):
        self.fc = get_provider("forecasting", backend="builtin")

    def test_moving_average_constant(self):
        series = [100.0] * 30
        result = self.fc.forecast(series, horizon=7, method="moving_average")
        assert len(result.values) == 7
        assert all(abs(v - 100.0) < 0.01 for v in result.values)

    def test_moving_average_trend(self):
        series = list(range(1, 31))  # 1..30
        result = self.fc.forecast(series, horizon=7, method="moving_average", window=7)
        # MA of [24..30] = 27.0
        assert all(abs(v - 27.0) < 0.01 for v in result.values)

    def test_exponential_smoothing(self):
        series = [10.0, 12.0, 14.0, 13.0, 15.0, 16.0, 14.0, 17.0]
        result = self.fc.forecast(series, horizon=7, method="exponential_smoothing")
        assert len(result.values) == 7
        assert all(v > 0 for v in result.values)
        assert result.method == "exponential_smoothing"

    def test_croston_intermittent(self):
        series = [0, 0, 5, 0, 0, 0, 3, 0, 0, 4, 0, 0, 0, 0, 2]
        result = self.fc.forecast(series, horizon=7, method="croston")
        assert len(result.values) == 7
        assert all(v > 0 for v in result.values)

    def test_croston_all_zeros(self):
        series = [0] * 20
        result = self.fc.forecast(series, horizon=7, method="croston")
        assert all(v == 0 for v in result.values)

    def test_auto_selects_croston_for_sparse(self):
        series = [0]*10 + [5] + [0]*10 + [3] + [0]*10
        result = self.fc.forecast(series, horizon=7, method="auto")
        assert "croston" in result.method

    def test_auto_selects_ets_for_regular(self):
        series = [10 + i * 0.5 for i in range(50)]
        result = self.fc.forecast(series, horizon=7, method="auto")
        assert "exponential" in result.method

    def test_seasonal_decompose(self):
        # Create series with known weekly pattern
        n = 56  # 8 weeks
        series = []
        for i in range(n):
            day = i % 7
            seasonal = [10, 12, 11, 13, 14, 20, 18][day]
            series.append(float(seasonal + i * 0.1))

        trend, seasonal, residual = self.fc.decompose(series, period=7)
        assert len(trend) == n
        assert len(seasonal) == n
        assert len(residual) == n
        # Seasonal should repeat with period 7
        for i in range(7, n):
            if not math.isnan(seasonal[i]) and not math.isnan(seasonal[i-7]):
                assert abs(seasonal[i] - seasonal[i-7]) < 0.01


class TestStatsforecast:
    def setup_method(self):
        self.fc = get_provider("forecasting", backend="statsforecast")

    def test_arima_forecast(self):
        series = [10 + i * 0.5 + (i % 7) * 2 for i in range(100)]
        result = self.fc.forecast(series, horizon=14, method="arima", season_length=7)
        assert len(result.values) == 14
        assert result.method.startswith("statsforecast:")
        assert all(v > 0 for v in result.values)

    def test_ets_forecast(self):
        series = [10 + i * 0.3 + (i % 7) * 1.5 for i in range(100)]
        result = self.fc.forecast(series, horizon=7, method="ets", season_length=7)
        assert len(result.values) == 7

    def test_croston_optimized(self):
        series = [0, 0, 5, 0, 0, 0, 3, 0, 0, 4, 0, 0, 0, 0, 2, 0, 0, 0, 6, 0]
        result = self.fc.forecast(series, horizon=7, method="croston")
        assert len(result.values) == 7
        assert all(v >= 0 for v in result.values)

    def test_auto_method(self):
        series = [50 + i * 0.2 for i in range(100)]
        result = self.fc.forecast(series, horizon=7, method="auto")
        assert len(result.values) == 7

    def test_has_prediction_intervals(self):
        series = [10 + i * 0.5 for i in range(100)]
        result = self.fc.forecast(series, horizon=7, method="ets", season_length=7)
        if result.lower_bound:
            assert len(result.lower_bound) == 7
            assert len(result.upper_bound) == 7
            # Lower should be below forecast, upper above
            for lo, val, hi in zip(result.lower_bound, result.values, result.upper_bound):
                assert lo <= val <= hi


# ===========================================================================
# Analytics — unit tests
# ===========================================================================


class TestAnalytics:
    def setup_method(self):
        self.analytics = get_provider("analytics")

    def _sample_orders(self):
        """Minimal synthetic orders for unit tests."""
        return [
            {"customer_id": "C1", "order_date": "2024-01-15", "line_total": "100"},
            {"customer_id": "C1", "order_date": "2024-03-10", "line_total": "150"},
            {"customer_id": "C1", "order_date": "2024-06-01", "line_total": "200"},
            {"customer_id": "C2", "order_date": "2024-02-20", "line_total": "50"},
            {"customer_id": "C3", "order_date": "2024-01-05", "line_total": "500"},
            {"customer_id": "C3", "order_date": "2024-01-20", "line_total": "300"},
            {"customer_id": "C3", "order_date": "2024-02-15", "line_total": "400"},
            {"customer_id": "C3", "order_date": "2024-03-10", "line_total": "250"},
            {"customer_id": "C4", "order_date": "2024-11-01", "line_total": "75"},
            {"customer_id": "C5", "order_date": "2024-12-15", "line_total": "1000"},
        ]

    def test_rfm_segmentation(self):
        orders = self._sample_orders()
        result = self.analytics.rfm_segment(orders, n_clusters=3)
        assert len(result.customers) == 5  # 5 unique customers
        assert result.n_clusters == 3
        # Each customer has required fields
        for c in result.customers:
            assert "customer_id" in c
            assert "recency" in c
            assert "frequency" in c
            assert "monetary" in c
            assert "segment" in c

    def test_customer_ltv(self):
        orders = self._sample_orders()
        result = self.analytics.customer_ltv(orders, projection_days=365)
        assert len(result.customers) == 5
        assert result.avg_ltv > 0
        # C3 should have highest LTV (most orders, high spend)
        c3 = next(c for c in result.customers if c["customer_id"] == "C3")
        assert c3["historical_ltv"] == 1450.0
        assert c3["order_count"] == 4

    def test_cohort_retention(self):
        orders = self._sample_orders()
        result = self.analytics.cohort_retention(orders, period="month")
        assert len(result.retention_matrix) > 0
        assert len(result.cohort_labels) > 0
        # First period retention should be 100% for all cohorts
        for row in result.retention_matrix:
            assert row[0] == 100.0


# ===========================================================================
# Integration tests — against sample CSV data
# ===========================================================================


class TestIntegrationInventory:
    """Run inventory models against products.csv + inventory.csv."""

    def test_eoq_all_products(self):
        """Compute EOQ for every product in the catalog."""
        products = _load_csv("products.csv")
        inv = get_provider("inventory", backend="builtin")

        results = []
        for p in products:
            annual_demand = float(p["base_daily_demand"]) * 365
            if annual_demand <= 0:
                continue
            cost = float(p["cost"])
            holding_cost = cost * 0.25  # 25% of cost as holding
            ordering_cost = 50.0  # fixed

            eoq = inv.eoq(annual_demand, ordering_cost, holding_cost)
            results.append({"sku": p["sku"], "eoq": eoq.order_quantity, "cost": eoq.total_cost})

        assert len(results) == len(products)
        # All EOQs should be positive
        assert all(r["eoq"] > 0 for r in results)

    def test_abc_from_orders(self):
        """ABC classification using actual order revenue per product."""
        orders = _load_csv("orders.csv")
        inv = get_provider("inventory")

        # Aggregate revenue per product
        revenue_by_product = {}
        for row in orders:
            pid = row["product_id"]
            rev = float(row["line_total"])
            revenue_by_product[pid] = revenue_by_product.get(pid, 0) + rev

        items = [{"sku": k, "revenue": v} for k, v in revenue_by_product.items()]
        result = inv.abc(items)

        total_a = sum(i["revenue"] for i in result["A"])
        total_all = sum(i["revenue"] for i in items)

        # A items contribute ~80% of revenue (by definition of 0.80 threshold)
        assert 0.75 < total_a / total_all <= 0.85
        # All items classified
        assert len(result["A"]) + len(result["B"]) + len(result["C"]) == len(items)
        # C items have lowest revenue contribution
        total_c = sum(i["revenue"] for i in result["C"])
        assert total_c / total_all < 0.10

    def test_stockout_risk_from_inventory(self):
        """Compute stockout risk for products using latest inventory snapshot."""
        products = _load_csv("products.csv")
        inventory_data = _load_csv("inventory.csv")
        inv = get_provider("inventory")

        # Get last day's inventory per product
        latest = {}
        for row in inventory_data:
            latest[row["product_id"]] = row  # last row wins (data is chronological)

        risks = []
        for p in products:
            pid = p["product_id"]
            if pid not in latest:
                continue
            stock = int(latest[pid]["closing_stock"])
            avg_demand = float(p["base_daily_demand"])
            lead_time = int(p["lead_time_days"])
            # Estimate demand std from CSV
            demand_std = avg_demand * 0.3  # rough approximation

            risk = inv.stockout_risk(stock, avg_demand, lead_time, demand_std)
            risks.append({"sku": p["sku"], "stock": stock, "risk": risk})

        assert len(risks) > 0
        # Some products should be at risk, some safe
        risky = [r for r in risks if r["risk"] > 0.5]
        safe = [r for r in risks if r["risk"] < 0.1]
        # We expect a mix
        assert len(risky) + len(safe) > 0

    def test_full_inventory_report(self):
        """Generate full inventory report for a product."""
        products = _load_csv("products.csv")
        p = products[0]
        inv = get_provider("inventory")

        report = inv.full_report(
            sku=p["sku"],
            annual_demand=float(p["base_daily_demand"]) * 365,
            demand_std=float(p["base_daily_demand"]) * 0.3,
            ordering_cost=50,
            holding_cost=float(p["cost"]) * 0.25,
            lead_time_days=int(p["lead_time_days"]),
            current_stock=int(p["initial_stock"]),
        )
        assert report.sku == p["sku"]
        assert report.eoq is not None
        assert report.safety_stock > 0


class TestIntegrationForecasting:
    """Run forecasting models against daily_demand.csv."""

    def _load_product_series(self, product_id: str) -> list:
        """Load daily demand series for one product."""
        demand = _load_csv("daily_demand.csv")
        return [int(r["units_sold"]) for r in demand if r["product_id"] == product_id]

    def _get_product_ids(self):
        products = _load_csv("products.csv")
        return [p["product_id"] for p in products]

    def test_builtin_moving_average_real_data(self):
        """Moving average forecast on real demand series."""
        pid = self._get_product_ids()[0]
        series = self._load_product_series(pid)
        fc = get_provider("forecasting", backend="builtin")

        result = fc.forecast(series, horizon=14, method="moving_average", window=28)
        assert len(result.values) == 14
        # Forecast should be in reasonable range of historical mean
        historical_mean = sum(series) / len(series)
        assert all(abs(v - historical_mean) < historical_mean * 3 for v in result.values)

    def test_builtin_ets_real_data(self):
        """Exponential smoothing on real demand."""
        pid = self._get_product_ids()[0]
        series = self._load_product_series(pid)
        fc = get_provider("forecasting", backend="builtin")

        result = fc.forecast(series, horizon=7, method="exponential_smoothing", alpha=0.2)
        assert len(result.values) == 7
        assert all(v >= 0 for v in result.values)

    def test_builtin_croston_intermittent_product(self):
        """Find an intermittent product and forecast with Croston."""
        products = _load_csv("products.csv")
        intermittent = [p for p in products if p["intermittent"] == "True"]
        if not intermittent:
            pytest.skip("No intermittent products in sample data")

        pid = intermittent[0]["product_id"]
        series = self._load_product_series(pid)
        fc = get_provider("forecasting", backend="builtin")

        result = fc.forecast(series, horizon=14, method="croston")
        assert len(result.values) == 14
        # Intermittent demand should produce low but non-zero forecasts
        assert all(v >= 0 for v in result.values)
        assert sum(result.values) > 0

    def test_statsforecast_arima_real_data(self):
        """AutoARIMA on real demand series."""
        pid = self._get_product_ids()[0]
        series = self._load_product_series(pid)
        fc = get_provider("forecasting", backend="statsforecast")

        result = fc.forecast(series, horizon=14, method="arima", season_length=7)
        assert len(result.values) == 14
        assert result.method.startswith("statsforecast:")

    def test_statsforecast_ets_real_data(self):
        """AutoETS on real demand series."""
        pid = self._get_product_ids()[5]
        series = self._load_product_series(pid)
        fc = get_provider("forecasting", backend="statsforecast")

        result = fc.forecast(series, horizon=7, method="ets", season_length=7)
        assert len(result.values) == 7
        # Should have prediction intervals
        if result.lower_bound:
            for lo, val, hi in zip(result.lower_bound, result.values, result.upper_bound):
                assert lo <= val <= hi

    def test_statsforecast_seasonal_naive(self):
        """SeasonalNaive — uses last season as forecast."""
        pid = self._get_product_ids()[2]
        series = self._load_product_series(pid)
        fc = get_provider("forecasting", backend="statsforecast")

        result = fc.forecast(series, horizon=7, method="seasonal_naive", season_length=7)
        assert len(result.values) == 7

    def test_compare_methods_same_series(self):
        """Compare multiple forecast methods on the same series."""
        pid = self._get_product_ids()[0]
        series = self._load_product_series(pid)

        builtin = get_provider("forecasting", backend="builtin")
        sf = get_provider("forecasting", backend="statsforecast")

        methods = {
            "ma": builtin.forecast(series, horizon=7, method="moving_average"),
            "ets_builtin": builtin.forecast(series, horizon=7, method="exponential_smoothing"),
            "arima": sf.forecast(series, horizon=7, method="arima", season_length=7),
            "ets_sf": sf.forecast(series, horizon=7, method="ets", season_length=7),
        }

        # All should produce 7 values
        for name, result in methods.items():
            assert len(result.values) == 7, f"{name} produced {len(result.values)} values"
            assert all(isinstance(v, float) for v in result.values), f"{name} has non-float values"


class TestIntegrationAnalytics:
    """Run analytics models against orders.csv + customers.csv."""

    def test_rfm_segmentation_full_dataset(self):
        """RFM segmentation on all ~130K order rows."""
        orders = _load_csv("orders.csv")
        analytics = get_provider("analytics")

        result = analytics.rfm_segment(orders, n_clusters=4)

        # Should segment all customers
        unique_customers = len(set(r["customer_id"] for r in orders))
        assert len(result.customers) == unique_customers
        assert result.n_clusters == 4
        # All segments should have members
        assert len(result.segments) > 0
        assert sum(result.segments.values()) == unique_customers
        # Champions should have high monetary value
        champions = [c for c in result.customers if c["segment"] == "Champions"]
        if champions:
            avg_champ_monetary = sum(c["monetary"] for c in champions) / len(champions)
            avg_all_monetary = sum(c["monetary"] for c in result.customers) / len(result.customers)
            assert avg_champ_monetary > avg_all_monetary

    def test_customer_ltv_full_dataset(self):
        """LTV estimation on full order dataset."""
        orders = _load_csv("orders.csv")
        analytics = get_provider("analytics")

        result = analytics.customer_ltv(orders, projection_days=365)

        assert len(result.customers) > 0
        assert result.avg_ltv > 0
        assert result.median_ltv > 0
        # Top customer should have high LTV
        top = max(result.customers, key=lambda c: c["predicted_ltv"])
        assert top["predicted_ltv"] > result.avg_ltv

    def test_cohort_retention_full_dataset(self):
        """Monthly cohort retention on full orders."""
        orders = _load_csv("orders.csv")
        analytics = get_provider("analytics")

        result = analytics.cohort_retention(orders, period="month")

        assert len(result.retention_matrix) > 0
        assert len(result.cohort_labels) > 0
        # First period is always 100% retention
        for row in result.retention_matrix:
            assert row[0] == 100.0
        # Later periods should have lower retention
        for row in result.retention_matrix:
            if len(row) > 3:
                assert row[3] <= 100.0
