///|
/// Metadata and runtime score for one deployable estimation model.
pub struct ModelCatalogEntry {
  name : String
  version : String
  dimension : Int
  metric : String
  score : Double
  latency_ms : Double
  memory_bytes : Int
  tags : Array[String]
  mut enabled : Bool
  mut uses : Int
} derive(Debug)

///|
pub fn ModelCatalogEntry::new(
  name : String,
  version : String,
  dimension : Int,
  metric : String,
  score : Double,
  latency_ms : Double,
  memory_bytes : Int,
  tags : Array[String],
) -> ModelCatalogEntry {
  {
    name,
    version,
    dimension: dimension.max(0),
    metric,
    score: if score.is_nan() || score.is_inf() {
      0.0
    } else {
      score
    },
    latency_ms: latency_ms.max(0.0),
    memory_bytes: memory_bytes.max(0),
    tags: tags.copy(),
    enabled: true,
    uses: 0,
  }
}

///|
pub fn ModelCatalogEntry::name(self : ModelCatalogEntry) -> String {
  self.name
}

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

///|
pub fn ModelCatalogEntry::dimension(self : ModelCatalogEntry) -> Int {
  self.dimension
}

///|
pub fn ModelCatalogEntry::metric(self : ModelCatalogEntry) -> String {
  self.metric
}

///|
pub fn ModelCatalogEntry::score(self : ModelCatalogEntry) -> Double {
  self.score
}

///|
pub fn ModelCatalogEntry::latency_ms(self : ModelCatalogEntry) -> Double {
  self.latency_ms
}

///|
pub fn ModelCatalogEntry::memory_bytes(self : ModelCatalogEntry) -> Int {
  self.memory_bytes
}

///|
pub fn ModelCatalogEntry::tags(self : ModelCatalogEntry) -> Array[String] {
  self.tags.copy()
}

///|
pub fn ModelCatalogEntry::enabled(self : ModelCatalogEntry) -> Bool {
  self.enabled
}

///|
pub fn ModelCatalogEntry::uses(self : ModelCatalogEntry) -> Int {
  self.uses
}

///|
pub fn ModelCatalogEntry::set_enabled(
  self : ModelCatalogEntry,
  enabled : Bool,
) -> Unit {
  self.enabled = enabled
}

///|
pub fn ModelCatalogEntry::record_use(self : ModelCatalogEntry) -> Unit {
  self.uses = self.uses + 1
}

///|
pub fn ModelCatalogEntry::has_tag(
  self : ModelCatalogEntry,
  tag : String,
) -> Bool {
  for value in self.tags {
    if value == tag {
      return true
    }
  }
  false
}

///|
pub fn ModelCatalogEntry::compatible(
  self : ModelCatalogEntry,
  dimension : Int,
  required_tags : Array[String],
) -> Bool {
  if !self.enabled || self.dimension != dimension {
    return false
  }
  for tag in required_tags {
    if !self.has_tag(tag) {
      return false
    }
  }
  true
}

///|
pub fn ModelCatalogEntry::identity(self : ModelCatalogEntry) -> String {
  self.name + "@" + self.version
}

///|
pub fn ModelCatalogEntry::with_score(
  self : ModelCatalogEntry,
  score : Double,
  latency_ms : Double,
) -> ModelCatalogEntry {
  {
    name: self.name,
    version: self.version,
    dimension: self.dimension,
    metric: self.metric,
    score,
    latency_ms: latency_ms.max(0.0),
    memory_bytes: self.memory_bytes,
    tags: self.tags.copy(),
    enabled: self.enabled,
    uses: self.uses,
  }
}

///|
/// Selection policy used to turn offline metrics into a deployable choice.
pub struct ModelSelectionPolicy {
  metric : String
  higher_is_better : Bool
  max_latency_ms : Double
  max_memory_bytes : Int
  minimum_score : Double
  required_tags : Array[String]
  dimension : Int?
} derive(Debug)

///|
pub fn ModelSelectionPolicy::new(
  metric : String,
  higher_is_better : Bool,
  max_latency_ms : Double,
  max_memory_bytes : Int,
  minimum_score : Double,
) -> ModelSelectionPolicy {
  {
    metric,
    higher_is_better,
    max_latency_ms: max_latency_ms.max(0.0),
    max_memory_bytes: max_memory_bytes.max(0),
    minimum_score,
    required_tags: [],
    dimension: None,
  }
}

///|
pub fn ModelSelectionPolicy::metric(self : ModelSelectionPolicy) -> String {
  self.metric
}

