"""
Delivery History Service — Empirical ETA from ground truth
==========================================================

WHY THIS EXISTS
---------------
Until now every ETA in the system was a *formula guess*
(`RealisticETACalculator`: distance / configured_speed + fixed buffers) that was
never checked against what actually happened in the field. Senior feedback was
that manual riders deliver faster than our estimates — i.e. the guess is wrong.

This service closes the loop. The external `nearledb` Postgres already records,
for every completed delivery, when the rider picked up (`pickuptime`) and when
the order was delivered (`deliverytime`). From those we reconstruct **actual
per-leg travel times** and learn empirical medians, so ETAs reflect reality
instead of a hand-tuned formula.

GROUND-TRUTH RECONSTRUCTION (per-leg, matches the optimizer)
-----------------------------------------------------------
The optimizer feeds *aerial* (straight-line) leg distance to `calculate_eta`
(see route_optimizer.py — `step_dist` comes from `aerial_matrix`, no road
factor). To learn a model that plugs in behind the same call, we reconstruct
observations the same way:

  * Group a rider's completed deliveries by day, ordered by `deliverytime`.
  * For each pair of consecutive deliveries:
        leg_min = deliverytime[i] - deliverytime[i-1]      (real door-to-door time)
        leg_km  = aerial haversine(drop[i-1], drop[i])     (same metric as optimizer)
  * The first delivery of a group is the kitchen→first-drop leg:
        leg_min = deliverytime[0] - pickuptime[0]          (distance unknown w/o
        kitchen coords, so it only feeds the non-distance keys).

Only `droplat/droplon`, `pickuptime`, `deliverytime`, `pickupcustomer`, `userid`
are needed — all confirmed-present columns (same set batch_analytics reads).

AGGREGATION + LOOKUP (hierarchical, cold-start safe)
----------------------------------------------------
Each observation feeds several keys at decreasing specificity. At prediction
time we walk the same hierarchy and use the first key with enough samples,
falling back to the formula when history is too thin:

    kzd  kitchen | drop_zone | traffic | dist_bucket   (most specific)
    kz   kitchen | drop_zone | traffic
    zd   drop_zone | traffic | dist_bucket
    z    drop_zone | traffic
    dt   dist_bucket | traffic
    t    traffic                                        (least specific)
    →    RealisticETACalculator formula                 (no data)

`rkz` (rider | kitchen | zone | traffic) is also stored for Phase-2 learned
rider affinity; it is not used by the default lookup yet.
"""

import logging
import math
import os
import sqlite3
import threading
import statistics
import time
from datetime import datetime, timedelta
from collections import defaultdict
from typing import Any, Dict, List, Optional, Tuple

from app.services.routing.zone_service import ZoneService

logger = logging.getLogger(__name__)

_DB_PATH = os.getenv("ML_DB_PATH", "ml_data/ml_store.db")
_WRITE_LOCK = threading.Lock()

# Aerial leg-distance buckets (km) — must match the metric the optimizer feeds
# to calculate_eta (pure haversine, no road factor).
_DIST_BUCKETS: List[Tuple[float, float]] = [
    (0.0, 1.0), (1.0, 2.0), (2.0, 3.0), (3.0, 5.0), (5.0, 8.0), (8.0, 12.0), (12.0, 1e9)
]

# Sanity filters for reconstructed legs.
_MAX_LEG_MIN = 60.0    # gaps longer than this are batch boundaries / idle, not a leg
_MAX_LEG_KM = 40.0     # implausible single hop


# ---------------------------------------------------------------------------
# Pure helpers
# ---------------------------------------------------------------------------

def dist_bucket(km: Optional[float]) -> Optional[str]:
    """Aerial leg distance -> discrete bucket label, or None if unknown."""
    if km is None:
        return None
    for lo, hi in _DIST_BUCKETS:
        if km <= hi:
            return f"{lo:g}-{hi:g}" if hi < 1e9 else f"{lo:g}+"
    return f"{_DIST_BUCKETS[-1][0]:g}+"


def hour_to_traffic(hour: int) -> str:
    """Time-of-day -> traffic category, mirroring get_time_of_day_category()."""
    if (8 <= hour < 10) or (12 <= hour < 14) or (17 <= hour < 20):
        return "peak"
    if hour < 7 or hour >= 22:
        return "light"
    return "normal"


def normalize_kitchen(name: Any) -> str:
    """Normalize a kitchen / pickup-customer name to a stable key token."""
    if not name:
        return "?"
    return " ".join(str(name).strip().lower().split())


