///|
/// Production-oriented model routing and score caching.
///
/// Routing decisions are kept independent from model implementations.  This
/// lets a service deploy several versions, enforce health and latency budgets,
/// and still return a deterministic fallback when the preferred route is
/// warming or temporarily unavailable.
pub(all) enum RouteHealth {
  Healthy
  Warming
  Degraded
  Offline
} derive(Debug, Eq)

///|
pub(all) enum RoutingPolicy {
  RoundRobin
  Weighted
  LeastLatency
  LowestErrorRate
} derive(Debug, Eq)

///|
pub(all) enum FallbackMode {
  Reject
  BestAvailable
  DefaultRoute
} derive(Debug, Eq)

///|
pub struct RouteCandidate {
  id : String
  version : String
  mut weight : Double
  latency_budget : Double
  max_batch : Int
  mut health : RouteHealth
  mut requests : Int
  mut failures : Int
  mut total_latency : Double
  mut last_seen : Int64
}

///|
pub fn RouteCandidate::new(
  id : String,
  version : String,
  weight? : Double = 1.0,
  latency_budget? : Double = 100.0,
  max_batch? : Int = 64,
) -> RouteCandidate {
  {
    id,
    version,
    weight: if weight < 0.0 {
      0.0
    } else {
      weight
    },
    latency_budget: if latency_budget <= 0.0 {
      100.0
    } else {
      latency_budget
    },
    max_batch: if max_batch < 1 {
      1
    } else {
      max_batch
    },
    health: Healthy,
    requests: 0,
    failures: 0,
    total_latency: 0.0,
    last_seen: 0L,
  }
}

///|
pub fn RouteCandidate::id(self : RouteCandidate) -> String {
  self.id
}

///|
pub fn RouteCandidate::version(self : RouteCandidate) -> String {
  self.version
}

///|
pub fn RouteCandidate::weight(self : RouteCandidate) -> Double {
  self.weight
}

///|
pub fn RouteCandidate::health(self : RouteCandidate) -> RouteHealth {
  self.health
}

///|
pub fn RouteCandidate::set_health(
  self : RouteCandidate,
  health : RouteHealth,
) -> Unit {
  self.health = health
}

///|
pub fn RouteCandidate::set_weight(
  self : RouteCandidate,
  weight : Double,
) -> Unit {
  self.weight = if weight < 0.0 { 0.0 } else { weight }
}

///|
pub fn RouteCandidate::max_batch(self : RouteCandidate) -> Int {
  self.max_batch
}

///|
pub fn RouteCandidate::latency_budget(self : RouteCandidate) -> Double {
  self.latency_budget
}

///|
pub fn RouteCandidate::requests(self : RouteCandidate) -> Int {
  self.requests
}

///|
pub fn RouteCandidate::failures(self : RouteCandidate) -> Int {
  self.failures
}

///|
pub fn RouteCandidate::average_latency(self : RouteCandidate) -> Double {
  if self.requests == 0 {
    0.0
  } else {
    self.total_latency / self.requests.to_double()
  }
}

///|
pub fn RouteCandidate::error_rate(self : RouteCandidate) -> Double {
  if self.requests == 0 {
    0.0
  } else {
    self.failures.to_double() / self.requests.to_double()
  }
}

///|
pub fn RouteCandidate::available(self : RouteCandidate) -> Bool {
  match self.health {
    Offline => false
    _ => true
  }
}

///|
pub fn RouteCandidate::record(
  self : RouteCandidate,
  success : Bool,
  latency : Double,
  timestamp : Int64,
) -> Unit {
  self.requests += 1
  if !success {
    self.failures += 1
  }
  self.total_latency += if latency < 0.0 || latency.is_nan() {
    0.0
  } else {
    latency
  }
  self.last_seen = timestamp
  if self.error_rate() > 0.5 {
    self.health = Degraded
  } else if self.health == Warming && self.requests >= 3 {
    self.health = Healthy
  }
}

///|
pub fn RouteCandidate::reset(self : RouteCandidate) -> Unit {
  self.health = Healthy
  self.requests = 0
  self.failures = 0
  self.total_latency = 0.0
  self.last_seen = 0L
}

///|
pub struct RouteDecision {
  route_id : String
  version : String
  fallback : Bool
  reason : String
}

