"""Controller methods for MCP tools — all business logic lives here.

Tools call these controllers and return their results directly.
Controllers handle CSV loading, series extraction, provider selection,
and result formatting. CSV rows are validated through canonical Pydantic
models (platoon.data.models) at the boundary.
"""

from __future__ import annotations

import csv
import time
from pathlib import Path
from typing import Any, Optional

from platoon.data.models import (
    DemandRecord,
    DemandSlot,
    InventorySnapshot,
    OrderLine,
    ProductRecord,
    StaffMember,
)


def read_file(path: str, head: int = 0) -> dict[str, Any]:
    """Read a file and return its contents.

    Supports CSV (returns rows as list of dicts) and plain text.

    Args:
        path: Absolute or relative file path.
        head: If > 0, return only the first N rows/lines.
    """
    p = Path(path)
    if not p.exists():
        return {"status": "error", "error": f"File not found: {path}"}
    if not p.is_file():
        return {"status": "error", "error": f"Not a file: {path}"}

    size = p.stat().st_size
    if size > 50_000_000:  # 50MB guard
        return {"status": "error", "error": f"File too large: {size:,} bytes (max 50MB)"}

    if p.suffix.lower() == ".csv":
        with open(p) as f:
            rows = list(csv.DictReader(f))
        if head > 0:
            rows = rows[:head]
        return {
            "status": "ok",
            "format": "csv",
            "path": str(p),
            "columns": list(rows[0].keys()) if rows else [],
            "row_count": len(rows),
            "rows": rows,
        }

    text = p.read_text()
    lines = text.splitlines()
    if head > 0:
        lines = lines[:head]
        text = "\n".join(lines)

    return {
        "status": "ok",
        "format": "text",
        "path": str(p),
        "line_count": len(lines),
        "content": text,
    }


def _validate_csv(model_cls, rows: list[dict], strict: bool = False):
    """Validate CSV rows against a Pydantic model. Returns (records, error_dict|None).

    In lenient mode (default), returns valid records even if some rows fail.
    Controllers still pass dicts downstream — this validates at the boundary only.
    """
    try:
        records = model_cls.validate_csv(rows)
        return records, None
    except ValueError as e:
        if strict:
            return None, {"status": "error", "error": str(e)}
        # Lenient: use whatever validated successfully
        return e.valid_records, None  # type: ignore[attr-defined]


def _series_std(series: list[float]) -> float:
    """Standard deviation of a series (population)."""
    if len(series) < 2:
        return 0.0
    mean = sum(series) / len(series)
    return (sum((x - mean) ** 2 for x in series) / len(series)) ** 0.5


