///|
// moonproxy core domain model. Everything here is pure MoonBit that compiles
// on every target (wasm, js, native); the socket runtime lives in the
// native-only `runtime` package and drives these types.

///|
/// The scheme used to reach an upstream backend. Only cleartext HTTP is
/// implemented in this version; `Https` is accepted in the model so a config
/// can name it, and reported as unsupported at the edge rather than dropped.
pub(all) enum Scheme {
  Http
  Https
}

///|
/// The default port a scheme implies when a backend names none.
pub fn Scheme::default_port(self : Scheme) -> Int {
  match self {
    Http => 80
    Https => 443
  }
}

///|
/// One upstream server instance a pool may forward to. Health and the live
/// connection count mutate as the proxy runs, so those fields are mutable;
/// identity and weight are fixed configuration.
pub struct Backend {
  host : String
  port : Int
  weight : Int
  scheme : Scheme
  mut healthy : Bool
  mut failures : Int
  mut active_conns : Int
}

///|
/// Build a backend from an explicit address. `weight` defaults to 1 and a
/// weight below 1 is treated as 1 so a misconfiguration can never zero out a
/// pool's share. A backend starts healthy and assumed reachable.
pub fn Backend::new(
  host : String,
  port : Int,
  weight? : Int = 1,
  scheme? : Scheme = Http,
) -> Backend {
  let w = if weight < 1 { 1 } else { weight }
  {
    host,
    port,
    weight: w,
    scheme,
    healthy: true,
    failures: 0,
    active_conns: 0,
  }
}

///|
/// The `host:port` text used to key and display this backend.
pub fn Backend::address(self : Backend) -> String {
  self.host + ":" + self.port.to_string()
}

///|
/// The origin URL (`scheme://host:port`) the native client dials.
pub fn Backend::origin(self : Backend) -> String {
  let scheme = match self.scheme {
    Http => "http://"
    Https => "https://"
  }
  scheme + self.host + ":" + self.port.to_string()
}

///|
/// The backend's host (IP or resolvable name).
pub fn Backend::host(self : Backend) -> String {
  self.host
}

///|
/// The backend's port.
pub fn Backend::port(self : Backend) -> Int {
  self.port
}

///|
/// How a route matches a request's path. `Exact` is the whole request target's
/// path component; `Prefix` matches any path beneath it.
pub(all) enum PathMatch {
  Exact(String)
  Prefix(String)
}

///|
/// How a route matches a request's Host header. `Any` matches every host;
/// `Host` is a case-insensitive exact name.
pub(all) enum HostMatch {
  Any
  Host(String)
}

///|
/// The load-balancing policy a pool uses to choose among its healthy backends.
pub(all) enum LBPolicy {
  /// Cycle through backends in order, spreading requests evenly.
  RoundRobin
  /// Smooth weighted round-robin (← nginx `weight`): interleave picks in
  /// proportion to each backend's weight.
  WeightedRR
  /// Send to the backend with the fewest in-flight requests.
  LeastConn
  /// Pick uniformly at random (RNG injected, so it is testable).
  Random
  /// Pin a client key (its IP) to a backend by a stable hash.
  IPHash
}

///|
/// A named group of interchangeable backends and the policy that balances over
/// them. A route points at one pool.
pub struct BackendPool {
  id : String
  backends : Array[Backend]
  policy : LBPolicy
}

///|
/// Start an empty pool with the given id and policy.
pub fn BackendPool::new(
  id : String,
  policy? : LBPolicy = RoundRobin,
) -> BackendPool {
  { id, backends: [], policy, }
}

///|
/// Add a backend and return the pool, so construction chains
/// (`BackendPool::new("api").add(a).add(b)`).
pub fn BackendPool::add(self : BackendPool, backend : Backend) -> BackendPool {
  let pool = self
  pool.backends.push(backend)
  pool
}

///|
/// The pool's id.
pub fn BackendPool::id(self : BackendPool) -> String {
  self.id
}

///|
/// The pool's backends (the live array the runtime balances over).
pub fn BackendPool::backends(self : BackendPool) -> Array[Backend] {
  self.backends
}

///|
/// The pool's load-balancing policy.
pub fn BackendPool::policy(self : BackendPool) -> LBPolicy {
  self.policy
}

///|
/// Per-route forwarding knobs. Defaults follow what nginx does.
pub struct RouteOptions {
  /// Forward the incoming Host instead of rewriting it to the backend's.
  preserve_host : Bool
  /// Add `X-Forwarded-For / Proto / Host` and `X-Real-IP`.
  forward_headers : Bool
  /// Retry the next backend on a connect failure or idempotent 5xx.
  failover : Bool
}

///|
/// The mainstream defaults: rewrite Host to the upstream, emit forwarding
/// headers, and fail over to a healthy peer.
pub fn RouteOptions::defaults() -> RouteOptions {
  { preserve_host: false, forward_headers: true, failover: true, }
}

///|
/// Build options with explicit toggles; any omitted field takes the default.
pub fn RouteOptions::new(
  preserve_host? : Bool = false,
  forward_headers? : Bool = true,
  failover? : Bool = true,
) -> RouteOptions {
  { preserve_host, forward_headers, failover, }
}

///|
/// A routing rule: when host and path match, forward to `pool`.
pub struct Route {
  id : String
  host : HostMatch
  path : PathMatch
  pool : BackendPool
  options : RouteOptions
}

///|
/// Build a route with an explicit host and path matcher.
pub fn Route::new(
  id : String,
  host : HostMatch,
  path : PathMatch,
  pool : BackendPool,
  options? : RouteOptions = RouteOptions::defaults(),
) -> Route {
  { id, host, path, pool, options, }
}

///|
/// A route that matches any host and forwards a path prefix to `pool`.
pub fn Route::prefix(id : String, prefix : String, pool : BackendPool) -> Route {
  Route::new(id, Any, Prefix(prefix), pool)
}

///|
/// A route that matches any host and forwards an exact path to `pool`.
pub fn Route::exact(id : String, path : String, pool : BackendPool) -> Route {
  Route::new(id, Any, Exact(path), pool)
}

///|
/// The pool this route forwards to.
pub fn Route::pool(self : Route) -> BackendPool {
  self.pool
}

///|
/// This route's forwarding options.
pub fn Route::options(self : Route) -> RouteOptions {
  self.options
}

///|
/// The top-level proxy configuration: where to listen and the ordered route
/// table. Routes are matched by specificity, not array order.
pub struct ProxyConfig {
  listen_host : String
  listen_port : Int
  routes : Array[Route]
}

///|
/// Start a config bound to `host:port`; add routes with `add_route`.
pub fn ProxyConfig::new(
  host? : String = "127.0.0.1",
  port? : Int = 8080,
) -> ProxyConfig {
  { listen_host: host, listen_port: port, routes: [], }
}

///|
/// Append a route and return the config for chaining.
pub fn ProxyConfig::add_route(self : ProxyConfig, route : Route) -> ProxyConfig {
  let config = self
  config.routes.push(route)
  config
}

///|
/// The `host:port` text the listener binds.
pub fn ProxyConfig::bind(self : ProxyConfig) -> String {
  self.listen_host + ":" + self.listen_port.to_string()
}

///|
/// The host the listener binds.
pub fn ProxyConfig::listen_host(self : ProxyConfig) -> String {
  self.listen_host
}

///|
/// The port the listener binds.
pub fn ProxyConfig::listen_port(self : ProxyConfig) -> Int {
  self.listen_port
}

///|
/// The route table.
pub fn ProxyConfig::routes(self : ProxyConfig) -> Array[Route] {
  self.routes
}