///|
pub fn RouteDecision::new(
  route_id : String,
  version : String,
  fallback : Bool,
  reason : String,
) -> RouteDecision {
  { route_id, version, fallback, reason }
}

///|
pub fn RouteDecision::route_id(self : RouteDecision) -> String {
  self.route_id
}

///|
pub fn RouteDecision::version(self : RouteDecision) -> String {
  self.version
}

///|
pub fn RouteDecision::is_fallback(self : RouteDecision) -> Bool {
  self.fallback
}

///|
pub fn RouteDecision::reason(self : RouteDecision) -> String {
  self.reason
}

///|
pub struct ModelRouter {
  candidates : Map[String, RouteCandidate]
  mut policy : RoutingPolicy
  mut fallback : FallbackMode
  mut default_route : String?
  mut cursor : Int
  mut decisions : Int
  mut fallbacks : Int
  mut rejected : Int
}

///|
pub fn ModelRouter::new(
  policy? : RoutingPolicy = Weighted,
  fallback? : FallbackMode = BestAvailable,
) -> ModelRouter {
  {
    candidates: Map([]),
    policy,
    fallback,
    default_route: None,
    cursor: 0,
    decisions: 0,
    fallbacks: 0,
    rejected: 0,
  }
}

///|
pub fn ModelRouter::register(
  self : ModelRouter,
  candidate : RouteCandidate,
  default? : Bool = false,
) -> Bool {
  let id = candidate.id()
  if id == "" || self.candidates.contains(id) {
    false
  } else {
    self.candidates[id] = candidate
    if default {
      self.default_route = Some(id)
    }
    true
  }
}

///|
pub fn ModelRouter::remove(self : ModelRouter, route_id : String) -> Bool {
  if self.candidates.contains(route_id) {
    self.candidates.remove(route_id)
    if self.default_route == Some(route_id) {
      self.default_route = None
    }
    true
  } else {
    false
  }
}

///|
pub fn ModelRouter::set_policy(
  self : ModelRouter,
  policy : RoutingPolicy,
) -> Unit {
  self.policy = policy
}

///|
pub fn ModelRouter::set_fallback(
  self : ModelRouter,
  fallback : FallbackMode,
) -> Unit {
  self.fallback = fallback
}

///|
pub fn ModelRouter::set_default(self : ModelRouter, route_id : String) -> Bool {
  if self.candidates.contains(route_id) {
    self.default_route = Some(route_id)
    true
  } else {
    false
  }
}

///|
fn route_ids(router : ModelRouter, available_only : Bool) -> Array[String] {
  let ids : Array[String] = []
  router.candidates.each((id, candidate) => {
    if !available_only || candidate.available() {
      ids.push(id)
    }
  })
  ids.sort()
  ids
}

///|
fn select_weighted(router : ModelRouter, ids : Array[String]) -> String? {
  if ids.is_empty() {
    None
  } else {
    let total = Ref(0.0)
    for id in ids {
      total.val += router.candidates[id].weight()
    }
    if total.val <= 0.0 {
      Some(ids[router.cursor % ids.length()])
    } else {
      let target = router.cursor.to_double() * 0.6180339887 % 1.0 * total.val
      let running = Ref(0.0)
      let selected = Ref(ids[ids.length() - 1])
      for id in ids {
        running.val += router.candidates[id].weight()
        if running.val >= target && selected.val == ids[ids.length() - 1] {
          selected.val = id
        }
      }
      Some(selected.val)
    }
  }
}

///|
fn select_route(router : ModelRouter, ids : Array[String]) -> String? {
  if ids.is_empty() {
    None
  } else {
    match router.policy {
      RoundRobin => Some(ids[router.cursor % ids.length()])
      Weighted => select_weighted(router, ids)
      LeastLatency => {
        let selected = Ref(ids[0])
        for id in ids {
          if router.candidates[id].average_latency() <
            router.candidates[selected.val].average_latency() {
            selected.val = id
          }
        }
        Some(selected.val)
      }
      LowestErrorRate => {
        let selected = Ref(ids[0])
        for id in ids {
          if router.candidates[id].error_rate() <
            router.candidates[selected.val].error_rate() {
            selected.val = id
          }
        }
        Some(selected.val)
      }
    }
  }
}

