"""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 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
        assert "anomaly" in domains
        assert "basket" in domains
        assert "cashflow" in domains
        assert "scheduling" in domains
        assert "markdown" in domains
        assert "promotion" in domains
        assert "leadtime" in domains
        assert "seasonal" 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
# ===========================================================================


# ===========================================================================
# Anomaly detection — unit tests
# ===========================================================================


class TestAnomalyBuiltin:
    def setup_method(self):
        self.ad = get_provider("anomaly", backend="builtin")

    def test_zscore_detects_spike(self):
        """Z-score detects an obvious spike."""
        series = [10.0] * 50 + [100.0] + [10.0] * 10
        result = self.ad.detect(series, method="zscore", window=30, threshold=2.0)
        assert result.anomaly_count >= 1
        spike = [a for a in result.anomalies if a.value == 100.0]
        assert len(spike) >= 1
        assert spike[0].direction == "spike"

    def test_zscore_detects_drop(self):
        """Z-score detects an obvious drop."""
        series = [50.0] * 50 + [0.0] + [50.0] * 10
        result = self.ad.detect(series, method="zscore", window=30, threshold=2.0)
        drops = [a for a in result.anomalies if a.direction == "drop"]
        assert len(drops) >= 1

    def test_iqr_method(self):
        """IQR method detects outliers."""
        series = [10.0] * 50 + [100.0] + [10.0] * 10
        result = self.ad.detect(series, method="iqr", window=30)
        assert result.anomaly_count >= 1
        assert result.method == "iqr"

    def test_no_anomalies_in_constant(self):
        """Constant series has no anomalies."""
        series = [10.0] * 100
        result = self.ad.detect(series, method="zscore", window=30, threshold=2.0)
        assert result.anomaly_count == 0

    def test_severity_levels(self):
        """Severity increases with deviation magnitude."""
        series = [10.0] * 50 + [50.0, 100.0, 200.0] + [10.0] * 10
        result = self.ad.detect(series, method="zscore", window=30, threshold=2.0)
        if result.anomalies:
            severities = {a.severity for a in result.anomalies}
            assert len(severities) >= 1  # at least one severity level

    def test_dates_preserved(self):
        """Date labels are preserved in anomaly output."""
        dates = [f"2024-01-{i+1:02d}" for i in range(30)]
        series = [10.0] * 20 + [100.0] + [10.0] * 9
        result = self.ad.detect(series, dates=dates, method="zscore", window=10, threshold=2.0)
        if result.anomalies:
            assert result.anomalies[0].date.startswith("2024-01-")

    def test_result_stats(self):
        """Result includes correct series-level statistics."""
        series = [10.0] * 100
        result = self.ad.detect(series, method="zscore")
        assert result.total_points == 100
        assert result.series_mean == 10.0
        assert result.series_std == 0.0


# ===========================================================================
# Basket analysis — unit tests
# ===========================================================================


class TestBasketBuiltin:
    def setup_method(self):
        self.ba = get_provider("basket", backend="builtin")

    def test_basic_association(self):
        """Finds obvious co-occurrence pattern."""
        # A and B always bought together, C alone
        transactions = [
            {"A", "B"}, {"A", "B"}, {"A", "B"}, {"A", "B"}, {"A", "B"},
            {"C", "D"}, {"C", "D"}, {"C", "D"},
            {"A", "B", "C"},
        ]
        result = self.ba.analyze(transactions, min_support=0.01, min_confidence=0.3)
        assert result.total_transactions == 9
        # A→B should have high confidence
        ab_rules = [r for r in result.rules if r.antecedent == "A" and r.consequent == "B"]
        assert len(ab_rules) >= 1
        assert ab_rules[0].confidence >= 0.8

    def test_lift_above_one(self):
        """Positive associations have lift > 1."""
        transactions = [
            {"A", "B"}, {"A", "B"}, {"A", "B"}, {"A", "B"},
            {"C", "D"}, {"C", "D"},
            {"A", "C"}, {"B", "D"},
        ]
        result = self.ba.analyze(transactions, min_support=0.01, min_confidence=0.3)
        for r in result.rules:
            assert r.lift > 0  # lift is always positive

    def test_empty_transactions(self):
        """Empty input returns empty result."""
        result = self.ba.analyze([], min_support=0.01, min_confidence=0.3)
        assert result.total_transactions == 0
        assert result.rules == []

    def test_single_item_transactions_no_rules(self):
        """Single-item transactions can't produce rules."""
        transactions = [{"A"}, {"B"}, {"C"}]
        result = self.ba.analyze(transactions, min_support=0.01, min_confidence=0.3)
        assert result.rules == []

    def test_min_confidence_filters(self):
        """Higher min_confidence reduces rule count."""
        transactions = [
            {"A", "B"}, {"A", "C"}, {"A", "D"}, {"B", "C"},
            {"A", "B"}, {"A", "C"}, {"B", "D"}, {"C", "D"},
        ]
        low = self.ba.analyze(transactions, min_support=0.01, min_confidence=0.1)
        high = self.ba.analyze(transactions, min_support=0.01, min_confidence=0.8)
        assert len(high.rules) <= len(low.rules)

    def test_sorted_by_lift(self):
        """Rules are sorted by lift descending."""
        transactions = [
            {"A", "B"}, {"A", "B"}, {"A", "B"},
            {"C", "D"}, {"C", "D"},
            {"A", "C"}, {"B", "D"},
        ]
        result = self.ba.analyze(transactions, min_support=0.01, min_confidence=0.1)
        lifts = [r.lift for r in result.rules]
        assert lifts == sorted(lifts, reverse=True)


