///|
/// ModelPort aggregator with host-injected model slots.
///
/// The router is deliberately unaware of provider adapters, endpoint
/// configuration, model discovery, and credential transport. A host composes
/// ModelSlot values and injects already-constructed ModelPorts.

///|
pub(all) struct RouterModelPort {
  slots : Map[String, ModelSlot]
  slot_order : Array[String]
  mut active_id : String
  mut active_effort : String?
  /// Devkit status bus for the model/ctx/cache status segments; `None`
  /// disables publication entirely.
  bus : @devkit.EventBus?
  /// Latest core-projected context state per session id; the `ctx` status
  /// segment renders the entry of the session an event is scoped to.
  context_states : Map[String, @types.ContextState]
  /// Canonical one-record store. When present, login writes a tagged
  /// ProviderCredential and replaces the previous method atomically.
  provider_credential_store : &@oauth.ProviderCredentialStore?
  cred_store : &@oauth.CredentialStore?
  auth_interaction : &@oauth.AuthInteraction?
  api_key_store : &@oauth.ApiKeyStore?
  auth_prompt : &@oauth.AuthPromptInteraction?
  credentials : Map[String, @oauth.Credential]
  api_key_credentials : Map[String, @oauth.ApiKeyCredential]
  /// Host-injected quota sources keyed by provider id, consulted before the
  /// devkit registry. Codex is push-shaped (readings ride on chat responses
  /// into a shared tracker with no polling endpoint), so its host injects the
  /// tracker here; registering it in the registry would make the active-
  /// provider path double-publish "5h"/"weekly" segments against the codex
  /// status publisher.
  extra_quota_sources : Map[String, &@devkit.QuotaSource]
  /// Optional semantic judgement capability used for per-call effort selection.
  priv mut decision : &@posoco.DecisionPort?
  /// Latest value per status segment observed on the shared bus; `/status`
  /// surfaces the provider-published quota windows ("5h", "weekly") from it.
  priv status_segments : Map[String, String]
  /// Latest registry quota readings for the active provider, rendered onto
  /// the status bar by `publish_quota_segments`.
  priv mut quota_readings : Array[@devkit.QuotaReading]
  /// Last successful registry pull (ms since epoch); throttles re-reads.
  priv mut quota_fetched_at : Int64
  /// Last failed registry pull (ms since epoch); backs re-reads off.
  priv mut quota_failed_at : Int64
  /// Quota segment names this router currently has published on the bar.
  priv mut quota_shown : Array[String]
}

///|
/// Construct a router from a non-empty, uniquely keyed slot list.
///
/// An empty default id selects the first slot in insertion order. A
/// non-empty default that is absent from `slots` raises, unless
/// `allow_missing_default` is set: hosts then keep the persisted selection
/// as a *pending* active id — requests to it fail visibly until the slot
/// appears (login/refresh) instead of silently moving to another model.
/// Composition failures are raised before the router is returned, so
/// callers never see a partially populated routing table.
pub fn RouterModelPort::RouterModelPort(
  slots~ : Array[ModelSlot],
  default_slot_id? : String = "",
  allow_missing_default? : Bool = false,
  bus? : @devkit.EventBus? = None,
  provider_credential_store? : &@oauth.ProviderCredentialStore? = None,
  cred_store? : &@oauth.CredentialStore? = None,
  auth_interaction? : &@oauth.AuthInteraction? = None,
  api_key_store? : &@oauth.ApiKeyStore? = None,
  auth_prompt? : &@oauth.AuthPromptInteraction? = None,
  extra_quota_sources? : Map[String, &@devkit.QuotaSource] = Map::from_array([]),
) -> RouterModelPort raise @posoco.CompositionError {
  if slots.is_empty() {
    raise @posoco.CompositionError::EmptyPort("posoco_ext_llm.slots")
  }
  let slot_map : Map[String, ModelSlot] = Map::from_array([])
  let slot_order : Array[String] = []
  for slot in slots {
    if slot.id.length() == 0 {
      raise @posoco.CompositionError::ManifestSchemaError(
        manifest_id="posoco_ext_llm",
        detail="model slot id must not be empty",
      )
    }
    if slot_map.contains(slot.id) {
      raise @posoco.CompositionError::ManifestSchemaError(
        manifest_id="posoco_ext_llm",
        detail="duplicate model slot id: " + slot.id,
      )
    }
    slot_map[slot.id] = slot
    slot_order.push(slot.id)
  }
  let active_id = if default_slot_id.length() == 0 {
    slot_order[0]
  } else if !slot_map.contains(default_slot_id) && allow_missing_default {
    default_slot_id
  } else {
    if !slot_map.contains(default_slot_id) {
      raise @posoco.CompositionError::ManifestSchemaError(
        manifest_id="posoco_ext_llm",
        detail="unknown default model slot: " + default_slot_id,
      )
    }
    default_slot_id
  }
  let router : RouterModelPort = {
    slots: slot_map,
    slot_order,
    active_id,
    active_effort: None,
    bus,
    context_states: Map::from_array([]),
    provider_credential_store,
    cred_store,
    auth_interaction,
    api_key_store,
    auth_prompt,
    credentials: Map::from_array([]),
    api_key_credentials: Map::from_array([]),
    extra_quota_sources,
    decision: None,
    status_segments: Map::from_array([]),
    quota_readings: [],
    quota_fetched_at: 0L,
    quota_failed_at: 0L,
    quota_shown: [],
  }
  match bus {
    Some(bus) => bus.subscribe(router as &@devkit.BusSubscriber)
    None => ()
  }
  router
}

