"""
Batch Efficiency Analyser
=========================
Pure-function service that analyses a delivery batch and returns:

  - fleet_summary        : aggregate metrics + load-balance scores
  - rider_timelines      : per-rider start/finish/pace/utilisation
  - substitution_opportunities : ranked, scored transfer plans
  - top_recommendation   : best action with confidence, root-cause & risk factors

No DB access here.  The route handler owns fetching; this layer is
fully testable with any list of dicts.

Expected delivery dict keys (all optional except userid):
  userid          : int   rider id
  pickupcustomer  : str   e.g. "Daily Grubs Bhuvaneshwari"
  assigntime      : str   "YYYY-MM-DD HH:MM:SS"
  pickuptime      : str   "YYYY-MM-DD HH:MM:SS" or None
  deliverytime    : str   "YYYY-MM-DD HH:MM:SS" or None
  dlat / droplat  : float delivery lat (either key accepted)
  dlon / droplon  : float delivery lon (either key accepted)
  deliveryid      : int   order id (for transfer manifests)
"""

from __future__ import annotations

import math
import statistics
from collections import defaultdict
from datetime import datetime, timedelta
from typing import Any

# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------

DEFAULT_KITCHEN_COORDS: dict[str, tuple[float, float]] = {
    "vidhya":        (11.01633, 77.01478),
    "jayanthi":      (11.03887, 76.93008),
    "nandhini":      (11.04324, 77.00068),
    "bhuvaneshwari": (11.00352, 76.95455),
    "selvarani":     (10.99274, 77.00535),
}

DEFAULT_KITCHEN_FRAGMENTS: list[str] = [
    "bhuvaneshwari", "jayanthi", "nandhini", "vidhya", "selvarani"
]

DEFAULT_ROAD_KMH: float = 13.0
DEFAULT_IDLE_THRESHOLD_MIN: float = 30.0
DEFAULT_MAX_TRANSFER: int = 4


# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------

def _hav(la1: float, lo1: float, la2: float, lo2: float) -> float:
    R = 6371.0
    la1, lo1, la2, lo2 = map(math.radians, [la1, lo1, la2, lo2])
    a = (math.sin((la2 - la1) / 2) ** 2
         + math.cos(la1) * math.cos(la2) * math.sin((lo2 - lo1) / 2) ** 2)
    return R * 2 * math.asin(math.sqrt(max(0.0, min(1.0, a))))


def _travel_min(km: float, kmh: float = DEFAULT_ROAD_KMH) -> float:
    return km / kmh * 60.0


def _parse_ts(s: Any) -> datetime | None:
    if not s:
        return None
    s = str(s).strip()
    for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"):
        try:
            return datetime.strptime(s, fmt)
        except ValueError:
            continue
    return None


def _fmt(dt: datetime | None) -> str | None:
    return dt.strftime("%H:%M:%S") if dt else None


def _get_coord(order: dict) -> tuple[float, float] | None:
    lat = order.get("dlat") or order.get("droplat") or order.get("deliverylat")
    lon = order.get("dlon") or order.get("droplon") or order.get("deliverylong")
    try:
        return float(lat), float(lon)
    except (TypeError, ValueError):
        return None


def _detect_kitchen(
    pickupcustomer: str | None,
    fragments: list[str],
) -> str | None:
    kl = (pickupcustomer or "").lower()
    for frag in fragments:
        if frag in kl:
            return frag
    return None


def _stdev(values: list[float]) -> float:
    """Population stdev; returns 0 for fewer than 2 values."""
    if len(values) < 2:
        return 0.0
    try:
        return statistics.stdev(values)
    except Exception:
        return 0.0


