///|
/// `PortRuntime` — the default `Runtime`: delegates every effect to the
/// composed extension ports (`ModelPort` + `ToolProvider`s). This is exactly
/// the behaviour of the historical internal port adapter; cancellation is
/// `NotPropagated` because plain `ToolProvider`s receive no cancel signal.
///
/// Host builders wrap this type and override the methods they need instead
/// of implementing `Runtime` from scratch.

///|
/// Map `@error.ModelError` (raised by ModelPort) to the kernel's
/// `ModelFailure` so it can travel through the reducer as data.
fn model_error_to_failure(error : @error.ModelError) -> @kernel.ModelFailure {
  match error {
    @error.RequestBuild(reason) => @kernel.RequestBuild(reason~)
    @error.Transport(reason) => @kernel.Transport(reason~)
    @error.ResponseParse(reason) => @kernel.Parse(reason~)
  }
}

///|
/// Decode the provider-agnostic subset of the canonical call-options bag.
///
/// `ChatOptions` intentionally cannot represent provider-specific fields. Do
/// not drop those fields: reject them at this boundary so the caller can route
/// the original bag to a host that understands it.
fn decode_chat_options(
  call_options : Json,
) -> Result[@types.ChatOptions, @kernel.ModelFailure] {
  let fields = match call_options {
    Json::Null => return Ok({ temperature: None, max_output_tokens: None })
    Json::Object(fields) => fields
    _ =>
      return Err(
        @kernel.RequestBuild(reason="call_options must be null or an object"),
      )
  }
  for key in fields.keys() {
    if key != "temperature" && key != "max_output_tokens" {
      return Err(
        @kernel.RequestBuild(
          reason="unsupported call_options field '" + key + "'",
        ),
      )
    }
  }
  let temperature = match fields.get("temperature") {
    None => None
    Some(Json::Number(value, ..)) =>
      if (value - value).is_nan() {
        return Err(
          @kernel.RequestBuild(
            reason="call_options.temperature must be a finite number",
          ),
        )
      } else {
        Some(value)
      }
    Some(_) =>
      return Err(
        @kernel.RequestBuild(reason="call_options.temperature must be a number"),
      )
  }
  let max_output_tokens = match fields.get("max_output_tokens") {
    None => None
    Some(Json::Number(value, ..)) => {
      let converted = value.to_int()
      if converted.to_double() != value {
        return Err(
          @kernel.RequestBuild(
            reason="call_options.max_output_tokens must be an integer in Int range",
          ),
        )
      }
      Some(converted)
    }
    Some(_) =>
      return Err(
        @kernel.RequestBuild(
          reason="call_options.max_output_tokens must be a number",
        ),
      )
  }
  Ok({ temperature, max_output_tokens })
}

///|
/// The default runtime, built from the composed extension ports.
pub struct PortRuntime {
  priv model : &@port.ModelPort
  priv tools : Map[String, &@port.ToolProvider]
}

///|
pub fn PortRuntime::new(
  model~ : &@port.ModelPort,
  tools~ : Map[String, &@port.ToolProvider],
) -> PortRuntime {
  { model, tools }
}

///|
pub impl Runtime for PortRuntime with fn call_model(
  self,
  scope : @kernel.InvocationScope,
  messages : Array[@kernel.Message],
  tool_definitions : Array[@kernel.ToolDef],
  call_options : Json,
  on_chunk : ((Json) -> Unit)?,
) -> Result[@kernel.ModelCallResult, @kernel.ModelFailure] {
  let options = match decode_chat_options(call_options) {
    Ok(options) => options
    Err(failure) => return Err(failure)
  }
  let stream : @types.StreamMode = match on_chunk {
    Some(cb) => @types.Stream(cb)
    None => @types.NoStream
  }
  let result = self.model.chat(
    scope, messages, tool_definitions, options, stream,
  ) catch {
    error => return Err(model_error_to_failure(error))
  }
  // When the modelport returns no preprocessing (empty processed_messages),
  // substitute the input messages so the reducer's transcript-replace step
  // is a no-op. Real modelports that actually preprocess return their
  // processed tree here and we honour it.
  let final_result : @kernel.ModelCallResult = if result.processed_messages.is_empty() {
    @kernel.ModelCallResult::{
      completion: result.completion,
      processed_messages: messages,
    }
  } else {
    result
  }
  Ok(final_result)
}

///|
pub impl Runtime for PortRuntime with fn execute_tool(self, ctx : EffectContext) -> @kernel.ToolOutcome {
  let name = ctx.tool_name.to_string()
  let provider = match self.tools.get(name) {
    Some(p) => p
    None =>
      return @kernel.RuntimeFailure(
        error_category="MissingOwner",
        message="no tool provider for '" + name + "'",
      )
  }
  let call : @kernel.ToolCall = {
    call_id: ctx.call_id,
    name: ctx.tool_name,
    arguments: ctx.arguments,
  }
  provider.execute(name, call) catch {
    error =>
      @kernel.RuntimeFailure(
        error_category="ToolProviderError",
        message=error.to_string(),
      )
  }
}

///|
/// Plain `ToolProvider`s receive no cancel signal, so the default runtime
/// reports `NotPropagated` for every effect. Runtimes with real cancellation
/// wrap this method.
pub impl Runtime for PortRuntime with fn cancel_effects(
  self : PortRuntime,
  effect_ids : Array[@kernel.EffectId],
  _reason : @kernel.CancelReason,
) -> Array[(@kernel.EffectId, @kernel.CancelDisposition)] {
  ignore(self)
  effect_ids.map(id => (id, @kernel.NotPropagated))
}

///|
/// Forward compact to `ModelPort::compact`.
pub impl Runtime for PortRuntime with fn compact(
  self,
  scope : @kernel.InvocationScope,
  messages : Array[@kernel.Message],
  call_options : Json,
  trigger : @kernel.CompactTrigger,
) -> Result[@kernel.CompactResult, @kernel.ModelFailure] {
  let options = match decode_chat_options(call_options) {
    Ok(options) => options
    Err(failure) => return Err(failure)
  }
  let result = self.model.compact(scope, messages, options, trigger) catch {
    error => return Err(model_error_to_failure(error))
  }
  Ok(result)
}