///|
/// Construct a router from provider-owned catalogs. Each provider extension
/// builds its own slots and capability metadata; this function only flattens
/// catalogs and lets `new` perform the final collision check.
pub fn RouterModelPort::from_catalogs(
  catalogs : Array[ProviderModelCatalog],
  default_slot_id? : String = "",
  allow_missing_default? : Bool = false,
  bus? : @devkit.EventBus? = None,
  provider_credential_store? : &@oauth.ProviderCredentialStore? = None,
  cred_store? : &@oauth.CredentialStore? = None,
  auth_interaction? : &@oauth.AuthInteraction? = None,
  api_key_store? : &@oauth.ApiKeyStore? = None,
  auth_prompt? : &@oauth.AuthPromptInteraction? = None,
  extra_quota_sources? : Map[String, &@devkit.QuotaSource] = Map::from_array([]),
  transient_retry? : TransientRetryPolicy? = Some(
    TransientRetryPolicy::default(),
  ),
) -> RouterModelPort raise @posoco.CompositionError {
  // from_catalogs is the one slot-ingestion seam: every provider port is
  // wrapped with the transient-failure retry decorator here (chat and compact
  // both dispatch through slot.port). A host that constructs RouterModelPort
  // directly bypasses this policy. `None` disables retrying entirely.
  let slots : Array[ModelSlot] = []
  for catalog in catalogs {
    for slot in catalog.slots() {
      let slot = match transient_retry {
        Some(policy) =>
          { ..slot, port: transient_retry_port(inner=slot.port, policy~), }
        None => slot
      }
      slots.push(slot)
    }
  }
  RouterModelPort(
    slots~,
    default_slot_id~,
    allow_missing_default~,
    bus~,
    provider_credential_store~,
    cred_store~,
    auth_interaction~,
    api_key_store~,
    auth_prompt~,
    extra_quota_sources~,
  )
}

///|
/// Return the active slot when it exists. `None` marks a pending default: a
/// persisted selection whose slot is absent from the current catalogs — a
/// visible user state, not an invariant violation.
fn RouterModelPort::active_slot_opt(self : RouterModelPort) -> ModelSlot? {
  self.slots.get(self.active_id)
}

///|
/// Dispatch target for model calls. A pending default fails the request with
/// a typed error so the user sees exactly which selection cannot be served.
fn RouterModelPort::dispatch_slot(
  self : RouterModelPort,
) -> ModelSlot raise @posoco.ModelError {
  match self.slots.get(self.active_id) {
    Some(slot) => slot
    None =>
      raise @posoco.ModelError::RequestBuild(
        "model slot '" +
        self.active_id +
        "' is not configured (persisted selection); switch with /model or log in to the provider",
      )
  }
}