def _score_candidate(
    o: dict,
    arrive_at_kitchen: datetime,
    k_coord: tuple[float, float],
    road_kmh: float,
) -> float:
    """
    Score a candidate order for transfer to the idle rider.
    Higher = better candidate.

    Two components:
      time_gain  : minutes saved vs the original delivery time.
                   Positive means idle rider genuinely arrives earlier.
      geo_penalty: haversine distance from kitchen to the order drop point.
                   Penalises far orders that inflate the idle rider's extra km.

    Orders with negative time_gain (idle rider would be slower) still get a
    score, allowing the caller to filter them out with a feasibility check.
    """
    d_ts = _parse_ts(o.get("deliverytime"))
    coord = _get_coord(o)
    if not d_ts or not coord:
        return -9999.0
    dist_km = _hav(k_coord[0], k_coord[1], coord[0], coord[1])
    est_deliver = arrive_at_kitchen + timedelta(minutes=_travel_min(dist_km, road_kmh))
    time_gain_min = (d_ts - est_deliver).total_seconds() / 60.0
    # Weight: time gain matters more than geography (70/30 split).
    # Penalise 2 min per km of extra distance so nearby clusters float up.
    return time_gain_min * 0.7 - dist_km * 2.0 * 0.3


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------

def analyse_batch(
    deliveries: list[dict],
    rider_names: dict[int, str] | None = None,
    kitchen_coords: dict[str, tuple[float, float]] | None = None,
    kitchen_fragments: list[str] | None = None,
    road_kmh: float = DEFAULT_ROAD_KMH,
    idle_threshold_min: float = DEFAULT_IDLE_THRESHOLD_MIN,
    max_transfer: int = DEFAULT_MAX_TRANSFER,
) -> dict:
    """
    Analyse a delivery batch and return substitution opportunities.

    Parameters
    ----------
    deliveries        : list of order dicts (see module docstring)
    rider_names       : optional {userid: name} override
    kitchen_coords    : optional kitchen pickup coordinates override
    kitchen_fragments : optional kitchen detection strings override
    road_kmh          : estimated loaded-rider road speed
    idle_threshold_min: minimum idle window to flag as opportunity
    max_transfer      : maximum orders to suggest transferring to one rider
    """
    if kitchen_coords is None:
        kitchen_coords = DEFAULT_KITCHEN_COORDS
    if kitchen_fragments is None:
        kitchen_fragments = DEFAULT_KITCHEN_FRAGMENTS
    if rider_names is None:
        rider_names = {}

    # ------------------------------------------------------------------
    # 1. Group deliveries by rider
    # ------------------------------------------------------------------
    by_rider: dict[int, list[dict]] = defaultdict(list)
    for o in deliveries:
        uid = o.get("userid")
        if uid is not None:
            by_rider[int(uid)].append(o)

    if not by_rider:
        return {
            "fleet_summary": {},
            "rider_timelines": [],
            "substitution_opportunities": [],
            "top_recommendation": None,
            "error": "No deliveries with valid userid found.",
        }

    # ------------------------------------------------------------------
    # 2. Per-rider timeline
    # ------------------------------------------------------------------
    timelines: list[dict] = []

    for uid, orders in by_rider.items():
        # Detect primary kitchen by majority vote
        kitchen_votes: dict[str, int] = defaultdict(int)
        for o in orders:
            k = _detect_kitchen(o.get("pickupcustomer"), kitchen_fragments)
            if k:
                kitchen_votes[k] += 1
        primary_kitchen = (
            max(kitchen_votes, key=kitchen_votes.get) if kitchen_votes else None
        )
        kitchen_confidence = (
            round(kitchen_votes[primary_kitchen] / len(orders), 2)
            if primary_kitchen else 0.0
        )

        # Timestamps
        finish_ts = None
        start_ts = None
        last_coord: tuple[float, float] | None = None
        completed_orders = 0

        for o in orders:
            a = _parse_ts(o.get("assigntime"))
            d = _parse_ts(o.get("deliverytime"))
            if a and (start_ts is None or a < start_ts):
                start_ts = a
            if d:
                completed_orders += 1
                if finish_ts is None or d > finish_ts:
                    finish_ts = d
                    coord = _get_coord(o)
                    if coord:
                        last_coord = coord

        # Active duration and pace
        active_minutes: float | None = None
        pace_orders_per_hour: float | None = None
        if start_ts and finish_ts and finish_ts > start_ts:
            active_minutes = round((finish_ts - start_ts).total_seconds() / 60, 1)
            pace_orders_per_hour = round(completed_orders / (active_minutes / 60), 1) if active_minutes else None

        timelines.append({
            "userid": uid,
            "name": rider_names.get(uid, f"Rider {uid}"),
            "kitchen": primary_kitchen,
            "kitchen_confidence": kitchen_confidence,
            "order_count": len(orders),
            "completed_orders": completed_orders,
            "pending_orders": len(orders) - completed_orders,
            "started_at": _fmt(start_ts),
            "finished_at": _fmt(finish_ts),
            "active_minutes": active_minutes,
            "pace_orders_per_hour": pace_orders_per_hour,
            "_finish_dt": finish_ts,
            "_start_dt": start_ts,
            "last_position": (
                {"lat": round(last_coord[0], 6), "lon": round(last_coord[1], 6)}
                if last_coord else None
            ),
            "_last_coord": last_coord,
        })

    # Sort by finish time
    timelines.sort(key=lambda t: t["_finish_dt"] or datetime.min)

    # ------------------------------------------------------------------
    # 3. Fleet summary
    # ------------------------------------------------------------------
    valid_start = [t["_start_dt"] for t in timelines if t["_start_dt"]]
    valid_finish = [t["_finish_dt"] for t in timelines if t["_finish_dt"]]

    fleet_start = min(valid_start) if valid_start else None
    fleet_done = max(valid_finish) if valid_finish else None

    # Load balance: stdev of order counts and finish-time spread
    order_counts = [t["order_count"] for t in timelines]
    finish_offsets_min = (
        [(f - fleet_start).total_seconds() / 60 for f in valid_finish]
        if fleet_start and valid_finish else []
    )
    finish_spread_min = (
        round((max(valid_finish) - min(valid_finish)).total_seconds() / 60)
        if len(valid_finish) >= 2 else 0
    )
    load_balance_stdev = round(_stdev([float(c) for c in order_counts]), 2)
    finish_time_stdev = round(_stdev(finish_offsets_min), 1)

    # Utilisation: how much of the batch window each rider was actively delivering
    batch_duration_min = (
        round((fleet_done - fleet_start).total_seconds() / 60)
        if fleet_start and fleet_done else None
    )
    avg_active_minutes = (
        round(
            sum(t["active_minutes"] for t in timelines if t["active_minutes"])
            / max(1, sum(1 for t in timelines if t["active_minutes"])),
            1,
        )
        if any(t["active_minutes"] for t in timelines) else None
    )
    avg_utilisation_pct = (
        round(avg_active_minutes / batch_duration_min * 100, 1)
        if avg_active_minutes and batch_duration_min else None
    )

    fleet_summary = {
        "total_orders": len(deliveries),
        "total_riders": len(by_rider),
        "fleet_start": _fmt(fleet_start),
        "fleet_done": _fmt(fleet_done),
        "total_duration_minutes": batch_duration_min,
        "orders_per_rider_avg": round(len(deliveries) / len(by_rider), 1),
        "load_balance_stdev": load_balance_stdev,
        "finish_time_spread_minutes": finish_spread_min,
        "finish_time_stdev_minutes": finish_time_stdev,
        "avg_utilisation_pct": avg_utilisation_pct,
        "avg_active_minutes": avg_active_minutes,
    }

    if not fleet_done:
        return {
            "fleet_summary": fleet_summary,
            "rider_timelines": _clean_timelines(timelines, fleet_done),
            "substitution_opportunities": [],
            "top_recommendation": None,
            "error": "No completed deliveries found.",
        }

    # Mark each rider's idle status and free window
    for t in timelines:
        fd = t["_finish_dt"]
        if fd:
            idle_min = (fleet_done - fd).total_seconds() / 60.0
            t["idle_minutes"] = round(idle_min)
            t["free_window_minutes"] = round(idle_min)  # time available for substitution
            t["status"] = "idle" if idle_min >= idle_threshold_min else "active"
        else:
            t["idle_minutes"] = 0
            t["free_window_minutes"] = 0
            t["status"] = "unknown"

    # ------------------------------------------------------------------
    # 4. Substitution opportunities
    # ------------------------------------------------------------------
    opportunities: list[dict] = []

    idle_riders = [t for t in timelines if t["status"] == "idle" and t["_last_coord"]]

    for idle in idle_riders:
        idle_uid = idle["userid"]
        idle_finish = idle["_finish_dt"]
        idle_coord = idle["_last_coord"]
        free_window_min = idle["free_window_minutes"]

        if idle["kitchen"] is None:
            continue  # can't determine origin kitchen

        for target_kitchen, k_coord in kitchen_coords.items():
            if target_kitchen == idle["kitchen"]:
                continue  # skip own kitchen

            # Travel from idle rider's last drop to the target kitchen
            travel_km = _hav(idle_coord[0], idle_coord[1], k_coord[0], k_coord[1])
            travel_min = _travel_min(travel_km, road_kmh)

            # Skip if idle rider can't even reach the kitchen before fleet is done
            if travel_min >= free_window_min:
                continue

            arrive_at_kitchen = idle_finish + timedelta(minutes=travel_min)

            # ----------------------------------------------------------
            # Candidate selection: score every order from this kitchen
            # that was delivered after the idle rider could arrive.
            # Score = time_gain (70%) + geo proximity (30%).
            # This prefers orders where idle rider is genuinely faster
            # AND that are close to the kitchen (less detour).
            # ----------------------------------------------------------
            candidate_orders: list[tuple[int, dict, float]] = []  # (rid, order, score)
            for rid, r_orders in by_rider.items():
                if rid == idle_uid:
                    continue
                r_kitchen = next(
                    (t["kitchen"] for t in timelines if t["userid"] == rid), None
                )
                if r_kitchen != target_kitchen:
                    continue
                for o in r_orders:
                    d_ts = _parse_ts(o.get("deliverytime"))
                    if not d_ts or d_ts <= arrive_at_kitchen:
                        continue
                    score = _score_candidate(o, arrive_at_kitchen, k_coord, road_kmh)
                    candidate_orders.append((rid, o, score))

            if not candidate_orders:
                continue

            # Sort best candidates first (highest score = most time gained, closest)
            candidate_orders.sort(key=lambda x: x[2], reverse=True)
            take_pool = candidate_orders[:max_transfer]

            # Greedy nearest-neighbour route from the kitchen through selected orders
            unvisited = list(range(len(take_pool)))
            curr_nn = k_coord
            greedy_take: list[tuple[int, dict]] = []
            while unvisited:
                ni = min(
                    unvisited,
                    key=lambda i: (
                        _hav(curr_nn[0], curr_nn[1], *c)
                        if (c := _get_coord(take_pool[i][1])) else 999.0
                    ),
                )
                unvisited.remove(ni)
                greedy_take.append((take_pool[ni][0], take_pool[ni][1]))
                coord = _get_coord(take_pool[ni][1])
                if coord:
                    curr_nn = coord

            # Simulate idle rider executing the greedy route
            curr_pos = k_coord
            est_time = arrive_at_kitchen
            transfer_manifests: list[dict] = []
            total_delivery_leg_min = 0.0

            for orig_rid, o in greedy_take:
                coord = _get_coord(o)
                if not coord:
                    continue
                d_km = _hav(curr_pos[0], curr_pos[1], coord[0], coord[1])
                d_min = _travel_min(d_km, road_kmh)
                total_delivery_leg_min += d_min
                est_deliver = est_time + timedelta(minutes=d_min)
                orig_deliver = _parse_ts(o.get("deliverytime"))

                improvement = (
                    round((orig_deliver - est_deliver).total_seconds() / 60)
                    if orig_deliver and est_deliver else None
                )
                is_feasible = improvement is not None and improvement > 0

                transfer_manifests.append({
                    "deliveryid": o.get("deliveryid"),
                    "from_rider_id": orig_rid,
                    "from_rider_name": rider_names.get(orig_rid, f"Rider {orig_rid}"),
                    "original_delivery_time": _fmt(orig_deliver),
                    "estimated_delivery_time": _fmt(est_deliver),
                    "improvement_minutes": improvement,
                    "is_feasible": is_feasible,
                    "location": {"lat": round(coord[0], 6), "lon": round(coord[1], 6)},
                })
                curr_pos = coord
                est_time = est_deliver

            # Only keep orders where idle rider is actually faster
            feasible_manifests = [m for m in transfer_manifests if m["is_feasible"]]
            if not feasible_manifests:
                continue

            idle_new_finish = est_time

            # Check idle rider can complete within their free window
            total_obligation_min = travel_min + total_delivery_leg_min
            if total_obligation_min > free_window_min:
                # Idle rider would finish after fleet, extending rather than helping
                continue

            # New fleet done after the transfer
            taken_order_objs = {id(o) for (_, o) in greedy_take}

            new_finish_by_rider: dict[int, datetime | None] = {}
            for rid, r_orders in by_rider.items():
                if rid == idle_uid:
                    new_finish_by_rider[rid] = idle_new_finish
                    continue
                remaining = [o for o in r_orders if id(o) not in taken_order_objs]
                finishes = [_parse_ts(o.get("deliverytime")) for o in remaining]
                valid = [f for f in finishes if f]
                new_finish_by_rider[rid] = max(valid) if valid else None

            new_fleet_done = max(
                (v for v in new_finish_by_rider.values() if v),
                default=fleet_done,
            )
            fleet_improvement = round(
                (fleet_done - new_fleet_done).total_seconds() / 60
            )

            # Most relieved rider
            orig_last_by_rider = {
                rid: max(
                    (f for f in [_parse_ts(o.get("deliverytime")) for o in r_o] if f),
                    default=None,
                )
                for rid, r_o in by_rider.items()
            }
            most_impacted_rid = max(
                (rid for rid in {r for r, _ in greedy_take}),
                key=lambda r: (orig_last_by_rider.get(r) or datetime.min),
                default=None,
            )
            orig_overloaded_finish = orig_last_by_rider.get(most_impacted_rid)
            new_overloaded_finish = new_finish_by_rider.get(most_impacted_rid)
            time_saved = (
                round((orig_overloaded_finish - new_overloaded_finish).total_seconds() / 60)
                if orig_overloaded_finish and new_overloaded_finish else 0
            )

            # Total extra km for idle rider (idle→kitchen + all delivery legs)
            total_extra_km = travel_km + sum(
                _hav(
                    (k_coord if i == 0 else (_get_coord(greedy_take[i-1][1]) or k_coord))[0],
                    (k_coord if i == 0 else (_get_coord(greedy_take[i-1][1]) or k_coord))[1],
                    *(_get_coord(o) or k_coord),
                )
                for i, (_, o) in enumerate(greedy_take)
                if _get_coord(o)
            )

            # ----------------------------------------------------------
            # Confidence score (0-100)
            # Measures how comfortable this transfer is given real constraints.
            #
            # Component 1 – Slack ratio: how much free time the idle rider
            #   has beyond the time they'll spend doing the transfer.
            #   (free_window - total_obligation) / free_window → 0..1
            #
            # Component 2 – Feasibility ratio: what fraction of the
            #   transferred orders actually deliver earlier than original.
            #   feasible_count / total_transferred → 0..1
            #
            # Component 3 – Fleet gain ratio: minutes saved as a fraction
            #   of total batch duration.  Capped at 20 min improvement for
            #   full score so small batches don't produce inflated scores.
            # ----------------------------------------------------------
            slack_ratio = max(0.0, (free_window_min - total_obligation_min) / free_window_min)
            feasibility_ratio = len(feasible_manifests) / max(1, len(transfer_manifests))
            fleet_gain_ratio = min(1.0, fleet_improvement / 20.0) if fleet_improvement > 0 else 0.0

            confidence_score = round(
                (slack_ratio * 0.4 + feasibility_ratio * 0.35 + fleet_gain_ratio * 0.25) * 100
            )

            efficiency_ratio = (
                round(fleet_improvement / max(0.1, total_extra_km), 2)
                if total_extra_km > 0 else 0.0
            )
            if efficiency_ratio >= 5:
                efficiency_rating = "high"
            elif efficiency_ratio >= 2:
                efficiency_rating = "medium"
            else:
                efficiency_rating = "low"

            opportunities.append({
                "idle_rider": {
                    "userid": idle_uid,
                    "name": idle["name"],
                    "primary_kitchen": idle["kitchen"],
                    "order_count": idle["order_count"],
                    "finished_at": _fmt(idle_finish),
                    "idle_minutes": idle["idle_minutes"],
                    "free_window_minutes": free_window_min,
                    "last_position": idle["last_position"],
                },
                "target_kitchen": target_kitchen,
                "travel_to_kitchen_km": round(travel_km, 1),
                "travel_to_kitchen_minutes": round(travel_min),
                "arrive_at_kitchen": _fmt(arrive_at_kitchen),
                "orders_to_transfer": transfer_manifests,
                "total_orders_transferred": len(feasible_manifests),
                "feasible_orders_count": len(feasible_manifests),
                "most_relieved_rider": {
                    "userid": most_impacted_rid,
                    "name": rider_names.get(most_impacted_rid, f"Rider {most_impacted_rid}"),
                    "original_finish": _fmt(orig_overloaded_finish),
                    "new_finish": _fmt(new_overloaded_finish),
                    "time_saved_minutes": time_saved,
                },
                "extra_km_for_idle_rider": round(total_extra_km, 1),
                "total_obligation_minutes": round(total_obligation_min),
                "idle_rider_new_finish": _fmt(idle_new_finish),
                "original_fleet_done": _fmt(fleet_done),
                "new_fleet_done": _fmt(new_fleet_done),
                "fleet_improvement_minutes": fleet_improvement,
                "confidence_score": confidence_score,
                "efficiency_ratio": efficiency_ratio,
                "efficiency_rating": efficiency_rating,
            })

    # Keep only net-positive opportunities
    opportunities = [o for o in opportunities if o["fleet_improvement_minutes"] > 0]

    # Sort: confidence first (overall quality), then fleet improvement, then rider time saved
    opportunities.sort(
        key=lambda x: (
            -x["confidence_score"],
            -x["fleet_improvement_minutes"],
            -x["most_relieved_rider"]["time_saved_minutes"],
        )
    )

    # ------------------------------------------------------------------
    # 5. Top recommendation
    # ------------------------------------------------------------------
    top_recommendation = _build_recommendation(
        opportunities, idle_threshold_min, fleet_done, timelines, fleet_summary
    )

    return {
        "fleet_summary": fleet_summary,
        "rider_timelines": _clean_timelines(timelines, fleet_done),
        "substitution_opportunities": opportunities,
        "top_recommendation": top_recommendation,
    }