def forecast(
    data_path: str,
    product_id: Optional[str] = None,
    horizon: int = 14,
    method: str = "auto",
    season_length: int = 7,
    holdout: int = 30,
) -> dict[str, Any]:
    """Run demand forecasting on a CSV time series.

    Loads CSV, extracts product series, runs forecast, returns structured results.

    Args:
        data_path: Path to daily_demand.csv (columns: date, product_id, units_sold).
        product_id: Which product to forecast. If None, picks the highest-volume product.
        horizon: Days ahead to forecast.
        method: Model — auto, moving_average, exponential_smoothing, croston, arima, ets, theta.
        season_length: Seasonal period (default 7 for weekly).
        holdout: Days to hold out for accuracy evaluation.
    """
    from platoon.learning.providers import get_provider

    # Validate horizon
    if horizon < 1 and holdout < 1:
        return {"status": "error", "error": "horizon must be >= 1 (nothing to forecast)"}

    # Load data
    p = Path(data_path)
    if not p.exists():
        return {"status": "error", "error": f"File not found: {data_path}"}

    with open(p) as f:
        rows = list(csv.DictReader(f))

    if not rows:
        return {"status": "error", "error": "CSV is empty"}

    required = {"date", "product_id", "units_sold"}
    actual = set(rows[0].keys())
    if not required.issubset(actual):
        return {"status": "error", "error": f"CSV must have columns: {required}. Found: {actual}"}

    # Validate through model (lenient — bad rows skipped)
    _validate_csv(DemandRecord, rows)

    # Resolve product
    if not product_id:
        totals: dict[str, float] = {}
        for r in rows:
            totals[r["product_id"]] = totals.get(r["product_id"], 0) + float(r["units_sold"])
        product_id = max(totals, key=totals.get)

    # Extract series
    filtered = [r for r in rows if r["product_id"] == product_id]
    if not filtered:
        available = sorted(set(r["product_id"] for r in rows))[:10]
        return {"status": "error", "error": f"No data for '{product_id}'", "available": available}

    dates = [r["date"] for r in filtered]
    series = [float(r["units_sold"]) for r in filtered]

    # Split train/holdout
    if 0 < holdout < len(series):
        train = series[:-holdout]
        actual_values = series[-holdout:]
    else:
        train = series
        actual_values = None
        holdout = 0

    # Pick backend
    sf_methods = {"arima", "ets", "theta", "seasonal_naive", "auto"}
    builtin_methods = {"moving_average", "exponential_smoothing", "croston", "seasonal_decompose"}

    if method in sf_methods:
        backend = "statsforecast"
    elif method in builtin_methods:
        backend = "builtin"
    else:
        backend = "statsforecast"

    try:
        provider = get_provider("forecasting", backend=backend)
    except (ValueError, ImportError) as e:
        # Fall back to builtin
        provider = get_provider("forecasting", backend="builtin")
        if method in sf_methods:
            method = "exponential_smoothing"

    # Run forecast
    forecast_horizon = holdout if actual_values else horizon
    # Only pass season_length to methods that use it
    kwargs = {}
    if method not in ("moving_average", "exponential_smoothing", "croston"):
        kwargs["season_length"] = season_length

    t0 = time.time()
    try:
        result = provider.forecast(train, horizon=forecast_horizon, method=method, **kwargs)
    except Exception as e:
        return {"status": "error", "error": f"Forecast failed: {e}"}
    elapsed_ms = (time.time() - t0) * 1000

    # Compute accuracy
    mae = None
    if actual_values:
        n = min(len(actual_values), len(result.values))
        mae = sum(abs(a - p) for a, p in zip(actual_values[:n], result.values[:n])) / n

    zero_pct = sum(1 for v in series if v == 0) / len(series) * 100

    # Use the provider's reported method when it includes backend info,
    # otherwise prepend the backend name for clarity
    if result.method and ":" in result.method:
        reported_method = result.method
    else:
        reported_method = f"{backend}:{method}"

    # Post-process confidence intervals
    lower = result.lower_bound
    upper = result.upper_bound

    # Generate synthetic CIs when the model doesn't provide them (e.g. Croston)
    if not lower or not upper:
        std = _series_std(train)
        lower = [round(max(0, v - 1.96 * std), 4) for v in result.values]
        upper = [round(v + 1.96 * std, 4) for v in result.values]

    # Clamp lower bounds to 0 — demand can't be negative
    lower = [round(max(0, v), 4) for v in lower]

    # Detect trend: compare first half vs second half of forecast
    fvals = result.values[:forecast_horizon]
    trend = None
    if len(fvals) >= 2:
        mid = len(fvals) // 2
        first_half = sum(fvals[:mid]) / mid if mid else 0
        second_half = sum(fvals[mid:]) / (len(fvals) - mid) if (len(fvals) - mid) else 0
        if first_half > 0:
            pct = (second_half - first_half) / first_half * 100
            if pct > 5:
                trend = "up"
            elif pct < -5:
                trend = "down"
            else:
                trend = "stable"

    # Detect seasonality: check if forecast repeats with period = season_length
    seasonality_detected = False
    if len(fvals) >= season_length * 2:
        cycle1 = fvals[:season_length]
        cycle2 = fvals[season_length:season_length * 2]
        if cycle1 and cycle2:
            diffs = [abs(a - b) for a, b in zip(cycle1, cycle2)]
            max_val = max(max(cycle1), max(cycle2), 1)
            if sum(diffs) / len(diffs) / max_val < 0.05:  # <5% deviation = repeating
                seasonality_detected = True

    # Warn about insufficient data
    warning = None
    if len(train) < 7:
        warning = f"Very short series ({len(train)} points) — forecast may be unreliable"

    out = {
        "status": "ok",
        "product_id": product_id,
        "method": reported_method,
        "series_length": len(series),
        "zero_pct": round(zero_pct, 1),
        "train_length": len(train),
        "holdout_length": holdout,
        "horizon": forecast_horizon,
        "forecast": result.values,
        "lower_bound": lower,
        "upper_bound": upper,
        "predicted_sum": round(sum(fvals), 1),
        "predicted_mean": round(sum(fvals) / len(fvals), 2) if fvals else 0,
        "trend": trend,
        "seasonality_detected": seasonality_detected,
        "mae": round(mae, 2) if mae is not None else None,
        "elapsed_ms": round(elapsed_ms, 1),
    }
    if warning:
        out["warning"] = warning
    return out


