"""Inventory optimization models.

Classical inventory management techniques with two backends:
    1. Built-in pure-math implementations (no dependencies)
    2. Stockpyl-backed implementations (richer models, validated)

Both backends share the same interface and return the same result types.
Use the provider system to select:

    from platoon.learning.providers import get_provider
    inv = get_provider("inventory")  # auto-selects best available
    result = inv.eoq(demand=10000, ordering_cost=50, holding_cost=2)

Or call functions directly:
    from platoon.learning.inventory import economic_order_quantity
    result = economic_order_quantity(demand=10000, ordering_cost=50, holding_cost_per_unit=2)

Theory references:
    - Deterministic: EOQ (Wilson), Economic Production Quantity
    - Stochastic: (Q,R) continuous review, (P,S) periodic review
    - Single-period: Newsvendor model
"""

import math
from dataclasses import dataclass
from typing import Any, Dict, List, Optional

from platoon.learning.providers import ModelProvider, register_provider


# ---------------------------------------------------------------------------
# Result types
# ---------------------------------------------------------------------------


@dataclass
class EOQResult:
    """Result of Economic Order Quantity calculation."""

    order_quantity: float
    annual_orders: float
    total_cost: float
    holding_cost: float
    ordering_cost: float


@dataclass
class InventoryReport:
    """Full inventory analysis for a single product/SKU."""

    sku: str
    eoq: Optional[EOQResult] = None
    safety_stock: Optional[float] = None
    reorder_point: Optional[float] = None
    stockout_risk: Optional[float] = None
    abc_class: Optional[str] = None


# ---------------------------------------------------------------------------
# Pure-math implementations (no external deps)
# ---------------------------------------------------------------------------


def economic_order_quantity(
    demand: float,
    ordering_cost: float,
    holding_cost_per_unit: float,
) -> EOQResult:
    """Calculate Economic Order Quantity (Wilson formula).

    Q* = sqrt(2 * D * S / H)

    Args:
        demand: Annual demand in units
        ordering_cost: Fixed cost per order ($)
        holding_cost_per_unit: Annual holding cost per unit ($)

    Returns:
        EOQResult with optimal order quantity and cost breakdown
    """
    if demand <= 0 or ordering_cost <= 0 or holding_cost_per_unit <= 0:
        raise ValueError("All inputs must be positive")

    q_star = math.sqrt(2 * demand * ordering_cost / holding_cost_per_unit)
    annual_orders = demand / q_star
    holding = (q_star / 2) * holding_cost_per_unit
    ordering = annual_orders * ordering_cost
    total = holding + ordering

    return EOQResult(
        order_quantity=round(q_star, 2),
        annual_orders=round(annual_orders, 2),
        total_cost=round(total, 2),
        holding_cost=round(holding, 2),
        ordering_cost=round(ordering, 2),
    )


def safety_stock(
    demand_std: float,
    lead_time_days: float,
    service_level: float = 0.95,
) -> float:
    """Calculate safety stock for a given service level.

    SS = Z(service_level) * demand_std * sqrt(lead_time)

    Args:
        demand_std: Standard deviation of daily demand
        lead_time_days: Average lead time in days
        service_level: Target service level (0.0-1.0, default 0.95)

    Returns:
        Safety stock quantity in units
    """
    if not 0 < service_level < 1:
        raise ValueError("service_level must be between 0 and 1 exclusive")

    # Z-score lookup using inverse normal approximation (Beasley-Springer-Moro)
    z = _norm_ppf(service_level)
    ss = z * demand_std * math.sqrt(lead_time_days)
    return round(ss, 2)


def reorder_point(
    avg_daily_demand: float,
    lead_time_days: float,
    safety_stock_qty: float,
) -> float:
    """Calculate reorder point.

    ROP = (avg_daily_demand * lead_time_days) + safety_stock

    Args:
        avg_daily_demand: Average daily demand in units
        lead_time_days: Average lead time in days
        safety_stock_qty: Pre-calculated safety stock

    Returns:
        Reorder point quantity
    """
    return round(avg_daily_demand * lead_time_days + safety_stock_qty, 2)


def abc_classification(
    items: List[Dict],
    value_key: str = "revenue",
    a_threshold: float = 0.80,
    b_threshold: float = 0.95,
) -> Dict[str, List[Dict]]:
    """Classify inventory items using ABC analysis (Pareto principle).

    Args:
        items: List of dicts with a value field
        value_key: Key containing the value (revenue, cost, etc.)
        a_threshold: Cumulative % cutoff for class A (default 0.80)
        b_threshold: Cumulative % cutoff for class B (default 0.95)

    Returns:
        Dict with keys "A", "B", "C", each containing classified items.
        Each item gets an added "abc_class" and "cumulative_pct" field.
    """
    if not items:
        return {"A": [], "B": [], "C": []}

    total = sum(item[value_key] for item in items)
    if total == 0:
        return {"A": [], "B": [], "C": list(items)}

    sorted_items = sorted(items, key=lambda x: x[value_key], reverse=True)

    result: Dict[str, List[Dict]] = {"A": [], "B": [], "C": []}
    cumulative = 0.0

    for item in sorted_items:
        cumulative += item[value_key] / total
        enriched = {**item, "cumulative_pct": round(cumulative, 4)}

        if cumulative <= a_threshold:
            enriched["abc_class"] = "A"
            result["A"].append(enriched)
        elif cumulative <= b_threshold:
            enriched["abc_class"] = "B"
            result["B"].append(enriched)
        else:
            enriched["abc_class"] = "C"
            result["C"].append(enriched)

    return result