def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
    """Great-circle (aerial) distance in km."""
    try:
        rlat1, rlon1, rlat2, rlon2 = map(math.radians, (lat1, lon1, lat2, lon2))
        dlat = rlat2 - rlat1
        dlon = rlon2 - rlon1
        a = math.sin(dlat / 2) ** 2 + math.cos(rlat1) * math.cos(rlat2) * math.sin(dlon / 2) ** 2
        return 6371.0 * 2 * math.asin(min(1.0, math.sqrt(a)))
    except Exception:
        return 0.0


def _parse_dt(val: Any) -> Optional[datetime]:
    if val in (None, "", 0):
        return None
    if isinstance(val, datetime):
        return val
    s = str(val).strip()
    for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S.%f",
                "%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%d %H:%M"):
        try:
            return datetime.strptime(s.split("+")[0].strip(), fmt)
        except Exception:
            continue
    try:
        from dateutil.parser import parse as _du
        return _du(s)
    except Exception:
        return None


# ---------------------------------------------------------------------------
# Shared Postgres connector (single source of truth for nearledb creds)
# ---------------------------------------------------------------------------

def connect_nearledb(connect_timeout: int = 10):
    """
    Open a connection to the external nearledb Postgres.

    Credentials come from DB_* env vars (same defaults batch_analytics used).
    Raises on failure — callers convert to their own error type.
    """
    import psycopg2  # imported lazily so the app boots without it

    conn = psycopg2.connect(
        host=os.getenv("DB_HOST", "66.116.207.225"),
        port=int(os.getenv("DB_PORT", "6432")),
        dbname=os.getenv("DB_NAME", "nearledb"),
        user=os.getenv("DB_USER", "admin"),
        password=os.getenv("DB_PASSWORD", "Package@123#"),
        connect_timeout=connect_timeout,
    )
    # Best-effort read-only session. (Port 6432 is pgbouncer, which rejects the
    # `options` startup param, so we set it post-connect; our code only issues
    # SELECTs regardless — we never write to or alter the source DB.)
    try:
        conn.set_session(readonly=True)
    except Exception:
        pass
    return conn


# ---------------------------------------------------------------------------
# Key construction (build-side and lookup-side share this so they stay in sync)
# ---------------------------------------------------------------------------

def _keys_for_observation(kitchen: str, zone: str, traffic: str,
                          bucket: Optional[str], rider_id: Any) -> List[str]:
    """All aggregation keys an observation contributes to."""
    keys = [
        f"kz|{kitchen}|{zone}|{traffic}",
        f"z|{zone}|{traffic}",
        f"t|{traffic}",
        f"rkz|{rider_id}|{kitchen}|{zone}|{traffic}",
    ]
    if bucket is not None:
        keys += [
            f"kzd|{kitchen}|{zone}|{traffic}|{bucket}",
            f"zd|{zone}|{traffic}|{bucket}",
            f"dt|{bucket}|{traffic}",
        ]
    return keys


def _lookup_keys(kitchen: Optional[str], zone: str, traffic: str,
                 bucket: Optional[str]) -> List[str]:
    """Ordered candidate keys, most specific first, for prediction-time lookup."""
    ordered: List[str] = []
    if kitchen and bucket is not None:
        ordered.append(f"kzd|{kitchen}|{zone}|{traffic}|{bucket}")
    if kitchen:
        ordered.append(f"kz|{kitchen}|{zone}|{traffic}")
    if bucket is not None:
        ordered.append(f"zd|{zone}|{traffic}|{bucket}")
    ordered.append(f"z|{zone}|{traffic}")
    if bucket is not None:
        ordered.append(f"dt|{bucket}|{traffic}")
    ordered.append(f"t|{traffic}")
    return ordered


# ---------------------------------------------------------------------------
# Service
# ---------------------------------------------------------------------------