# ===========================================================================
# Cash flow — unit tests
# ===========================================================================


class TestCashFlowBuiltin:
    def setup_method(self):
        self.cf = get_provider("cashflow", backend="builtin")

    def test_basic_projection(self):
        """Projects revenue and COGS correctly."""
        products = [{"product_id": "P1", "price": 100, "cost": 40, "lead_time_days": 7}]
        demand = {"P1": [10.0] * 7}
        result = self.cf.project(products, demand, horizon=7)

        assert result.horizon_days == 7
        assert len(result.periods) == 7
        # Revenue = 10 * 100 = 1000/day
        assert result.summary["total_revenue"] == 7000.0
        # COGS = 10 * 40 = 400/day
        assert result.summary["total_cogs"] == 2800.0

    def test_with_inventory_tracking(self):
        """Stock decreases and reorders fire when below ROP."""
        products = [{"product_id": "P1", "price": 100, "cost": 40, "lead_time_days": 3}]
        demand = {"P1": [10.0] * 30}
        inventory_state = {
            "P1": {"stock": 50, "reorder_point": 30, "eoq": 100},
        }
        result = self.cf.project(products, demand, inventory_state, horizon=30)

        # Should have at least one reorder event
        assert len(result.reorder_events) >= 1
        assert result.summary["total_reorder_costs"] > 0

    def test_zero_demand(self):
        """Zero demand produces zero revenue."""
        products = [{"product_id": "P1", "price": 100, "cost": 40, "lead_time_days": 7}]
        demand = {"P1": [0.0] * 7}
        result = self.cf.project(products, demand, horizon=7)
        assert result.summary["total_revenue"] == 0

    def test_multiple_products(self):
        """Multi-product projection aggregates correctly."""
        products = [
            {"product_id": "P1", "price": 100, "cost": 40, "lead_time_days": 7},
            {"product_id": "P2", "price": 50, "cost": 20, "lead_time_days": 5},
        ]
        demand = {"P1": [10.0] * 7, "P2": [20.0] * 7}
        result = self.cf.project(products, demand, horizon=7)

        # P1: 10*100=1000/day, P2: 20*50=1000/day → 2000/day → 14000 total
        assert result.summary["total_revenue"] == 14000.0

    def test_summary_fields(self):
        """Summary includes all expected keys."""
        products = [{"product_id": "P1", "price": 100, "cost": 40, "lead_time_days": 7}]
        demand = {"P1": [10.0] * 7}
        result = self.cf.project(products, demand, horizon=7)

        expected_keys = {"total_revenue", "total_cogs", "total_gross_profit",
                         "total_reorder_costs", "net_cash", "avg_daily_revenue", "avg_daily_profit"}
        assert expected_keys.issubset(set(result.summary.keys()))


# ===========================================================================
# Scheduling — unit tests
# ===========================================================================