# ---------------------------------------------------------------------------
# Helpers for clean output
# ---------------------------------------------------------------------------

def _clean_timelines(
    timelines: list[dict],
    fleet_done: datetime | None,
) -> list[dict]:
    out = []
    for t in timelines:
        out.append({
            "userid": t["userid"],
            "name": t["name"],
            "kitchen": t["kitchen"],
            "kitchen_confidence": t.get("kitchen_confidence", 0.0),
            "order_count": t["order_count"],
            "completed_orders": t.get("completed_orders", t["order_count"]),
            "pending_orders": t.get("pending_orders", 0),
            "started_at": t["started_at"],
            "finished_at": t["finished_at"],
            "active_minutes": t.get("active_minutes"),
            "pace_orders_per_hour": t.get("pace_orders_per_hour"),
            "idle_minutes": t.get("idle_minutes", 0),
            "free_window_minutes": t.get("free_window_minutes", 0),
            "status": t.get("status", "unknown"),
            "last_position": t.get("last_position"),
        })
    return out


def _build_recommendation(
    opportunities: list[dict],
    idle_threshold: float,
    fleet_done: datetime | None,
    timelines: list[dict],
    fleet_summary: dict,
) -> dict | None:
    if not opportunities:
        # Diagnose WHY there are no opportunities even if some riders were idle
        idle_count = sum(1 for t in timelines if t.get("status") == "idle")
        if idle_count == 0:
            reason = "All riders finished within the idle threshold window — batch was well balanced."
        else:
            reason = (
                f"{idle_count} rider(s) finished early but no feasible substitution found: "
                "either travel time exceeds the idle window, or all candidate orders "
                "would be delivered later by the idle rider than the original."
            )
        return {
            "action": "none",
            "reason": reason,
            "fleet_balance_assessment": _balance_assessment(fleet_summary),
        }

    best = opportunities[0]
    idle = best["idle_rider"]
    target = best["target_kitchen"]
    relieved = best["most_relieved_rider"]
    primary_kitchen = idle["primary_kitchen"] or "unknown"
    confidence = best["confidence_score"]

    # Root cause: why was this rider idle?
    root_cause = _diagnose_root_cause(idle, timelines, fleet_summary)

    # Risk factors
    risk_factors = _identify_risks(best, idle_threshold)

    description = (
        f"{idle['name']} ({primary_kitchen}) finished all {idle['order_count']} orders "
        f"at {idle['finished_at']} — {idle['idle_minutes']} min before the fleet finished. "
        f"Assigning {best['feasible_orders_count']} {target} orders: "
        f"travel {best['travel_to_kitchen_km']} km ({best['travel_to_kitchen_minutes']} min), "
        f"arrive at {target} kitchen at {best['arrive_at_kitchen']}. "
        f"Relieves {relieved['name']} by {relieved['time_saved_minutes']} min "
        f"({relieved['original_finish']} → {relieved['new_finish']}). "
        f"Fleet finishes {best['fleet_improvement_minutes']} min earlier "
        f"({best['original_fleet_done']} → {best['new_fleet_done']}). "
        f"Confidence: {confidence}/100."
    )

    # Dynamic thresholds derived from the actual batch data
    idle_rider_loads = [t["order_count"] for t in timelines if t.get("kitchen") == primary_kitchen]
    target_rider_loads = [t["order_count"] for t in timelines if t.get("kitchen") == target]
    activate_idle_threshold = max(6, idle.get("order_count", 6) + 2)
    activate_target_threshold = max(8, round(sum(target_rider_loads) / max(1, len(target_rider_loads)) * 1.1))

    activate_rule = {
        "condition": "AND",
        "rules": [
            {
                "field": f"{primary_kitchen}_order_count",
                "operator": "<=",
                "value": activate_idle_threshold,
                "reason": (
                    f"{idle['name']} had {idle['order_count']} orders today and was idle "
                    f"{idle['idle_minutes']} min. Dual-kitchen kicks in when their load "
                    f"stays at or below {activate_idle_threshold}."
                ),
            },
            {
                "field": f"{target}_order_count",
                "operator": ">=",
                "value": activate_target_threshold,
                "reason": (
                    f"{target.capitalize()} had enough orders today to justify the detour "
                    f"({sum(target_rider_loads)} total across {len(target_rider_loads)} rider(s)). "
                    f"Activate when {target} load is ≥ {activate_target_threshold}."
                ),
            },
        ],
    }

    return {
        "action": "dual_kitchen_assignment",
        "idle_rider_id": idle["userid"],
        "idle_rider_name": idle["name"],
        "primary_kitchen": primary_kitchen,
        "second_kitchen": target,
        "second_kitchen_dispatch_after": best["arrive_at_kitchen"],
        "description": description,
        "fleet_improvement_minutes": best["fleet_improvement_minutes"],
        "confidence_score": confidence,
        "efficiency_rating": best["efficiency_rating"],
        "root_cause": root_cause,
        "risk_factors": risk_factors,
        "activate_when": activate_rule,
        "fleet_balance_assessment": _balance_assessment(fleet_summary),
        "api_hint": {
            "endpoint": "/api/v1/optimize",
            "note": (
                f"In the next batch, pre-assign the last "
                f"{best['feasible_orders_count']} {target} orders to "
                f"rider {idle['userid']} ({idle['name']}) with a "
                f"dispatch-after time of {best['arrive_at_kitchen']}."
            ),
        },
    }