///|
/// Split a `"provider/model"` slot id into its parts. Every provider
/// extension builds slot ids in this shape; a malformed id degrades to the
/// whole string in both parts — the result is display-only, never a routing
/// fact.
fn split_slot_id(slot_id : String) -> (String, String) {
  match slot_id.find("/") {
    Some(index) => (slot_id[0:index].to_owned(), slot_id[index + 1:].to_owned())
    None => (slot_id, slot_id)
  }
}

///|
/// Return slots in the same order in which they were composed.
pub fn RouterModelPort::list_slots(self : RouterModelPort) -> Array[ModelSlot] {
  let result : Array[ModelSlot] = []
  for id in self.slot_order {
    match self.slots.get(id) {
      Some(slot) => result.push(slot)
      None =>
        abort(
          "posoco_ext_llm router invariant violated: ordered slot is missing: " +
          id,
        )
    }
  }
  result
}

///|
/// Return all slot ids in composition order.
pub fn RouterModelPort::list_slot_ids(self : RouterModelPort) -> Array[String] {
  self.slot_order.copy()
}

///|
pub fn RouterModelPort::active_slot_id(self : RouterModelPort) -> String {
  self.active_id
}

///|
pub fn RouterModelPort::current_provider_id(self : RouterModelPort) -> String {
  match self.active_slot_opt() {
    Some(slot) => slot.provider_id
    None => split_slot_id(self.active_id).0
  }
}

///|
pub fn RouterModelPort::current_model_id(self : RouterModelPort) -> String {
  match self.active_slot_opt() {
    Some(slot) => slot.model_id
    None => split_slot_id(self.active_id).1
  }
}

///|
pub fn RouterModelPort::active_effort(self : RouterModelPort) -> String? {
  self.active_effort
}

///|
/// Select a slot without rebuilding its ModelPort.
pub fn RouterModelPort::switch_slot(
  self : RouterModelPort,
  slot_id : String,
) -> Unit raise @posoco.CompositionError {
  if !self.slots.contains(slot_id) {
    raise @posoco.CompositionError::ManifestSchemaError(
      manifest_id="posoco_ext_llm",
      detail="unknown model slot: " + slot_id,
    )
  }
  self.active_id = slot_id
  self.active_effort = None
  self.publish_model_status()
  self.publish_provider_event()
  // The new provider's readings are unknown until its first response (or an
  // immediate /model probe): drop the previous provider's quota segments and
  // reset the pull cache.
  self.clear_quota_segments()
}