///|
pub fn ModelRouter::choose(self : ModelRouter) -> RouteDecision? {
  let available = route_ids(self, true)
  let mut fallback = false
  let selected = select_route(self, available)
  let chosen = match selected {
    Some(route_id) => Some(route_id)
    None =>
      match self.fallback {
        Reject => None
        BestAvailable => {
          fallback = true
          select_route(self, route_ids(self, false))
        }
        DefaultRoute => {
          fallback = true
          self.default_route
        }
      }
  }
  self.cursor += 1
  match chosen {
    Some(route_id) => {
      self.decisions += 1
      if fallback {
        self.fallbacks += 1
      }
      let candidate = self.candidates[route_id]
      Some(
        RouteDecision::new(
          route_id,
          candidate.version(),
          fallback,
          if fallback {
            "fallback"
          } else {
            "preferred"
          },
        ),
      )
    }
    None => {
      self.rejected += 1
      None
    }
  }
}

///|
pub fn ModelRouter::record(
  self : ModelRouter,
  route_id : String,
  success : Bool,
  latency : Double,
  timestamp : Int64,
) -> Bool {
  match self.candidates.get(route_id) {
    Some(candidate) => {
      candidate.record(success, latency, timestamp)
      true
    }
    None => false
  }
}

///|
pub fn ModelRouter::candidate(
  self : ModelRouter,
  route_id : String,
) -> RouteCandidate? {
  self.candidates.get(route_id)
}

///|
pub fn ModelRouter::routes(self : ModelRouter) -> Array[String] {
  route_ids(self, false)
}

///|
pub fn ModelRouter::available_routes(self : ModelRouter) -> Array[String] {
  route_ids(self, true)
}

///|
pub fn ModelRouter::decisions(self : ModelRouter) -> Int {
  self.decisions
}

///|
pub fn ModelRouter::fallbacks(self : ModelRouter) -> Int {
  self.fallbacks
}

///|
pub fn ModelRouter::rejected(self : ModelRouter) -> Int {
  self.rejected
}

///|
pub fn ModelRouter::fallback_rate(self : ModelRouter) -> Double {
  if self.decisions == 0 {
    0.0
  } else {
    self.fallbacks.to_double() / self.decisions.to_double()
  }
}

///|
pub fn ModelRouter::reset(self : ModelRouter) -> Unit {
  self.candidates.each((_, candidate) => candidate.reset())
  self.cursor = 0
  self.decisions = 0
  self.fallbacks = 0
  self.rejected = 0
}

///|
pub struct PredictionEnvelope {
  request_id : String
  route_id : String
  version : String
  values : Array[Double]
  confidence : Double
  timestamp : Int64
  cached : Bool
}

///|
pub fn PredictionEnvelope::new(
  request_id : String,
  decision : RouteDecision,
  values : Array[Double],
  confidence : Double,
  timestamp : Int64,
  cached? : Bool = false,
) -> PredictionEnvelope {
  {
    request_id,
    route_id: decision.route_id(),
    version: decision.version(),
    values: copy_vector(values),
    confidence: clamp_probability(confidence),
    timestamp,
    cached,
  }
}

///|
pub fn PredictionEnvelope::request_id(self : PredictionEnvelope) -> String {
  self.request_id
}

///|
pub fn PredictionEnvelope::route_id(self : PredictionEnvelope) -> String {
  self.route_id
}

///|
pub fn PredictionEnvelope::version(self : PredictionEnvelope) -> String {
  self.version
}

///|
pub fn PredictionEnvelope::values(self : PredictionEnvelope) -> Array[Double] {
  copy_vector(self.values)
}

///|
pub fn PredictionEnvelope::confidence(self : PredictionEnvelope) -> Double {
  self.confidence
}

///|
pub fn PredictionEnvelope::timestamp(self : PredictionEnvelope) -> Int64 {
  self.timestamp
}

///|
pub fn PredictionEnvelope::cached(self : PredictionEnvelope) -> Bool {
  self.cached
}

///|
pub struct ScoreCacheEntry {
  key : String
  values : Array[Double]
  created_at : Int64
  expires_at : Int64
  mut hits : Int
}