def _diagnose_root_cause(
    idle: dict,
    timelines: list[dict],
    fleet_summary: dict,
) -> str:
    """
    Explain WHY this rider finished early by comparing their load
    against the fleet average and their kitchen's order volume.
    """
    avg_orders = fleet_summary.get("orders_per_rider_avg", 0)
    rider_orders = idle["order_count"]
    kitchen = idle["primary_kitchen"] or "their kitchen"
    name = idle["name"]

    if avg_orders > 0 and rider_orders < avg_orders * 0.7:
        shortfall = round(avg_orders - rider_orders, 1)
        return (
            f"{name} received {rider_orders} orders vs fleet average of {avg_orders:.1f} "
            f"(−{shortfall:.1f}). {kitchen.capitalize()} kitchen generated fewer orders "
            f"than the fleet needed to keep this rider fully utilised. "
            f"This is a systematic under-loading of the {kitchen} kitchen in this batch."
        )
    elif rider_orders <= 4:
        return (
            f"{name} had only {rider_orders} orders — a very light load regardless of fleet average. "
            f"Likely a short-demand window at {kitchen} kitchen. "
            f"Dual-kitchen assignment is especially effective when primary kitchen load is ≤ 4 orders."
        )
    else:
        spread = fleet_summary.get("finish_time_spread_minutes", 0)
        return (
            f"{name} is simply faster than peers — finished {idle['idle_minutes']} min ahead "
            f"despite a normal load of {rider_orders} orders. "
            f"Fleet finish-time spread is {spread} min, indicating uneven workload distribution."
        )