///|
/// Replace every slot of one provider in place, preserving composition order
/// for all other providers. Hosts use this after a live model-discovery
/// refresh: the refreshed catalog's slots replace the provider's previous
/// slots without recomposing the Agent.
///
/// Validation mirrors `new` (non-empty, unique ids, provider id match) and
/// fails before any mutation, so a rejected replacement leaves the routing
/// table untouched. The active selection survives when its slot id still
/// exists afterwards (the slot object is swapped, selection and effort stay);
/// when a previously present active slot disappears, selection falls back to
/// the provider's first new slot and the effort selection is cleared. A
/// pending default is untouched: if the replacement provides its slot the
/// selection heals in place, otherwise it stays pending.
///
/// Every success path ends with a model-status republication, so the status
/// bar reflects the swapped catalog; the provider event rides only the
/// fallback path, where the active slot context changed.
pub fn RouterModelPort::replace_provider_slots(
  self : RouterModelPort,
  provider_id : String,
  slots : Array[ModelSlot],
) -> Unit raise @posoco.CompositionError {
  if slots.is_empty() {
    raise @posoco.CompositionError::ManifestSchemaError(
      manifest_id="posoco_ext_llm",
      detail="replacement model slots must not be empty: " + provider_id,
    )
  }
  let seen : Map[String, Unit] = Map::from_array([])
  for slot in slots {
    if slot.provider_id != provider_id {
      raise @posoco.CompositionError::ManifestSchemaError(
        manifest_id="posoco_ext_llm",
        detail="replacement slot provider id does not match: " + slot.id,
      )
    }
    if seen.contains(slot.id) {
      raise @posoco.CompositionError::ManifestSchemaError(
        manifest_id="posoco_ext_llm",
        detail="duplicate replacement model slot id: " + slot.id,
      )
    }
    seen[slot.id] = ()
  }
  let old_active_id = self.active_id
  // A pending default (persisted selection without a slot object) must stay
  // pending across replacements: only a previously present active slot can
  // fall back to the provider's first new slot.
  let had_active = self.slots.contains(old_active_id)
  // Drop the provider's previous ids from the ordered list, then re-insert
  // the replacement ids where the provider's first old id appeared (append
  // when the provider contributed no slots before).
  let replaced_order : Array[String] = []
  let mut inserted = false
  for id in self.slot_order {
    if self.slots.get(id) is Some(slot) && slot.provider_id == provider_id {
      if !inserted {
        for new_slot in slots {
          replaced_order.push(new_slot.id)
        }
        inserted = true
      }
    } else {
      replaced_order.push(id)
    }
  }
  if !inserted {
    for new_slot in slots {
      replaced_order.push(new_slot.id)
    }
  }
  for old_id in self.slot_order.copy() {
    if self.slots.get(old_id) is Some(slot) && slot.provider_id == provider_id {
      ignore(self.slots.remove(old_id))
    }
  }
  for new_slot in slots {
    self.slots[new_slot.id] = new_slot
  }
  self.slot_order.clear()
  self.slot_order.append(replaced_order)
  if had_active && !self.slots.contains(old_active_id) {
    self.active_id = slots[0].id
    self.active_effort = None
    self.publish_provider_event()
  }
  // Last, and only once the active id is guaranteed valid again: the model
  // segment render reads the active slot.
  self.publish_model_status()
}

///|
/// User-authored text only when the current model input ends in a user message.
/// Tool-followup model rounds therefore reuse the already selected/default effort
/// instead of paying for a repeated semantic classification.
fn current_user_text(messages : ArrayView[@posoco.Message]) -> String? {
  let message = match messages.last() {
    Some(message) => message
    None => return None
  }
  match message {
    @posoco.Message::UserMessage(content~) => {
      let builder = StringBuilder()
      let mut wrote = false
      for part in content {
        match part {
          @posoco.Content::Text(text) => {
            if wrote {
              builder.write_string("\n")
            }
            builder.write_string(text)
            wrote = true
          }
          _ => ()
        }
      }
      let text = builder.to_string()
      if text.trim().length() > 0 {
        Some(text)
      } else {
        None
      }
    }
    _ => None
  }
}

///|
fn highest_probability_index(probabilities : Array[Double]) -> Int? {
  if probabilities.is_empty() {
    return None
  }
  let mut best = 0
  for i = 1; i < probabilities.length(); i = i + 1 {
    if probabilities[i] > probabilities[best] {
      best = i
    }
  }
  Some(best)
}