///|
pub fn ModelSelectionPolicy::higher_is_better(
  self : ModelSelectionPolicy,
) -> Bool {
  self.higher_is_better
}

///|
pub fn ModelSelectionPolicy::max_latency_ms(
  self : ModelSelectionPolicy,
) -> Double {
  self.max_latency_ms
}

///|
pub fn ModelSelectionPolicy::max_memory_bytes(
  self : ModelSelectionPolicy,
) -> Int {
  self.max_memory_bytes
}

///|
pub fn ModelSelectionPolicy::minimum_score(
  self : ModelSelectionPolicy,
) -> Double {
  self.minimum_score
}

///|
pub fn ModelSelectionPolicy::required_tags(
  self : ModelSelectionPolicy,
) -> Array[String] {
  self.required_tags.copy()
}

///|
pub fn ModelSelectionPolicy::dimension(self : ModelSelectionPolicy) -> Int? {
  self.dimension
}

///|
pub fn ModelSelectionPolicy::with_tags(
  self : ModelSelectionPolicy,
  tags : Array[String],
) -> ModelSelectionPolicy {
  {
    metric: self.metric,
    higher_is_better: self.higher_is_better,
    max_latency_ms: self.max_latency_ms,
    max_memory_bytes: self.max_memory_bytes,
    minimum_score: self.minimum_score,
    required_tags: tags.copy(),
    dimension: self.dimension,
  }
}

///|
pub fn ModelSelectionPolicy::for_dimension(
  self : ModelSelectionPolicy,
  dimension : Int,
) -> ModelSelectionPolicy {
  {
    metric: self.metric,
    higher_is_better: self.higher_is_better,
    max_latency_ms: self.max_latency_ms,
    max_memory_bytes: self.max_memory_bytes,
    minimum_score: self.minimum_score,
    required_tags: self.required_tags.copy(),
    dimension: Some(dimension.max(0)),
  }
}

///|
/// A scored evaluation returned by model selection.
pub struct ModelEvaluation {
  identity : String
  eligible : Bool
  score : Double
  normalized_score : Double
  reasons : Array[String]
} derive(Debug)

///|
pub fn ModelEvaluation::new(
  entry : ModelCatalogEntry,
  eligible : Bool,
  normalized_score : Double,
  reasons : Array[String],
) -> ModelEvaluation {
  {
    identity: entry.identity(),
    eligible,
    score: entry.score(),
    normalized_score: normalized_score.clamp(min=0.0, max=1.0),
    reasons: reasons.copy(),
  }
}

///|
pub fn ModelEvaluation::identity(self : ModelEvaluation) -> String {
  self.identity
}

///|
pub fn ModelEvaluation::eligible(self : ModelEvaluation) -> Bool {
  self.eligible
}

///|
pub fn ModelEvaluation::score(self : ModelEvaluation) -> Double {
  self.score
}

///|
pub fn ModelEvaluation::normalized_score(self : ModelEvaluation) -> Double {
  self.normalized_score
}

///|
pub fn ModelEvaluation::reasons(self : ModelEvaluation) -> Array[String] {
  self.reasons.copy()
}

///|
/// Result of a selection pass, including explainability for rejected models.
pub struct ModelSelectionResult {
  selected : ModelCatalogEntry?
  evaluations : Array[ModelEvaluation]
  fallback_used : Bool
  reason : String
} derive(Debug)

///|
pub fn ModelSelectionResult::none(
  evaluations : Array[ModelEvaluation],
  reason : String,
) -> ModelSelectionResult {
  {
    selected: None,
    evaluations: evaluations.copy(),
    fallback_used: false,
    reason,
  }
}

///|
pub fn ModelSelectionResult::selected(
  entry : ModelCatalogEntry,
  evaluations : Array[ModelEvaluation],
  fallback_used : Bool,
  reason : String,
) -> ModelSelectionResult {
  {
    selected: Some(entry),
    evaluations: evaluations.copy(),
    fallback_used,
    reason,
  }
}

///|
pub fn ModelSelectionResult::selected_entry(
  self : ModelSelectionResult,
) -> ModelCatalogEntry? {
  self.selected
}

///|
pub fn ModelSelectionResult::evaluations(
  self : ModelSelectionResult,
) -> Array[ModelEvaluation] {
  self.evaluations.copy()
}

///|
pub fn ModelSelectionResult::fallback_used(self : ModelSelectionResult) -> Bool {
  self.fallback_used
}

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