class TestSchedulingBuiltin:
    def setup_method(self):
        self.sched = get_provider("scheduling", backend="builtin")

    def test_basic_scheduling(self):
        """Assigns staff to slots."""
        demand = {"Monday": 16.0, "Tuesday": 8.0}
        staff = [
            {"staff_id": "S1", "name": "Alice", "hourly_rate": 15, "max_hours_per_week": 40, "available_days": "Monday,Tuesday"},
            {"staff_id": "S2", "name": "Bob", "hourly_rate": 20, "max_hours_per_week": 40, "available_days": "Monday,Tuesday"},
        ]
        result = self.sched.schedule(demand, staff, shift_hours=8)

        assert result.total_hours > 0
        assert result.total_cost > 0
        assert len(result.assignments) >= 2

    def test_cheapest_first(self):
        """Cheapest staff are assigned first."""
        demand = {"Monday": 8.0}
        staff = [
            {"staff_id": "S1", "name": "Expensive", "hourly_rate": 50, "max_hours_per_week": 40, "available_days": "Monday"},
            {"staff_id": "S2", "name": "Cheap", "hourly_rate": 10, "max_hours_per_week": 40, "available_days": "Monday"},
        ]
        result = self.sched.schedule(demand, staff, shift_hours=8)

        # First assignment should be the cheap staff
        assert result.assignments[0].staff_name == "Cheap"

    def test_availability_respected(self):
        """Staff not available on a day are not assigned."""
        demand = {"Monday": 8.0, "Saturday": 8.0}
        staff = [
            {"staff_id": "S1", "name": "Weekday", "hourly_rate": 15, "max_hours_per_week": 40, "available_days": "Monday,Tuesday"},
            {"staff_id": "S2", "name": "Weekend", "hourly_rate": 15, "max_hours_per_week": 16, "available_days": "Saturday,Sunday"},
        ]
        result = self.sched.schedule(demand, staff, shift_hours=8)

        for a in result.assignments:
            if a.day == "Monday":
                assert a.staff_name == "Weekday"
            if a.day == "Saturday":
                assert a.staff_name == "Weekend"

    def test_max_hours_respected(self):
        """Staff can't exceed their max_hours_per_week."""
        demand = {f"Day{i}": 8.0 for i in range(7)}  # 56 hours total
        staff = [
            {"staff_id": "S1", "name": "PartTime", "hourly_rate": 15, "max_hours_per_week": 16,
             "available_days": "Day0,Day1,Day2,Day3,Day4,Day5,Day6"},
        ]
        result = self.sched.schedule(demand, staff, shift_hours=8)
        s1_hours = sum(a.hours for a in result.assignments if a.staff_id == "S1")
        assert s1_hours <= 16

    def test_coverage_gaps_reported(self):
        """Reports gaps when demand exceeds available staff."""
        demand = {"Monday": 100.0}  # needs many staff
        staff = [
            {"staff_id": "S1", "name": "Alice", "hourly_rate": 15, "max_hours_per_week": 40, "available_days": "Monday"},
        ]
        result = self.sched.schedule(demand, staff, shift_hours=8)
        assert len(result.coverage_gaps) >= 1
        assert result.coverage_gaps[0]["gap"] > 0

    def test_empty_demand(self):
        """No demand produces empty schedule."""
        result = self.sched.schedule({}, [], shift_hours=8)
        assert result.total_cost == 0
        assert result.total_hours == 0
        assert result.assignments == []