def optimize(
    data_path: str,
    product_id: Optional[str] = None,
    orders_path: Optional[str] = None,
    inventory_path: Optional[str] = None,
    service_level: float = 0.95,
) -> dict[str, Any]:
    """Run inventory optimization on CSV data.

    Computes EOQ, safety stock, reorder point, ABC classification,
    and stockout risk for products.

    Args:
        data_path: Path to products.csv (columns: product_id, sku, cost, price, base_daily_demand, lead_time_days).
        product_id: Analyze a single product. If None, analyzes all.
        orders_path: Optional orders.csv for ABC classification.
        inventory_path: Optional inventory.csv for current stock levels.
        service_level: Target service level for safety stock (0-1, default 0.95).
    """
    from platoon.learning.providers import get_provider

    # Validate service_level
    if not (0 < service_level < 1):
        return {"status": "error", "error": f"service_level must be between 0 and 1 exclusive, got {service_level}"}

    p = Path(data_path)
    if not p.exists():
        return {"status": "error", "error": f"File not found: {data_path}"}

    with open(p) as f:
        products = list(csv.DictReader(f))

    if not products:
        return {"status": "error", "error": "Products CSV is empty"}

    # Validate through model (lenient)
    _validate_csv(ProductRecord, products)

    inv = get_provider("inventory")

    # Filter to single product if specified
    if product_id:
        products = [r for r in products if r["product_id"] == product_id]
        if not products:
            return {"status": "error", "error": f"Product '{product_id}' not found"}

    # ABC classification from orders
    abc_lookup: dict[str, str] = {}
    abc_summary = None
    if orders_path:
        op = Path(orders_path)
        if op.exists():
            with open(op) as f:
                orders = list(csv.DictReader(f))
            revenue_by_product: dict[str, float] = {}
            for row in orders:
                pid = row["product_id"]
                revenue_by_product[pid] = revenue_by_product.get(pid, 0) + float(row["line_total"])
            items = [{"sku": k, "revenue": v} for k, v in revenue_by_product.items()]
            abc = inv.abc(items)
            for cls_name, cls_items in abc.items():
                for item in cls_items:
                    abc_lookup[item["sku"]] = cls_name
            abc_summary = {c: len(items) for c, items in abc.items()}

    # Current stock levels
    latest_stock: dict[str, int] = {}
    if inventory_path:
        ip = Path(inventory_path)
        if ip.exists():
            with open(ip) as f:
                for row in csv.DictReader(f):
                    latest_stock[row["product_id"]] = int(row["closing_stock"])

    # Per-product optimization
    results = []
    alerts = []
    for row in products:
        pid = row["product_id"]
        daily_demand = float(row["base_daily_demand"])
        annual_demand = daily_demand * 365
        cost = float(row["cost"])
        lead_time = int(row["lead_time_days"])
        demand_std = daily_demand * 0.3
        holding_cost = cost * 0.25
        ordering_cost = 50.0
        stock = latest_stock.get(pid, int(row.get("initial_stock", 0)))

        report = inv.full_report(
            sku=row["sku"],
            annual_demand=annual_demand,
            demand_std=demand_std,
            ordering_cost=ordering_cost,
            holding_cost=holding_cost,
            lead_time_days=lead_time,
            current_stock=stock,
            service_level=service_level,
        )

        price = float(row.get("price", 0))
        days_of_stock = round(stock / daily_demand, 1) if daily_demand > 0 else None
        restock_cost = round(report.eoq.order_quantity * cost, 2)
        daily_revenue = round(daily_demand * price, 2)

        entry = {
            "product_id": pid,
            "sku": report.sku,
            "abc_class": abc_lookup.get(pid, "—"),
            "price": price,
            "cost": cost,
            "daily_demand": round(daily_demand, 2),
            "lead_time_days": lead_time,
            "eoq": round(report.eoq.order_quantity),
            "restock_cost": restock_cost,
            "safety_stock": round(report.safety_stock),
            "reorder_point": round(report.reorder_point),
            "current_stock": stock,
            "days_of_stock": days_of_stock,
            "daily_revenue": daily_revenue,
            "stockout_risk": round(report.stockout_risk, 3) if report.stockout_risk is not None else None,
        }
        results.append(entry)

        if report.stockout_risk and report.stockout_risk > 0.3:
            alerts.append({
                "product_id": pid,
                "sku": report.sku,
                "risk": round(report.stockout_risk, 3),
                "reorder_point": round(report.reorder_point),
                "current_stock": stock,
                "days_of_stock": days_of_stock,
                "daily_revenue": daily_revenue,
                "restock_cost": restock_cost,
                "action": f"Reorder {round(report.eoq.order_quantity)} units",
            })

    return {
        "status": "ok",
        "product_count": len(results),
        "abc_summary": abc_summary,
        "results": results,
        "alerts": sorted(alerts, key=lambda a: a["risk"], reverse=True),
    }