///|
/// Return a temporary slot rebuilt at a DecisionPort-selected reasoning
/// effort. This never mutates the router's active slot or sticky effort. Any
/// DecisionPort failure falls back to the current deterministic selection.
async fn RouterModelPort::decision_slot_for_chat(
  self : RouterModelPort,
  messages : ArrayView[@posoco.Message],
  tool_count : Int,
) -> ModelSlot? noraise {
  if self.active_effort is Some(_) {
    return None
  }
  let decision = match self.decision {
    Some(decision) => decision
    None => return None
  }
  let slot = match self.active_slot_opt() {
    Some(slot) => slot
    None => return None
  }
  if slot.thinking_efforts.length() < 2 || slot.rebuild_on_effort is None {
    return None
  }
  let user_text = match current_user_text(messages) {
    Some(text) => text
    None => return None
  }
  let levels = slot.thinking_efforts.map(fn(effort) {
    Json::object({ "effort": Json::string(effort) })
  })
  let request : @posoco.DecisionRequest = {
    state: Json::object({
      "user_request": Json::string(user_text),
      "provider_id": Json::string(slot.provider_id),
      "model_id": Json::string(slot.model_id),
      "tool_count": Json::number(tool_count.to_double()),
    }),
    questions: [
      @posoco.DecisionQuestion::Score(
        id="llm.reasoning_effort",
        instructions=Json::string(
          "Estimate how much reasoning effort this user turn needs. Levels are ordered from least to most effort. Judge task complexity only; do not choose a provider or model.",
        ),
        levels~,
      ),
    ],
  }
  let result = decision.evaluate(request) catch { _ => return None }
  result.validate_for(request) catch {
    _ => return None
  }
  for answer in result.answers {
    match answer {
      @posoco.DecisionAnswer::ScoreAnswer(
        id="llm.reasoning_effort",
        probabilities~,
        ..
      ) =>
        match highest_probability_index(probabilities) {
          Some(index) if index < slot.thinking_efforts.length() &&
            probabilities[index] >= 0.6 =>
            match slot.rebuild_on_effort {
              Some(rebuild) =>
                return Some(rebuild(slot.thinking_efforts[index]))
              None => return None
            }
          _ => return None
        }
      _ => ()
    }
  }
  None
}

///|
pub impl @posoco.ModelPort for RouterModelPort with fn chat(
  self : RouterModelPort,
  scope : @posoco.InvocationScope,
  messages : ArrayView[@posoco.Message],
  tools : Array[@posoco.ToolDef],
  options : @posoco.ChatOptions,
  stream : @posoco.StreamMode,
) -> @posoco.ModelCallResult raise @posoco.ModelError {
  let slot = match self.decision_slot_for_chat(messages, tools.length()) {
    Some(slot) => slot
    None => self.dispatch_slot()
  }
  let result = slot.port.chat(scope, messages, tools, options, stream)
  // Refresh after the response: the registry pull never delays the first
  // token, and every failure inside stays captured (this never raises).
  self.refresh_quota_if_due()
  result
}

///|
pub extend RouterModelPort with @posoco.ModelPort::{chat, compact}

///|
pub impl @posoco.ModelPort for RouterModelPort with fn compact(
  self : RouterModelPort,
  scope : @posoco.InvocationScope,
  messages : ArrayView[@posoco.Message],
  options : @posoco.ChatOptions,
  trigger : @posoco.CompactTrigger,
) -> @posoco.CompactResult raise @posoco.ModelError {
  self.dispatch_slot().port.compact(scope, messages, options, trigger)
}

///|
pub impl @posoco.ModelPort for RouterModelPort with fn provider_config(
  self : RouterModelPort,
) -> @posoco.ProviderConfig {
  match self.active_slot_opt() {
    Some(slot) => slot.port.provider_config()
    // A pending default reports no capabilities: auto-compact stays off and
    // effort resets instead of fabricating facts for a slot that is absent.
    None => @posoco.ProviderConfig::empty()
  }
}

///|
/// Consume one bus event: keep the latest value of every status segment so
/// `/status` can surface provider-published quota windows. A register
/// without a value (or an unregister) clears the segment. The router only
/// reads the bus here — model/ctx/cache segments are published elsewhere —
/// so this subscriber never feeds back into the bus.
pub impl @devkit.BusSubscriber for RouterModelPort with fn on_bus_event(
  self : RouterModelPort,
  event : @devkit.BusEvent,
) -> Unit {
  match @devkit.decode_status_op(event) {
    Some(Register(segment~, value~, ..)) =>
      match value {
        Some(v) => self.status_segments[segment] = v
        None => ignore(self.status_segments.remove(segment))
      }
    Some(Update(segment~, value~)) => self.status_segments[segment] = value
    Some(Unregister(segment~)) => ignore(self.status_segments.remove(segment))
    None => ()
  }
}

///|
pub extend RouterModelPort with @posoco/devkit.BusSubscriber::{on_bus_event}