class DeliveryHistoryService:
    """Ingests completed deliveries, learns empirical leg-time medians, serves lookups."""

    def __init__(self):
        self._db_path = _DB_PATH
        self._zone = ZoneService()
        # cache: full_key -> (count, median_min, p75_min)
        self._cache: Dict[str, Tuple[int, float, float]] = {}
        self._last_refreshed: Optional[datetime] = None
        self._last_summary: Dict[str, Any] = {}
        self._refresh_lock = threading.Lock()
        self._refresh_attempt_at: Optional[datetime] = None
        self._scheduler_started = False
        self._ensure_db()
        self._load_cache()

    # -- schema -------------------------------------------------------------

    def _ensure_db(self) -> None:
        try:
            os.makedirs(os.path.dirname(self._db_path) or ".", exist_ok=True)
            conn = sqlite3.connect(self._db_path)
            conn.execute("""
                CREATE TABLE IF NOT EXISTS delivery_history (
                    id            INTEGER PRIMARY KEY AUTOINCREMENT,
                    delivery_ts   TEXT,
                    rider_id      TEXT,
                    kitchen       TEXT,
                    drop_zone     TEXT,
                    traffic       TEXT,
                    dist_bucket   TEXT,
                    leg_km        REAL,
                    leg_min       REAL,
                    is_first      INTEGER DEFAULT 0
                )
            """)
            conn.execute("""
                CREATE TABLE IF NOT EXISTS eta_stats (
                    key          TEXT PRIMARY KEY,
                    key_type     TEXT,
                    sample_count INTEGER,
                    median_min   REAL,
                    p75_min      REAL,
                    updated_at   TEXT
                )
            """)
            conn.execute("CREATE INDEX IF NOT EXISTS idx_eta_stats_type ON eta_stats(key_type)")
            # Local mirror of the nearledb `deliveries` rows we care about.
            # The request path NEVER touches Postgres — only this table.
            # Deduped by deliveryid so incremental syncs are idempotent.
            conn.execute("""
                CREATE TABLE IF NOT EXISTS delivery_raw (
                    deliveryid    TEXT PRIMARY KEY,
                    userid        TEXT,
                    pickupcustomer TEXT,
                    pickuptime    TEXT,
                    deliverytime  TEXT,
                    dlat          REAL,
                    dlon          REAL,
                    plat          REAL,
                    plon          REAL
                )
            """)
            # Migration for stores created before pickup coords were added.
            for _ddl in (
                "ALTER TABLE delivery_raw ADD COLUMN plat REAL",
                "ALTER TABLE delivery_raw ADD COLUMN plon REAL",
            ):
                try:
                    conn.execute(_ddl)
                except Exception:
                    pass
            conn.execute("CREATE INDEX IF NOT EXISTS idx_raw_dtime ON delivery_raw(deliverytime)")
            # Single-row sync watermark / bookkeeping.
            conn.execute("""
                CREATE TABLE IF NOT EXISTS eta_sync_state (
                    id             INTEGER PRIMARY KEY CHECK (id = 1),
                    last_delivery_ts TEXT,
                    last_synced_at TEXT,
                    total_rows     INTEGER DEFAULT 0
                )
            """)
            conn.execute("INSERT OR IGNORE INTO eta_sync_state (id, total_rows) VALUES (1, 0)")
            conn.commit()
            conn.close()
        except Exception as e:
            logger.error(f"[DeliveryHistory] DB init failed: {e}")

    # -- ingestion ----------------------------------------------------------

    def fetch_completed_deliveries(
        self, days: int, tenant_id: int = 916, since: Optional[str] = None
    ) -> List[Dict[str, Any]]:
        """
        READ-ONLY pull from nearledb `deliveries`.

        * `since` set      -> incremental: only rows newer than the watermark.
        * `since` None     -> initial backfill of the last `days`.

        Only the `deliveries` table is read; the session is read-only (no writes,
        no schema changes — enforced at the connection level).
        """
        conn = connect_nearledb()
        try:
            cur = conn.cursor()
            if since:
                where_time = "d.deliverytime::timestamp > %s"
                time_param = since
            else:
                where_time = "d.deliverytime::timestamp >= %s"
                time_param = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S")
            cur.execute(
                f"""
                SELECT
                    d.deliveryid,
                    d.userid,
                    d.pickupcustomer,
                    d.pickuptime,
                    d.deliverytime,
                    COALESCE(d.droplat,  d.deliverylat)  AS dlat,
                    COALESCE(d.droplon, d.deliverylong)  AS dlon,
                    d.pickuplat                          AS plat,
                    d.pickuplon                          AS plon
                FROM deliveries d
                WHERE d.tenantid = %s
                  AND d.deliverytime IS NOT NULL
                  AND d.pickuptime  IS NOT NULL
                  AND {where_time}
                  AND COALESCE(d.droplat, d.deliverylat) IS NOT NULL
                  AND d.userid IS NOT NULL
                ORDER BY d.deliverytime
                """,
                (tenant_id, time_param),
            )
            cols = [c.name for c in cur.description]
            rows = [dict(zip(cols, r)) for r in cur.fetchall()]
            cur.close()
            return rows
        finally:
            conn.close()

    # -- watermark / local raw store ---------------------------------------

    def _get_watermark(self) -> Optional[str]:
        try:
            conn = sqlite3.connect(self._db_path)
            row = conn.execute(
                "SELECT last_delivery_ts FROM eta_sync_state WHERE id = 1"
            ).fetchone()
            conn.close()
            return row[0] if row and row[0] else None
        except Exception:
            return None

    def sync_from_db(self, days: int = 14, tenant_id: int = 916,
                     full: bool = False) -> Dict[str, Any]:
        """
        Pull NEW completed deliveries from nearledb into the local mirror.

        Idempotent (INSERT OR IGNORE on deliveryid). On the first run (empty
        watermark) or `full=True`, backfills the last `days`; afterwards only
        rows newer than the watermark are fetched — minimal DB load.
        """
        watermark = None if full else self._get_watermark()
        rows = self.fetch_completed_deliveries(days, tenant_id, since=watermark)

        inserted = 0
        max_ts = watermark
        conn = sqlite3.connect(self._db_path)
        try:
            if full:
                # A full sync truly rebuilds the local mirror (also backfills any
                # newly-added columns that INSERT OR IGNORE would otherwise skip).
                conn.execute("DELETE FROM delivery_raw")
                conn.execute("UPDATE eta_sync_state SET last_delivery_ts = NULL WHERE id = 1")
                max_ts = None
            for r in rows:
                dt = str(r.get("deliverytime") or "")
                try:
                    cur = conn.execute(
                        "INSERT OR IGNORE INTO delivery_raw "
                        "(deliveryid, userid, pickupcustomer, pickuptime, deliverytime, dlat, dlon, plat, plon) "
                        "VALUES (?,?,?,?,?,?,?,?,?)",
                        (str(r.get("deliveryid")), str(r.get("userid")),
                         r.get("pickupcustomer"), str(r.get("pickuptime") or ""),
                         dt, r.get("dlat"), r.get("dlon"), r.get("plat"), r.get("plon")),
                    )
                    inserted += cur.rowcount
                except Exception:
                    continue
                if dt and (max_ts is None or dt > max_ts):
                    max_ts = dt
            total = conn.execute("SELECT COUNT(*) FROM delivery_raw").fetchone()[0]
            conn.execute(
                "UPDATE eta_sync_state SET last_delivery_ts = ?, last_synced_at = ?, total_rows = ? WHERE id = 1",
                (max_ts, datetime.utcnow().isoformat(), total),
            )
            conn.commit()
        finally:
            conn.close()
        return {"fetched": len(rows), "inserted": inserted, "local_total": total,
                "watermark": max_ts, "mode": "full" if full else "incremental"}

    def _prune_raw(self, days: int) -> int:
        """Drop local rows older than the rolling window to bound the store."""
        cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S")
        try:
            conn = sqlite3.connect(self._db_path)
            cur = conn.execute("DELETE FROM delivery_raw WHERE deliverytime < ?", (cutoff,))
            deleted = cur.rowcount
            conn.commit()
            conn.close()
            return deleted
        except Exception:
            return 0

    def _load_raw_rows(self, days: int) -> List[Dict[str, Any]]:
        """Read the local mirror within the rolling window (no DB hit)."""
        cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S")
        conn = sqlite3.connect(self._db_path)
        rows = conn.execute(
            "SELECT deliveryid, userid, pickupcustomer, pickuptime, deliverytime, dlat, dlon, plat, plon "
            "FROM delivery_raw WHERE deliverytime >= ? ORDER BY deliverytime",
            (cutoff,),
        ).fetchall()
        conn.close()
        cols = ["deliveryid", "userid", "pickupcustomer", "pickuptime", "deliverytime", "dlat", "dlon", "plat", "plon"]
        return [dict(zip(cols, r)) for r in rows]

    def get_pattern_records(self, days: int) -> List[Dict[str, Any]]:
        """
        Shape the local nearledb mirror into the record format the delivery-
        history pattern store (Phase-0 kitchen+zone rider lookup) expects:
        kitchen/pickuplat/pickuplon/deliverylat/deliverylong/userid/ridername.

        `ridername` is always "" — delivery_raw doesn't store it (it's
        display-only in the /ml/history debug endpoint; every actual matching
        decision is keyed on userid, so this doesn't affect assignment).
        """
        records: List[Dict[str, Any]] = []
        for r in self._load_raw_rows(days):
            try:
                plat = float(r.get("plat") or 0)
                plon = float(r.get("plon") or 0)
                dlat = float(r.get("dlat") or 0)
                dlon = float(r.get("dlon") or 0)
                uid  = int(float(r.get("userid") or 0))
                if not plat or not dlat or uid == 0:
                    continue
                records.append({
                    "kitchen":     (r.get("pickupcustomer") or "").strip().lower(),
                    "pickuplat":   plat, "pickuplon": plon,
                    "deliverylat": dlat, "deliverylong": dlon,
                    "userid":      uid,
                    "ridername":  "",
                })
            except (TypeError, ValueError):
                continue
        return records

    def sample_batches(self, days: int = 14, min_drops: int = 4, max_drops: int = 15,
                       limit: int = 10) -> List[Dict[str, Any]]:
        """
        Real recent delivery batches from the local mirror for the road-sequencing
        decision agent. Grouped by (rider, day, kitchen); origin = that kitchen's
        pickup coords (centroid fallback). Most-recent batches first.
        Returns [{origin, drops, rider, day, kitchen}].
        """
        rows = self._load_raw_rows(days)
        groups: Dict[Tuple[str, str, str], List[Dict[str, Any]]] = defaultdict(list)
        for r in rows:
            dt = _parse_dt(r.get("deliverytime"))
            if dt is None:
                continue
            try:
                if not (float(r["dlat"]) and float(r["dlon"])):
                    continue
            except (TypeError, ValueError):
                continue
            key = (str(r.get("userid")), dt.strftime("%Y-%m-%d"),
                   normalize_kitchen(r.get("pickupcustomer")))
            groups[key].append(r)

        batches: List[Dict[str, Any]] = []
        for key in sorted(groups, key=lambda k: k[1], reverse=True):
            items = groups[key]
            if not (min_drops <= len(items) <= max_drops):
                continue
            drops = [(float(i["dlat"]), float(i["dlon"])) for i in items]
            origin = None
            for i in items:
                try:
                    pla, plo = float(i["plat"]), float(i["plon"])
                    if pla and plo:
                        origin = (pla, plo)
                        break
                except (TypeError, ValueError):
                    continue
            if origin is None:
                origin = (sum(d[0] for d in drops) / len(drops),
                          sum(d[1] for d in drops) / len(drops))
            batches.append({"origin": origin, "drops": drops,
                            "rider": key[0], "day": key[1], "kitchen": key[2]})
            if len(batches) >= limit:
                break
        return batches

    def _build_observations(self, rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
        """Reconstruct per-leg observations from ordered delivery rows."""
        # Group by (rider, calendar day of delivery)
        groups: Dict[Tuple[str, str], List[Dict[str, Any]]] = defaultdict(list)
        for r in rows:
            dt = _parse_dt(r.get("deliverytime"))
            pt = _parse_dt(r.get("pickuptime"))
            if dt is None or pt is None:
                continue
            try:
                dlat = float(r.get("dlat"))
                dlon = float(r.get("dlon"))
            except (TypeError, ValueError):
                continue
            if dlat == 0 or dlon == 0:
                continue
            groups[(str(r.get("userid")), dt.strftime("%Y-%m-%d"))].append({
                "dt": dt, "pt": pt, "lat": dlat, "lon": dlon,
                "kitchen": normalize_kitchen(r.get("pickupcustomer")),
                "rider": str(r.get("userid")),
            })

        obs: List[Dict[str, Any]] = []
        for _key, items in groups.items():
            items.sort(key=lambda x: x["dt"])
            prev = None
            for i, it in enumerate(items):
                zone = self._zone.determine_zone(it["lat"], it["lon"])
                traffic = hour_to_traffic(it["dt"].hour)
                if i == 0:
                    # kitchen -> first drop; distance unknown without kitchen coords
                    leg_min = (it["dt"] - it["pt"]).total_seconds() / 60.0
                    leg_km = None
                    is_first = 1
                else:
                    leg_min = (it["dt"] - prev["dt"]).total_seconds() / 60.0
                    leg_km = _haversine_km(prev["lat"], prev["lon"], it["lat"], it["lon"])
                    is_first = 0
                prev = it
                # filter implausible legs
                if leg_min <= 0 or leg_min > _MAX_LEG_MIN:
                    continue
                if leg_km is not None and (leg_km <= 0 or leg_km > _MAX_LEG_KM):
                    continue
                obs.append({
                    "delivery_ts": it["dt"].strftime("%Y-%m-%d %H:%M:%S"),
                    "rider_id": it["rider"],
                    "kitchen": it["kitchen"],
                    "drop_zone": zone,
                    "traffic": traffic,
                    "dist_bucket": dist_bucket(leg_km),
                    "leg_km": leg_km,
                    "leg_min": round(leg_min, 2),
                    "is_first": is_first,
                })
        return obs

    @staticmethod
    def _aggregate(observations: List[Dict[str, Any]]) -> Dict[str, Tuple[int, float, float]]:
        """Group observations by every key and compute (count, median, p75)."""
        buckets: Dict[str, List[float]] = defaultdict(list)
        for o in observations:
            for k in _keys_for_observation(
                o["kitchen"], o["drop_zone"], o["traffic"], o["dist_bucket"], o["rider_id"]
            ):
                buckets[k].append(o["leg_min"])
        stats: Dict[str, Tuple[int, float, float]] = {}
        for k, vals in buckets.items():
            vals.sort()
            n = len(vals)
            median = statistics.median(vals)
            p75 = vals[min(n - 1, int(math.ceil(0.75 * n)) - 1)] if n else median
            stats[k] = (n, round(median, 2), round(p75, 2))
        return stats

    def rebuild_aggregates(self, days: int = 14) -> Dict[str, Any]:
        """
        Rebuild empirical medians from the LOCAL mirror only (no DB hit).
        Reconstructs per-leg observations, aggregates, persists, reloads cache.
        """
        rows = self._load_raw_rows(days)
        observations = self._build_observations(rows)
        stats = self._aggregate(observations)

        now = datetime.utcnow().isoformat()
        try:
            conn = sqlite3.connect(self._db_path)
            conn.execute("DELETE FROM delivery_history")
            conn.execute("DELETE FROM eta_stats")
            conn.executemany(
                "INSERT INTO delivery_history "
                "(delivery_ts, rider_id, kitchen, drop_zone, traffic, dist_bucket, leg_km, leg_min, is_first) "
                "VALUES (?,?,?,?,?,?,?,?,?)",
                [(o["delivery_ts"], o["rider_id"], o["kitchen"], o["drop_zone"],
                  o["traffic"], o["dist_bucket"], o["leg_km"], o["leg_min"], o["is_first"])
                 for o in observations],
            )
            conn.executemany(
                "INSERT INTO eta_stats (key, key_type, sample_count, median_min, p75_min, updated_at) "
                "VALUES (?,?,?,?,?,?)",
                [(k, k.split("|", 1)[0], n, med, p75, now) for k, (n, med, p75) in stats.items()],
            )
            conn.commit()
            conn.close()
        except Exception as e:
            logger.error(f"[DeliveryHistory] persist failed: {e}", exc_info=True)
            return {"status": "persist_failed", "error": str(e)}

        self._load_cache()
        self._last_refreshed = datetime.utcnow()
        n_by_type: Dict[str, int] = defaultdict(int)
        for k in stats:
            n_by_type[k.split("|", 1)[0]] += 1
        summary = {
            "status": "ok",
            "local_rows": len(rows),
            "observations": len(observations),
            "stat_keys": len(stats),
            "keys_by_type": dict(n_by_type),
            "history_days": days,
            "rebuilt_at": self._last_refreshed.isoformat(),
        }
        logger.info(
            f"[DeliveryHistory] aggregates rebuilt: local_rows={len(rows)} "
            f"obs={len(observations)} keys={len(stats)} ({dict(n_by_type)})"
        )
        return summary

    def refresh_eta_stats(self, days: int = 14, tenant_id: int = 916,
                          full: bool = False) -> Dict[str, Any]:
        """
        Full pipeline: incremental DB sync -> prune local store -> rebuild
        aggregates locally -> (if pattern_source=db) rebuild the Phase-0
        pattern store from the same synced rows. This is what the scheduler
        and the admin endpoint call. The DB is touched only by the sync step
        (new rows only).
        """
        from app.config.dynamic_config import get_config
        cfg = get_config()
        pattern_on = str(cfg.get("pattern_source", "csv")) == "db"
        pattern_days = int(cfg.get("pattern_history_days", 30))
        # Local retention must cover whichever consumer needs more history
        # (the ETA window vs. the pattern-store window) without changing the
        # ETA computation's own `days` window below — that backtest result is
        # already validated at 14 days and this must not perturb it.
        retain_days = max(days, pattern_days) if pattern_on else days

        with _WRITE_LOCK:
            try:
                sync = self.sync_from_db(days=retain_days, tenant_id=tenant_id, full=full)
            except Exception as e:
                logger.error(f"[DeliveryHistory] sync failed: {e}", exc_info=True)
                # Still try to serve whatever is already local.
                rebuilt = self.rebuild_aggregates(days)
                self._last_summary = {"status": "sync_failed", "error": str(e), "rebuild": rebuilt}
                return self._last_summary

            self._prune_raw(retain_days)
            rebuilt = self.rebuild_aggregates(days)

            pattern_rebuild = None
            if pattern_on:
                try:
                    from app.services.vector.delivery_history_store import get_delivery_history_store
                    records = self.get_pattern_records(pattern_days)
                    n = get_delivery_history_store().rebuild_from_records(records)
                    pattern_rebuild = {"records": n, "days": pattern_days}
                except Exception as e:
                    logger.warning(f"[DeliveryHistory] pattern-store rebuild skipped: {e}")

            self._last_summary = {
                "status": "ok", "sync": sync, "rebuild": rebuilt,
                "pattern_rebuild": pattern_rebuild,
            }
            return self._last_summary

    # -- cache + lookup -----------------------------------------------------

    def _load_cache(self) -> None:
        try:
            conn = sqlite3.connect(self._db_path)
            rows = conn.execute(
                "SELECT key, sample_count, median_min, p75_min FROM eta_stats"
            ).fetchall()
            conn.close()
            self._cache = {k: (int(n), float(med), float(p75)) for k, n, med, p75 in rows}
            if self._cache:
                logger.info(f"[DeliveryHistory] loaded {len(self._cache)} eta_stats keys into cache")
        except Exception as e:
            logger.warning(f"[DeliveryHistory] cache load failed: {e}")
            self._cache = {}

    def has_data(self) -> bool:
        return bool(self._cache)

    def maybe_background_refresh(self, days: int = 14, tenant_id: int = 916,
                                 cooldown_s: int = 600) -> bool:
        """
        If the cache is empty, kick off a one-shot background refresh (at most
        once per cooldown). Keeps the prediction hot path non-blocking — the
        current call falls back to the formula; later calls use empirical data.
        Returns True if a refresh thread was started.
        """
        if self._cache:
            return False
        with self._refresh_lock:
            if self._cache:
                return False
            now = datetime.utcnow()
            if (self._refresh_attempt_at is not None
                    and (now - self._refresh_attempt_at).total_seconds() < cooldown_s):
                return False
            self._refresh_attempt_at = now

        def _run():
            try:
                self.refresh_eta_stats(days, tenant_id)
            except Exception as e:
                logger.warning(f"[DeliveryHistory] background refresh failed: {e}")

        threading.Thread(target=_run, daemon=True, name="eta-refresh").start()
        return True

    def local_count(self) -> int:
        try:
            conn = sqlite3.connect(self._db_path)
            n = conn.execute("SELECT COUNT(*) FROM delivery_raw").fetchone()[0]
            conn.close()
            return int(n)
        except Exception:
            return 0

    def ensure_background_sync(self, interval_hours: int = 6, days: int = 14,
                               tenant_id: int = 916) -> bool:
        """
        Start the autonomous sync agent (once per process). It refreshes
        immediately on startup (full backfill if the local store is empty,
        otherwise incremental), then re-syncs every `interval_hours`.

        Runs in a daemon thread so it never blocks the app; all request-path
        ETA lookups read the local SQLite mirror, never Postgres.
        """
        with self._refresh_lock:
            if self._scheduler_started:
                return False
            self._scheduler_started = True

        def _loop():
            logger.info(
                f"[ETA-Agent] autonomous sync started — interval={interval_hours}h, window={days}d"
            )
            first = True
            while True:
                try:
                    full = first and self.local_count() == 0
                    result = self.refresh_eta_stats(days=days, tenant_id=tenant_id, full=full)
                    logger.info(f"[ETA-Agent] sync cycle done: {result.get('status')}")
                except Exception as e:
                    logger.warning(f"[ETA-Agent] sync cycle failed (will retry): {e}")
                # Recompute learned rider affinity from the freshly-synced mirror.
                try:
                    from app.services.routing.rider_affinity_service import get_rider_affinity
                    get_rider_affinity().refresh(days=days)
                except Exception as e:
                    logger.debug(f"[ETA-Agent] affinity refresh skipped: {e}")
                first = False
                time.sleep(max(1, int(interval_hours)) * 3600)

        threading.Thread(target=_loop, daemon=True, name="eta-sync-agent").start()
        return True

    def lookup(
        self,
        distance_km: float,
        traffic_cat: str,
        kitchen: Optional[str] = None,
        drop_coords: Optional[Tuple[float, float]] = None,
        min_samples: int = 20,
        stat: str = "median",
    ) -> Optional[Dict[str, Any]]:
        """
        Return the empirical leg time (minutes) for a context, or None if no key
        has >= min_samples. Walks the specificity hierarchy.
        """
        if not self._cache:
            return None
        zone = "Unknown"
        if drop_coords and drop_coords[0] and drop_coords[1]:
            zone = self._zone.determine_zone(float(drop_coords[0]), float(drop_coords[1]))
        bucket = dist_bucket(distance_km) if distance_km and distance_km > 0 else None
        k_norm = normalize_kitchen(kitchen) if kitchen else None

        for full_key in _lookup_keys(k_norm, zone, traffic_cat, bucket):
            hit = self._cache.get(full_key)
            if hit and hit[0] >= min_samples:
                count, median, p75 = hit
                value = p75 if stat == "p75" else median
                return {
                    "value_min": value,
                    "sample_count": count,
                    "source_key": full_key,
                    "key_type": full_key.split("|", 1)[0],
                }
        return None

    # -- diagnostics / backtest --------------------------------------------

    def get_summary(self) -> Dict[str, Any]:
        return {
            "has_data": self.has_data(),
            "cache_keys": len(self._cache),
            "last_summary": self._last_summary,
        }

    def backtest(self, days: int = 14, tenant_id: int = 916,
                 min_samples: int = 20, stat: str = "median") -> Dict[str, Any]:
        """
        Honest time-split backtest: build empirical stats on the older 80% of
        observations, then compare formula vs empirical MAE on the most recent
        20% (held out). Proves whether empirical beats the formula before trust.
        """
        from app.services.routing.realistic_eta_calculator import RealisticETACalculator
        formula = RealisticETACalculator()

        # Backtest runs entirely on the local mirror — no DB hit.
        rows = self._load_raw_rows(days)
        obs = self._build_observations(rows)
        obs = [o for o in obs if o["is_first"] == 0]  # need leg_km for both predictors
        if len(obs) < 50:
            return {"status": "insufficient_data", "observations": len(obs)}

        obs.sort(key=lambda o: o["delivery_ts"])
        split = int(len(obs) * 0.8)
        train, test = obs[:split], obs[split:]
        train_stats = self._aggregate(train)

        def _empirical(o) -> Optional[float]:
            for full_key in _lookup_keys(o["kitchen"], o["drop_zone"], o["traffic"], o["dist_bucket"]):
                hit = train_stats.get(full_key)
                if hit and hit[0] >= min_samples:
                    return hit[2] if stat == "p75" else hit[1]
            return None

        f_err, e_err, e_err_fallback, covered = [], [], [], 0
        for o in test:
            actual = o["leg_min"]
            f_pred = formula.calculate_eta(
                distance_km=o["leg_km"], is_first_order=False,
                order_type="Economy", time_of_day=o["traffic"],
            )
            f_err.append(abs(f_pred - actual))
            emp = _empirical(o)
            if emp is not None:
                covered += 1
                e_err.append(abs(emp - actual))
                e_err_fallback.append(abs(emp - actual))
            else:
                e_err_fallback.append(abs(f_pred - actual))  # fallback to formula

        def _mae(xs):
            return round(sum(xs) / len(xs), 2) if xs else None

        return {
            "status": "ok",
            "history_days": days,
            "observations": len(obs),
            "test_size": len(test),
            "empirical_coverage_pct": round(100.0 * covered / len(test), 1),
            "formula_mae_min": _mae(f_err),
            "empirical_mae_min_covered": _mae(e_err),
            "empirical_mae_min_with_fallback": _mae(e_err_fallback),
            "min_samples": min_samples,
            "stat": stat,
            "interpretation": (
                "empirical_better"
                if (_mae(e_err_fallback) is not None and _mae(f_err) is not None
                    and _mae(e_err_fallback) < _mae(f_err))
                else "no_improvement"
            ),
        }


# ---------------------------------------------------------------------------
# Singleton
# ---------------------------------------------------------------------------

_service: Optional[DeliveryHistoryService] = None
_service_lock = threading.Lock()


def get_delivery_history_service() -> DeliveryHistoryService:
    """Get (and lazily build) the DeliveryHistoryService singleton."""
    global _service
    with _service_lock:
        if _service is None:
            _service = DeliveryHistoryService()
        return _service