///|
/// Versioned model catalog with explicit activation and selection.
pub struct ModelCatalog {
  entries : Array[ModelCatalogEntry]
  mut active_identity : String?
  max_entries : Int
  mut registrations : Int
  mut selections : Int
} derive(Debug)

///|
pub fn ModelCatalog::new(max_entries : Int) -> ModelCatalog {
  {
    entries: [],
    active_identity: None,
    max_entries: max_entries.max(1),
    registrations: 0,
    selections: 0,
  }
}

///|
pub fn ModelCatalog::entries(self : ModelCatalog) -> Array[ModelCatalogEntry] {
  self.entries.copy()
}

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

///|
pub fn ModelCatalog::registrations(self : ModelCatalog) -> Int {
  self.registrations
}

///|
pub fn ModelCatalog::selections(self : ModelCatalog) -> Int {
  self.selections
}

///|
pub fn ModelCatalog::active_identity(self : ModelCatalog) -> String? {
  self.active_identity
}

///|
fn ModelCatalog::model_catalog_find(
  self : ModelCatalog,
  identity : String,
) -> Int {
  for i, entry in self.entries {
    if entry.identity() == identity {
      return i
    }
  }
  -1
}

///|
pub fn ModelCatalog::register(
  self : ModelCatalog,
  entry : ModelCatalogEntry,
) -> Bool {
  let identity = entry.identity()
  let existing = self.model_catalog_find(identity)
  if existing >= 0 {
    self.entries[existing] = entry
    self.registrations = self.registrations + 1
    return true
  }
  if self.entries.length() >= self.max_entries {
    return false
  }
  self.entries.push(entry)
  self.registrations = self.registrations + 1
  true
}

///|
pub fn ModelCatalog::unregister(self : ModelCatalog, identity : String) -> Bool {
  let index = self.model_catalog_find(identity)
  if index < 0 {
    false
  } else {
    self.entries.remove(index) |> ignore
    match self.active_identity {
      Some(active) => if active == identity { self.active_identity = None }
      None => ()
    }
    true
  }
}

///|
pub fn ModelCatalog::find(
  self : ModelCatalog,
  identity : String,
) -> ModelCatalogEntry? {
  let index = self.model_catalog_find(identity)
  if index < 0 {
    None
  } else {
    Some(self.entries[index])
  }
}

///|
pub fn ModelCatalog::enable(
  self : ModelCatalog,
  identity : String,
  enabled : Bool,
) -> Bool {
  match self.find(identity) {
    None => false
    Some(entry) => {
      entry.set_enabled(enabled)
      true
    }
  }
}

///|
pub fn ModelCatalog::activate(self : ModelCatalog, identity : String) -> Bool {
  match self.find(identity) {
    None => false
    Some(entry) =>
      if entry.enabled() {
        self.active_identity = Some(identity)
        true
      } else {
        false
      }
  }
}

///|
fn model_selection_score(
  entry : ModelCatalogEntry,
  policy : ModelSelectionPolicy,
) -> Double {
  if policy.higher_is_better() {
    entry.score()
  } else {
    1.0 / (1.0 + entry.score().abs())
  }
}

///|
fn model_selection_reasons(
  entry : ModelCatalogEntry,
  policy : ModelSelectionPolicy,
) -> (Bool, Array[String]) {
  let reasons : Array[String] = []
  let mut eligible = entry.enabled()
  if !entry.enabled() {
    reasons.push("disabled")
    eligible = false
  }
  if entry.metric() != policy.metric() {
    reasons.push("metric-mismatch")
    eligible = false
  }
  if entry.latency_ms() > policy.max_latency_ms() &&
    policy.max_latency_ms() > 0.0 {
    reasons.push("latency-limit")
    eligible = false
  }
  if entry.memory_bytes() > policy.max_memory_bytes() &&
    policy.max_memory_bytes() > 0 {
    reasons.push("memory-limit")
    eligible = false
  }
  if entry.score() < policy.minimum_score() && policy.higher_is_better() {
    reasons.push("score-floor")
    eligible = false
  }
  match policy.dimension() {
    Some(dimension) =>
      if entry.dimension() != dimension {
        reasons.push("dimension-mismatch")
        eligible = false
      }
    None => ()
  }
  for tag in policy.required_tags() {
    if !entry.has_tag(tag) {
      reasons.push("tag-missing:" + tag)
      eligible = false
    }
  }
  (eligible, reasons)
}