def stockout_risk_score(
    current_stock: int,
    avg_daily_demand: float,
    lead_time_days: float,
    demand_std: float = 0.0,
) -> float:
    """Calculate a 0-1 stockout risk score.

    Uses the probability that demand during lead time exceeds current stock.
    With demand_std=0 (deterministic), returns 0 or 1.
    With demand_std>0 (stochastic), returns P(demand > stock).

    Args:
        current_stock: Current inventory on hand
        avg_daily_demand: Average daily demand
        lead_time_days: Days until next replenishment
        demand_std: Standard deviation of daily demand

    Returns:
        Risk score between 0.0 (safe) and 1.0 (certain stockout)
    """
    expected_demand = avg_daily_demand * lead_time_days

    if demand_std <= 0:
        # Deterministic: binary risk
        return 1.0 if current_stock < expected_demand else 0.0

    # Stochastic: P(demand during lead time > current stock)
    demand_std_lt = demand_std * math.sqrt(lead_time_days)
    if demand_std_lt <= 0:
        return 1.0 if current_stock < expected_demand else 0.0

    z = (current_stock - expected_demand) / demand_std_lt
    # P(X > stock) = 1 - Phi(z)
    return round(1.0 - _norm_cdf(z), 4)


# ---------------------------------------------------------------------------
# Built-in provider (pure math, no deps)
# ---------------------------------------------------------------------------


@register_provider
class BuiltinInventoryProvider(ModelProvider):
    """Pure-math inventory optimization — no external dependencies."""

    name = "builtin_inventory"
    domain = "inventory"
    backend = "builtin"

    def eoq(self, demand: float, ordering_cost: float, holding_cost: float) -> EOQResult:
        return economic_order_quantity(demand, ordering_cost, holding_cost)

    def safety_stock(self, demand_std: float, lead_time: float, service_level: float = 0.95) -> float:
        return safety_stock(demand_std, lead_time, service_level)

    def reorder_point(self, avg_demand: float, lead_time: float, ss: float) -> float:
        return reorder_point(avg_demand, lead_time, ss)

    def abc(self, items: List[Dict], value_key: str = "revenue") -> Dict[str, List[Dict]]:
        return abc_classification(items, value_key)

    def stockout_risk(self, stock: int, avg_demand: float, lead_time: float, std: float = 0.0) -> float:
        return stockout_risk_score(stock, avg_demand, lead_time, std)

    def full_report(
        self,
        sku: str,
        annual_demand: float,
        demand_std: float,
        ordering_cost: float,
        holding_cost: float,
        lead_time_days: float,
        current_stock: int,
        service_level: float = 0.95,
        revenue: float = 0.0,
    ) -> InventoryReport:
        """Run all inventory models for a single SKU."""
        eoq_result = self.eoq(annual_demand, ordering_cost, holding_cost)
        ss = self.safety_stock(demand_std, lead_time_days, service_level)
        avg_daily = annual_demand / 365
        rop = self.reorder_point(avg_daily, lead_time_days, ss)
        risk = self.stockout_risk(current_stock, avg_daily, lead_time_days, demand_std)

        return InventoryReport(
            sku=sku,
            eoq=eoq_result,
            safety_stock=ss,
            reorder_point=rop,
            stockout_risk=risk,
        )


# ---------------------------------------------------------------------------
# Stockpyl provider (library-backed, richer models)
# ---------------------------------------------------------------------------