class TestSkillBasedScheduling:
    """Tests for skill-based scheduling (tutor scheduling use case)."""

    def setup_method(self):
        self.sched = get_provider("scheduling", backend="builtin")

    def test_skill_filter_assigns_matching_staff(self):
        """Only staff with matching skills are assigned to skill-required slots."""
        demand = {"Monday_math": 8.0}
        staff = [
            {"staff_id": "T1", "name": "MathTeacher", "hourly_rate": 25,
             "max_hours_per_week": 40, "available_days": "Monday", "skills": "math,AP_calc"},
            {"staff_id": "T2", "name": "EnglishTeacher", "hourly_rate": 20,
             "max_hours_per_week": 40, "available_days": "Monday", "skills": "english,history"},
        ]
        skills_by_slot = {"Monday_math": ["math", "AP_calc"]}
        result = self.sched.schedule(demand, staff, shift_hours=8, required_skills_by_slot=skills_by_slot)

        assert len(result.assignments) == 1
        assert result.assignments[0].staff_name == "MathTeacher"

    def test_skill_filter_case_insensitive(self):
        """Skill matching is case-insensitive."""
        demand = {"Monday_sci": 8.0}
        staff = [
            {"staff_id": "T1", "name": "Scientist", "hourly_rate": 25,
             "max_hours_per_week": 40, "available_days": "Monday", "skills": "Physics,CHEMISTRY"},
        ]
        skills_by_slot = {"Monday_sci": ["physics"]}
        result = self.sched.schedule(demand, staff, shift_hours=8, required_skills_by_slot=skills_by_slot)

        assert len(result.assignments) == 1
        assert result.assignments[0].staff_name == "Scientist"

    def test_skill_or_semantics(self):
        """Staff needs at least one matching skill (OR, not AND)."""
        demand = {"Monday_science": 8.0}
        staff = [
            {"staff_id": "T1", "name": "PhysicsOnly", "hourly_rate": 20,
             "max_hours_per_week": 40, "available_days": "Monday", "skills": "physics"},
        ]
        # Slot requires physics OR chemistry OR biology — staff has physics
        skills_by_slot = {"Monday_science": ["physics", "chemistry", "biology"]}
        result = self.sched.schedule(demand, staff, shift_hours=8, required_skills_by_slot=skills_by_slot)

        assert len(result.assignments) == 1

    def test_no_skills_required_backward_compatible(self):
        """Slots without skill requirements accept any staff (backward compat)."""
        demand = {"Monday": 8.0}
        staff = [
            {"staff_id": "T1", "name": "Anyone", "hourly_rate": 15,
             "max_hours_per_week": 40, "available_days": "Monday"},
        ]
        # No skills_by_slot → backward compatible
        result = self.sched.schedule(demand, staff, shift_hours=8)
        assert len(result.assignments) == 1

    def test_none_skills_param_backward_compatible(self):
        """required_skills_by_slot=None preserves existing behavior."""
        demand = {"Monday": 8.0}
        staff = [
            {"staff_id": "T1", "name": "Anyone", "hourly_rate": 15,
             "max_hours_per_week": 40, "available_days": "Monday"},
        ]
        result = self.sched.schedule(demand, staff, shift_hours=8, required_skills_by_slot=None)
        assert len(result.assignments) == 1

    def test_coverage_gap_when_no_matching_skills(self):
        """Reports gap when no staff have the required skills."""
        demand = {"Monday_art": 8.0}
        staff = [
            {"staff_id": "T1", "name": "MathOnly", "hourly_rate": 20,
             "max_hours_per_week": 40, "available_days": "Monday", "skills": "math"},
        ]
        skills_by_slot = {"Monday_art": ["art", "music"]}
        result = self.sched.schedule(demand, staff, shift_hours=8, required_skills_by_slot=skills_by_slot)

        assert len(result.assignments) == 0
        assert len(result.coverage_gaps) == 1
        assert result.coverage_gaps[0]["slot"] == "Monday_art"


# ===========================================================================
# 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


class TestIntegrationAnomaly:
    """Run anomaly detection against daily_demand.csv."""

    def test_anomaly_detection_real_data(self):
        """Detect anomalies in real demand series."""
        demand = _load_csv("daily_demand.csv")
        products = _load_csv("products.csv")
        pid = products[0]["product_id"]

        series = [float(r["units_sold"]) for r in demand if r["product_id"] == pid]
        dates = [r["date"] for r in demand if r["product_id"] == pid]

        ad = get_provider("anomaly")
        result = ad.detect(series, dates, method="zscore", window=30, threshold=2.5)

        assert result.total_points == len(series)
        assert result.anomaly_rate >= 0
        # Holiday spikes should be detected
        assert result.anomaly_count >= 0


class TestIntegrationBasket:
    """Run basket analysis against orders.csv."""

    def test_basket_real_orders(self):
        """Find association rules in real order data."""
        orders = _load_csv("orders.csv")

        # Group by order_id
        order_items: dict[str, set] = {}
        for r in orders:
            oid = r["order_id"]
            if oid not in order_items:
                order_items[oid] = set()
            order_items[oid].add(r["product_id"])

        transactions = [items for items in order_items.values() if len(items) >= 2]

        ba = get_provider("basket")
        result = ba.analyze(transactions, min_support=0.001, min_confidence=0.01)

        assert result.total_transactions == len(transactions)
        assert result.total_transactions > 0
        # Should find some rules in real data (uniform 80-product catalog has low confidence)
        assert len(result.rules) > 0