def detect_anomalies(
    data_path: str,
    product_id: Optional[str] = None,
    method: str = "zscore",
    window: int = 30,
    threshold: float = 2.5,
) -> dict[str, Any]:
    """Detect anomalies in a demand time series.

    Loads daily_demand.csv, extracts a product series, and runs anomaly detection.

    Args:
        data_path: Path to daily_demand.csv (columns: date, product_id, units_sold).
        product_id: Which product to analyze. If None, picks the highest-volume product.
        method: Detection method — "zscore" or "iqr".
        window: Rolling window size in days (default 30).
        threshold: Z-score threshold for flagging (default 2.5, zscore method only).
    """
    from platoon.learning.providers import get_provider

    p = Path(data_path)
    if not p.exists():
        return {"status": "error", "error": f"File not found: {data_path}"}

    with open(p) as f:
        rows = list(csv.DictReader(f))

    if not rows:
        return {"status": "error", "error": "CSV is empty"}

    required = {"date", "product_id", "units_sold"}
    if not required.issubset(set(rows[0].keys())):
        return {"status": "error", "error": f"CSV must have columns: {required}"}

    # Validate through model (lenient)
    _validate_csv(DemandRecord, rows)

    # Resolve product
    if not product_id:
        totals: dict[str, float] = {}
        for r in rows:
            totals[r["product_id"]] = totals.get(r["product_id"], 0) + float(r["units_sold"])
        product_id = max(totals, key=totals.get)

    filtered = [r for r in rows if r["product_id"] == product_id]
    if not filtered:
        available = sorted(set(r["product_id"] for r in rows))[:10]
        return {"status": "error", "error": f"No data for '{product_id}'", "available": available}

    dates = [r["date"] for r in filtered]
    series = [float(r["units_sold"]) for r in filtered]

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

    return {
        "status": "ok",
        "product_id": product_id,
        "method": result.method,
        "total_points": result.total_points,
        "anomaly_count": result.anomaly_count,
        "anomaly_rate": result.anomaly_rate,
        "series_mean": result.series_mean,
        "series_std": result.series_std,
        "anomalies": [
            {
                "date": a.date,
                "value": a.value,
                "expected": a.expected,
                "z_score": a.z_score,
                "direction": a.direction,
                "severity": a.severity,
            }
            for a in result.anomalies
        ],
    }