@register_provider
class StockpylInventoryProvider(ModelProvider):
    """Stockpyl-backed inventory optimization — textbook algorithms.

    Adds newsvendor model, (s,S) policies, and multi-echelon support
    beyond what the built-in math provides.
    """

    name = "stockpyl_inventory"
    domain = "inventory"
    backend = "stockpyl"

    @classmethod
    def is_available(cls) -> bool:
        try:
            import stockpyl
            return True
        except ImportError:
            return False

    def eoq(self, demand: float, ordering_cost: float, holding_cost: float) -> EOQResult:
        """EOQ using stockpyl's implementation."""
        from stockpyl.eoq import economic_order_quantity as sp_eoq

        q, cost = sp_eoq(ordering_cost, holding_cost, demand)
        annual_orders = demand / q
        holding = (q / 2) * holding_cost
        ordering = annual_orders * ordering_cost
        return EOQResult(
            order_quantity=round(q, 2),
            annual_orders=round(annual_orders, 2),
            total_cost=round(cost, 2),
            holding_cost=round(holding, 2),
            ordering_cost=round(ordering, 2),
        )

    def safety_stock(self, demand_std: float, lead_time: float, service_level: float = 0.95) -> float:
        """Safety stock — delegates to built-in (stockpyl uses same formula)."""
        return safety_stock(demand_std, lead_time, service_level)

    def reorder_point(self, avg_demand: float, lead_time: float, ss: float) -> float:
        return reorder_point(avg_demand, lead_time, ss)

    def abc(self, items: List[Dict], value_key: str = "revenue") -> Dict[str, List[Dict]]:
        return abc_classification(items, value_key)

    def stockout_risk(self, stock: int, avg_demand: float, lead_time: float, std: float = 0.0) -> float:
        return stockout_risk_score(stock, avg_demand, lead_time, std)

    def newsvendor(
        self,
        selling_price: float,
        cost: float,
        demand_mean: float,
        demand_std: float,
        salvage_value: float = 0.0,
    ) -> Dict[str, float]:
        """Newsvendor model — optimal order quantity for single-period problem.

        For perishable goods, seasonal items, or one-time buys.

        Args:
            selling_price: Revenue per unit sold
            cost: Cost per unit ordered
            demand_mean: Mean of demand distribution
            demand_std: Std dev of demand distribution
            salvage_value: Revenue per unsold unit (default 0)

        Returns:
            Dict with optimal_quantity, expected_profit, critical_ratio
        """
        from stockpyl.newsvendor import newsvendor_normal

        q, cost_result = newsvendor_normal(
            holding_cost=cost - salvage_value,
            stockout_cost=selling_price - cost,
            demand_mean=demand_mean,
            demand_sd=demand_std,
        )
        critical_ratio = (selling_price - cost) / (selling_price - salvage_value)

        return {
            "optimal_quantity": round(q, 2),
            "expected_cost": round(cost_result, 2),
            "critical_ratio": round(critical_ratio, 4),
        }

    def full_report(
        self,
        sku: str,
        annual_demand: float,
        demand_std: float,
        ordering_cost: float,
        holding_cost: float,
        lead_time_days: float,
        current_stock: int,
        service_level: float = 0.95,
        revenue: float = 0.0,
    ) -> InventoryReport:
        """Run all inventory models for a single SKU."""
        eoq_result = self.eoq(annual_demand, ordering_cost, holding_cost)
        ss = self.safety_stock(demand_std, lead_time_days, service_level)
        avg_daily = annual_demand / 365
        rop = self.reorder_point(avg_daily, lead_time_days, ss)
        risk = self.stockout_risk(current_stock, avg_daily, lead_time_days, demand_std)

        return InventoryReport(
            sku=sku,
            eoq=eoq_result,
            safety_stock=ss,
            reorder_point=rop,
            stockout_risk=risk,
        )


# ---------------------------------------------------------------------------
# Math helpers (no scipy needed)
# ---------------------------------------------------------------------------


def _norm_cdf(x: float) -> float:
    """Standard normal CDF approximation (Abramowitz & Stegun)."""
    return 0.5 * (1.0 + math.erf(x / math.sqrt(2.0)))


def _norm_ppf(p: float) -> float:
    """Inverse standard normal (rational approximation, Beasley-Springer-Moro).

    Accurate to ~1e-9 for 0.0001 < p < 0.9999.
    """
    if p <= 0 or p >= 1:
        raise ValueError("p must be between 0 and 1 exclusive")

    # Rational approximation constants
    a = [
        -3.969683028665376e01, 2.209460984245205e02,
        -2.759285104469687e02, 1.383577518672690e02,
        -3.066479806614716e01, 2.506628277459239e00,
    ]
    b = [
        -5.447609879822406e01, 1.615858368580409e02,
        -1.556989798598866e02, 6.680131188771972e01,
        -1.328068155288572e01,
    ]
    c = [
        -7.784894002430293e-03, -3.223964580411365e-01,
        -2.400758277161838e00, -2.549732539343734e00,
        4.374664141464968e00, 2.938163982698783e00,
    ]
    d = [
        7.784695709041462e-03, 3.224671290700398e-01,
        2.445134137142996e00, 3.754408661907416e00,
    ]

    p_low = 0.02425
    p_high = 1.0 - p_low

    if p < p_low:
        q = math.sqrt(-2.0 * math.log(p))
        return (((((c[0]*q+c[1])*q+c[2])*q+c[3])*q+c[4])*q+c[5]) / \
               ((((d[0]*q+d[1])*q+d[2])*q+d[3])*q+1.0)
    elif p <= p_high:
        q = p - 0.5
        r = q * q
        return (((((a[0]*r+a[1])*r+a[2])*r+a[3])*r+a[4])*r+a[5])*q / \
               (((((b[0]*r+b[1])*r+b[2])*r+b[3])*r+b[4])*r+1.0)
    else:
        q = math.sqrt(-2.0 * math.log(1.0 - p))
        return -(((((c[0]*q+c[1])*q+c[2])*q+c[3])*q+c[4])*q+c[5]) / \
                ((((d[0]*q+d[1])*q+d[2])*q+d[3])*q+1.0)