///|
pub fn ModelCatalog::select(
  self : ModelCatalog,
  policy : ModelSelectionPolicy,
) -> ModelSelectionResult {
  let evaluations : Array[ModelEvaluation] = []
  let mut selected : ModelCatalogEntry? = None
  let mut best_score = -1.0e300
  for entry in self.entries {
    let (eligible, reasons) = model_selection_reasons(entry, policy)
    let score = model_selection_score(entry, policy)
    evaluations.push(ModelEvaluation::new(entry, eligible, score, reasons))
    if eligible && score > best_score {
      best_score = score
      selected = Some(entry)
    }
  }
  self.selections = self.selections + 1
  match selected {
    None => ModelSelectionResult::none(evaluations, "no-compatible-model")
    Some(entry) => {
      entry.record_use()
      ModelSelectionResult::selected(entry, evaluations, false, "best-score")
    }
  }
}

///|
pub fn ModelCatalog::select_or_active(
  self : ModelCatalog,
  policy : ModelSelectionPolicy,
) -> ModelSelectionResult {
  let result = self.select(policy)
  match result.selected_entry() {
    Some(_) => result
    None =>
      match self.active_identity {
        None => result
        Some(identity) =>
          match self.find(identity) {
            None => result
            Some(entry) =>
              ModelSelectionResult::selected(
                entry,
                result.evaluations(),
                true,
                "active-fallback",
              )
          }
      }
  }
}

///|
pub fn model_catalog_best_by_latency(
  catalog : ModelCatalog,
) -> ModelCatalogEntry? {
  let mut selected : ModelCatalogEntry? = None
  for entry in catalog.entries() {
    if entry.enabled() {
      match selected {
        None => selected = Some(entry)
        Some(previous) =>
          if entry.latency_ms() < previous.latency_ms() {
            selected = Some(entry)
          }
      }
    }
  }
  selected
}

///|
pub fn model_catalog_total_memory(catalog : ModelCatalog) -> Int {
  let mut total = 0
  for entry in catalog.entries() {
    if entry.enabled() {
      total = total + entry.memory_bytes()
    }
  }
  total
}

///|
pub fn model_catalog_average_score(catalog : ModelCatalog) -> Double {
  let mut total = 0.0
  let mut count = 0
  for entry in catalog.entries() {
    if entry.enabled() {
      total = total + entry.score()
      count = count + 1
    }
  }
  if count == 0 {
    0.0
  } else {
    total / count.to_double()
  }
}

///|
pub fn model_catalog_compatible(
  catalog : ModelCatalog,
  dimension : Int,
  tags : Array[String],
) -> Array[ModelCatalogEntry] {
  let result : Array[ModelCatalogEntry] = []
  for entry in catalog.entries() {
    if entry.compatible(dimension, tags) {
      result.push(entry)
    }
  }
  result
}

///|
pub fn model_catalog_summary(catalog : ModelCatalog) -> String {
  let active = match catalog.active_identity() {
    None => "none"
    Some(identity) => identity
  }
  "models=" +
  catalog.length().to_string() +
  ",active=" +
  active +
  ",mean_score=" +
  model_catalog_average_score(catalog).to_string()
}

///|
/// Compare two entries with a lexicographic score/latency policy.
pub fn model_entry_preferred(
  left : ModelCatalogEntry,
  right : ModelCatalogEntry,
  higher_is_better : Bool,
) -> Bool {
  if !left.enabled() {
    return false
  }
  if !right.enabled() {
    return true
  }
  if left.score() == right.score() {
    left.latency_ms() < right.latency_ms()
  } else if higher_is_better {
    left.score() > right.score()
  } else {
    left.score() < right.score()
  }
}

///|
pub fn model_catalog_rank(
  catalog : ModelCatalog,
  policy : ModelSelectionPolicy,
) -> Array[ModelEvaluation] {
  catalog.select(policy).evaluations()
}

///|
pub fn model_metric_regression_score(
  baseline : Double,
  candidate : Double,
  higher_is_better : Bool,
) -> Double {
  if baseline.is_nan() || candidate.is_nan() {
    0.0
  } else if higher_is_better {
    (candidate / baseline.max(1.0e-12)).clamp(min=0.0, max=2.0) * 0.5
  } else {
    (baseline / candidate.max(1.0e-12)).clamp(min=0.0, max=2.0) * 0.5
  }
}

///|
pub fn model_catalog_has_regression(
  baseline : ModelCatalogEntry,
  candidate : ModelCatalogEntry,
  tolerance : Double,
  higher_is_better : Bool,
) -> Bool {
  let improvement = model_metric_regression_score(
    baseline.score(),
    candidate.score(),
    higher_is_better,
  )
  improvement + tolerance.max(0.0) < 0.5
}