def _identify_risks(best: dict, idle_threshold: float) -> list[str]:
    """
    Enumerate operational risks for the recommended substitution.
    """
    risks: list[str] = []
    travel_min = best["travel_to_kitchen_minutes"]
    obligation = best["total_obligation_minutes"]
    free_window = best["idle_rider"]["free_window_minutes"]
    confidence = best["confidence_score"]
    extra_km = best["extra_km_for_idle_rider"]

    slack_min = free_window - obligation
    if slack_min < 10:
        risks.append(
            f"Tight schedule: only {slack_min} min of slack between idle rider's "
            f"estimated finish and fleet completion. Any delay (traffic, kitchen wait) "
            f"would eliminate the benefit."
        )
    if travel_min > 15:
        risks.append(
            f"Long commute to target kitchen ({travel_min} min). "
            f"Kitchen departure time must be precise — a late start erodes time savings."
        )
    if extra_km > 8:
        risks.append(
            f"Extra {extra_km} km for the idle rider adds fuel cost and rider fatigue. "
            f"Verify this is worthwhile if fleet improvement is marginal."
        )
    if confidence < 50:
        risks.append(
            f"Low confidence ({confidence}/100): limited slack or few feasible transfers. "
            f"Consider this as a contingency plan rather than a guaranteed improvement."
        )
    if best.get("feasible_orders_count", 0) < best.get("total_orders_transferred", 1):
        risks.append(
            "Not all proposed transfers save time — some orders are included to fill "
            "the idle rider's route but don't improve individual delivery times."
        )
    if not risks:
        risks.append("No significant risks identified. Transfer looks operationally sound.")

    return risks


def _balance_assessment(fleet_summary: dict) -> str:
    """Short human-readable verdict on batch balance quality."""
    spread = fleet_summary.get("finish_time_spread_minutes", 0)
    stdev = fleet_summary.get("load_balance_stdev", 0)
    util = fleet_summary.get("avg_utilisation_pct")

    if spread <= 10 and stdev <= 1:
        verdict = "Excellent — riders finished close together with balanced loads."
    elif spread <= 20 and stdev <= 2:
        verdict = "Good — minor imbalance, acceptable for this fleet size."
    elif spread <= 35:
        verdict = f"Moderate imbalance — {spread} min spread between earliest and latest finish."
    else:
        verdict = (
            f"High imbalance — {spread} min spread. Some riders sat idle while others overran. "
            f"Pre-planning dual-kitchen assignments is strongly recommended."
        )

    if util is not None:
        verdict += f" Average rider utilisation: {util}% of batch window."
    return verdict