def basket_analysis(
    orders_path: str,
    min_support: float = 0.01,
    min_confidence: float = 0.3,
    max_rules: int = 50,
) -> dict[str, Any]:
    """Run basket analysis on order data to find association rules.

    Groups orders.csv by order_id into transactions, then computes co-occurrence rules.

    Args:
        orders_path: Path to orders.csv (columns: order_id, product_id).
        min_support: Minimum support threshold (default 0.01).
        min_confidence: Minimum confidence threshold (default 0.3).
        max_rules: Maximum rules to return (default 50).
    """
    from platoon.learning.providers import get_provider

    p = Path(orders_path)
    if not p.exists():
        return {"status": "error", "error": f"File not found: {orders_path}"}

    with open(p) as f:
        rows = list(csv.DictReader(f))

    if not rows:
        return {"status": "error", "error": "CSV is empty"}

    if "order_id" not in rows[0] or "product_id" not in rows[0]:
        return {"status": "error", "error": "CSV must have columns: order_id, product_id"}

    # Validate through model (lenient)
    _validate_csv(OrderLine, rows)

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

    # Filter to transactions with 2+ items (single-item orders can't produce rules)
    transactions = [items for items in order_items.values() if len(items) >= 2]

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

    rules = result.rules[:max_rules]

    return {
        "status": "ok",
        "total_orders": len(order_items),
        "multi_item_orders": len(transactions),
        "total_transactions": result.total_transactions,
        "rule_count": len(rules),
        "method": result.method,
        "rules": [
            {
                "antecedent": r.antecedent,
                "consequent": r.consequent,
                "support": r.support,
                "confidence": r.confidence,
                "lift": r.lift,
            }
            for r in rules
        ],
    }


def cashflow(
    data_path: str,
    demand_path: str,
    inventory_path: Optional[str] = None,
    horizon: int = 30,
    product_id: Optional[str] = None,
    aggregate: bool = True,
) -> dict[str, Any]:
    """Project cash flow from product data + demand history.

    Loads products.csv for prices/costs, daily_demand.csv for demand signal,
    and optionally inventory.csv for stock tracking with reorder simulation.

    Args:
        data_path: Path to products.csv.
        demand_path: Path to daily_demand.csv.
        inventory_path: Optional inventory.csv for stock levels and reorder simulation.
        horizon: Days to project forward (default 30).
        product_id: Analyze a single product. If None, analyzes all.
        aggregate: If True, returns aggregated daily totals. If False, per-product detail.
    """
    from platoon.learning.providers import get_provider

    # Load products
    pp = Path(data_path)
    if not pp.exists():
        return {"status": "error", "error": f"File not found: {data_path}"}
    with open(pp) as f:
        products = list(csv.DictReader(f))

    if not products:
        return {"status": "error", "error": "Products CSV is empty"}

    # Load demand
    dp = Path(demand_path)
    if not dp.exists():
        return {"status": "error", "error": f"File not found: {demand_path}"}
    with open(dp) as f:
        demand_rows = list(csv.DictReader(f))

    # Filter to single product if specified
    if product_id:
        products = [p for p in products if p["product_id"] == product_id]
        if not products:
            return {"status": "error", "error": f"Product '{product_id}' not found"}

    product_ids = {p["product_id"] for p in products}

    # Build demand signal: use last N days of demand as forward signal
    demand_by_product: dict[str, list[float]] = {}
    for r in demand_rows:
        pid = r["product_id"]
        if pid not in product_ids:
            continue
        if pid not in demand_by_product:
            demand_by_product[pid] = []
        demand_by_product[pid].append(float(r["units_sold"]))

    # Use last `horizon` days as forward demand signal
    for pid in demand_by_product:
        series = demand_by_product[pid]
        demand_by_product[pid] = series[-horizon:] if len(series) >= horizon else series

    # Build inventory state with EOQ/ROP from inventory provider
    inventory_state = None
    if inventory_path:
        ip = Path(inventory_path)
        if ip.exists():
            inv = get_provider("inventory")
            with open(ip) as f:
                inv_rows = list(csv.DictReader(f))

            # Get latest stock per product
            latest_stock: dict[str, int] = {}
            for r in inv_rows:
                if r["product_id"] in product_ids:
                    latest_stock[r["product_id"]] = int(r["closing_stock"])

            inventory_state = {}
            for p in products:
                pid = p["product_id"]
                daily_demand = float(p["base_daily_demand"])
                cost = float(p["cost"])
                demand_std = daily_demand * 0.3
                holding_cost = cost * 0.25
                lead_time = int(p["lead_time_days"])

                ss = inv.safety_stock(demand_std, lead_time, 0.95)
                rop = inv.reorder_point(daily_demand, lead_time, ss)
                eoq_result = inv.eoq(daily_demand * 365, 50.0, holding_cost)

                inventory_state[pid] = {
                    "stock": latest_stock.get(pid, int(p.get("initial_stock", 0))),
                    "reorder_point": rop,
                    "eoq": eoq_result.order_quantity,
                }

    cf = get_provider("cashflow")
    result = cf.project(products, demand_by_product, inventory_state, horizon=horizon)

    return {
        "status": "ok",
        "horizon_days": result.horizon_days,
        "summary": result.summary,
        "reorder_events": result.reorder_events,
        "periods": [
            {
                "date": p.date,
                "revenue": p.revenue,
                "cogs": p.cogs,
                "gross_profit": p.gross_profit,
                "reorder_costs": p.reorder_costs,
                "net_cash": p.net_cash,
            }
            for p in result.periods
        ],
    }