///|
pub fn ScoreCacheEntry::new(
  key : String,
  values : Array[Double],
  created_at : Int64,
  ttl : Int64,
) -> ScoreCacheEntry {
  {
    key,
    values: copy_vector(values),
    created_at,
    expires_at: created_at + ttl,
    hits: 0,
  }
}

///|
pub fn ScoreCacheEntry::key(self : ScoreCacheEntry) -> String {
  self.key
}

///|
pub fn ScoreCacheEntry::values(self : ScoreCacheEntry) -> Array[Double] {
  copy_vector(self.values)
}

///|
pub fn ScoreCacheEntry::created_at(self : ScoreCacheEntry) -> Int64 {
  self.created_at
}

///|
pub fn ScoreCacheEntry::expires_at(self : ScoreCacheEntry) -> Int64 {
  self.expires_at
}

///|
pub fn ScoreCacheEntry::expired(self : ScoreCacheEntry, now : Int64) -> Bool {
  now >= self.expires_at
}

///|
pub fn ScoreCacheEntry::record_hit(self : ScoreCacheEntry) -> Unit {
  self.hits += 1
}

///|
pub fn ScoreCacheEntry::hits(self : ScoreCacheEntry) -> Int {
  self.hits
}

///|
pub struct ScoreMemoCache {
  capacity : Int
  ttl : Int64
  entries : Map[String, ScoreCacheEntry]
  order : Array[String]
  mut lookups : Int
  mut hits : Int
  mut misses : Int
  mut evictions : Int
}

///|
pub fn ScoreMemoCache::new(
  capacity? : Int = 4096,
  ttl? : Int64 = 60000L,
) -> ScoreMemoCache {
  {
    capacity: if capacity < 0 {
      0
    } else {
      capacity
    },
    ttl: if ttl <= 0L {
      1L
    } else {
      ttl
    },
    entries: Map([]),
    order: [],
    lookups: 0,
    hits: 0,
    misses: 0,
    evictions: 0,
  }
}

///|
pub fn ScoreMemoCache::get(
  self : ScoreMemoCache,
  key : String,
  now : Int64,
) -> Array[Double]? {
  self.lookups += 1
  match self.entries.get(key) {
    Some(entry) =>
      if entry.expired(now) {
        self.entries.remove(key)
        self.misses += 1
        None
      } else {
        entry.record_hit()
        self.hits += 1
        Some(entry.values())
      }
    None => {
      self.misses += 1
      None
    }
  }
}

///|
pub fn ScoreMemoCache::put(
  self : ScoreMemoCache,
  key : String,
  values : Array[Double],
  now : Int64,
) -> Bool {
  if self.capacity == 0 || key == "" {
    false
  } else if self.entries.contains(key) {
    self.entries[key] = ScoreCacheEntry::new(key, values, now, self.ttl)
    true
  } else {
    self.entries[key] = ScoreCacheEntry::new(key, values, now, self.ttl)
    self.order.push(key)
    if self.order.length() > self.capacity {
      let oldest = self.order.remove(0)
      self.entries.remove(oldest)
      self.evictions += 1
    }
    true
  }
}

///|
pub fn ScoreMemoCache::remove(self : ScoreMemoCache, key : String) -> Bool {
  if self.entries.contains(key) {
    self.entries.remove(key)
    true
  } else {
    false
  }
}

///|
pub fn ScoreMemoCache::size(self : ScoreMemoCache) -> Int {
  self.entries.length()
}

///|
pub fn ScoreMemoCache::lookups(self : ScoreMemoCache) -> Int {
  self.lookups
}

///|
pub fn ScoreMemoCache::hits(self : ScoreMemoCache) -> Int {
  self.hits
}

///|
pub fn ScoreMemoCache::misses(self : ScoreMemoCache) -> Int {
  self.misses
}

///|
pub fn ScoreMemoCache::evictions(self : ScoreMemoCache) -> Int {
  self.evictions
}

///|
pub fn ScoreMemoCache::hit_rate(self : ScoreMemoCache) -> Double {
  if self.lookups == 0 {
    0.0
  } else {
    self.hits.to_double() / self.lookups.to_double()
  }
}

///|
pub fn ScoreMemoCache::clear(self : ScoreMemoCache) -> Unit {
  self.entries.clear()
  self.order.clear()
  self.lookups = 0
  self.hits = 0
  self.misses = 0
  self.evictions = 0
}