def schedule(
    demand_path: str,
    staff_path: str,
    shift_hours: float = 8.0,
    min_coverage: float = 1.0,
    horizon_days: int = 7,
    product_id: Optional[str] = None,
    slots_path: Optional[str] = None,
) -> dict[str, Any]:
    """Schedule staff based on demand signal and staff availability.

    Two modes:
    1. **Demand-based** (default): Aggregates daily_demand.csv into day-of-week
       demand slots, assigns staff using greedy cost minimization.
    2. **Slot-based** (slots_path provided): Loads explicit slot definitions with
       hours and optional skill requirements. Enables skill-based scheduling
       (e.g. tutor scheduling where math slots need math-skilled staff).

    Args:
        demand_path: Path to daily_demand.csv.
        staff_path: Path to staff.csv (columns: staff_id, name, hourly_rate,
            max_hours_per_week, available_days, skills).
        shift_hours: Hours per shift (default 8).
        min_coverage: Minimum coverage fraction per slot (default 1.0).
        horizon_days: Days to schedule (default 7 = one week).
        product_id: If specified, only consider demand from this product.
        slots_path: Optional path to slots CSV (slot_label, hours_needed, required_skills).
            When provided, uses explicit slots instead of demand aggregation.
    """
    from platoon.learning.providers import get_provider

    # Load staff
    sp = Path(staff_path)
    if not sp.exists():
        return {"status": "error", "error": f"File not found: {staff_path}"}
    with open(sp) as f:
        staff = list(csv.DictReader(f))

    if not staff:
        return {"status": "error", "error": "Staff CSV is empty"}

    # Validate staff through model (lenient)
    _validate_csv(StaffMember, staff)

    # --- Slot-based scheduling (skill-aware) ---
    if slots_path:
        slp = Path(slots_path)
        if not slp.exists():
            return {"status": "error", "error": f"File not found: {slots_path}"}
        with open(slp) as f:
            slot_rows = list(csv.DictReader(f))

        if not slot_rows:
            return {"status": "error", "error": "Slots CSV is empty"}

        # Validate slot rows
        _validate_csv(DemandSlot, slot_rows)

        # Build demand_by_slot and required_skills_by_slot from slot definitions
        demand_by_slot: dict[str, float] = {}
        required_skills_by_slot: dict[str, list[str]] = {}
        for row in slot_rows:
            label = row["slot_label"]
            demand_by_slot[label] = float(row["hours_needed"])
            skills_raw = row.get("required_skills", "")
            if skills_raw:
                required_skills_by_slot[label] = [s.strip() for s in skills_raw.split(",") if s.strip()]

        try:
            sched = get_provider("scheduling", backend="builtin")
        except (ValueError, ImportError):
            sched = get_provider("scheduling")

        result = sched.schedule(
            demand_by_slot, staff, shift_hours=shift_hours,
            min_coverage=min_coverage,
            required_skills_by_slot=required_skills_by_slot if required_skills_by_slot else None,
        )

        return {
            "status": "ok",
            "mode": "skill_based",
            "total_cost": result.total_cost,
            "total_hours": result.total_hours,
            "demand_by_slot": result.demand_by_slot,
            "coverage_by_slot": result.coverage_by_slot,
            "coverage_gaps": result.coverage_gaps,
            "schedule_grid": result.schedule_grid,
            "assignment_count": len(result.assignments),
            "assignments": [
                {
                    "staff_id": a.staff_id,
                    "staff_name": a.staff_name,
                    "day": a.day,
                    "slot": a.slot_label,
                    "hours": a.hours,
                    "cost": a.cost,
                }
                for a in result.assignments
            ],
        }

    # --- Demand-based scheduling (original behavior) ---

    # Load demand
    dp = Path(demand_path)
    if not dp.exists():
        return {"status": "error", "error": f"File not found: {demand_path}"}
    with open(dp) as f:
        demand_rows = list(csv.DictReader(f))

    # Filter demand by product if specified
    if product_id:
        demand_rows = [r for r in demand_rows if r["product_id"] == product_id]
        if not demand_rows:
            return {"status": "error", "error": f"No demand data for '{product_id}'"}

    # Aggregate demand into day-of-week slots
    # Use last horizon_days of demand to estimate daily demand by day-of-week
    from datetime import datetime
    day_names = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]

    day_totals: dict[str, list[float]] = {d: [] for d in day_names}
    for r in demand_rows[-horizon_days * 80:]:  # sample recent data
        try:
            dt = datetime.strptime(r["date"], "%Y-%m-%d")
            day_name = day_names[dt.weekday()]
            day_totals[day_name].append(float(r["units_sold"]))
        except (ValueError, KeyError):
            continue

    # Convert to hours needed: total daily units / units_per_staff_hour (estimate ~20 units/hr)
    units_per_hour = 20.0
    demand_by_slot_agg: dict[str, float] = {}
    for day, values in day_totals.items():
        if values:
            avg_units = sum(values) / max(len(values) / len(set(r.get("product_id", "") for r in demand_rows[-560:])), 1)
            hours_needed = max(shift_hours, avg_units / units_per_hour)
            demand_by_slot_agg[day] = round(hours_needed, 1)

    if not demand_by_slot_agg:
        return {"status": "error", "error": "Could not compute demand slots from data"}

    try:
        sched = get_provider("scheduling", backend="builtin")
    except (ValueError, ImportError):
        sched = get_provider("scheduling")
    result = sched.schedule(demand_by_slot_agg, staff, shift_hours=shift_hours, min_coverage=min_coverage)

    return {
        "status": "ok",
        "total_cost": result.total_cost,
        "total_hours": result.total_hours,
        "demand_by_slot": result.demand_by_slot,
        "coverage_by_slot": result.coverage_by_slot,
        "coverage_gaps": result.coverage_gaps,
        "schedule_grid": result.schedule_grid,
        "assignment_count": len(result.assignments),
        "assignments": [
            {
                "staff_id": a.staff_id,
                "staff_name": a.staff_name,
                "day": a.day,
                "slot": a.slot_label,
                "hours": a.hours,
                "cost": a.cost,
            }
            for a in result.assignments
        ],
    }
