///|
pub(all) enum Mode {
  StatsD
  DogStatsD
} derive(Eq, Debug)

///|
pub(all) enum Metric {
  Counter(Double)
  Gauge(Double)
  GaugeDelta(Double)
  Timer(Double)
  Set(String)
  Histogram(Double)
  Distribution(Double)
} derive(Debug)

///|
pub(all) suberror EncodeError {
  Invalid(String)
  TooLarge(Int32, Int32)
} derive(Debug)

///|
/// Validated, indivisible wire message. Negative absolute gauges are rejected.
pub struct Message {
  priv wire : Bytes
}

///|
pub fn Message::bytes(self : Message) -> Bytes {
  self.wire
}

///|
fn field(
  s : String,
  forbidden : String,
  empty~ : Bool = false,
) -> Unit raise EncodeError {
  if !empty && s.is_empty() {
    raise Invalid("empty field")
  }
  for c in s {
    if c.to_int() < 32 || c.to_int() == 127 || forbidden.contains(c.to_string()) {
      raise Invalid("field delimiter or control character")
    }
  }
}

///|
fn finite(v : Double) -> Unit raise EncodeError {
  if v.is_nan() || v.is_inf() {
    raise Invalid("non-finite number")
  }
}

///|
/// Rate validation is separate from random selection. draw must be uniform [0,1).
pub fn sampled(rate : Double, draw : Double) -> Bool raise EncodeError {
  finite(rate)
  finite(draw)
  if rate <= 0 || rate > 1 || draw < 0 || draw >= 1 {
    raise Invalid("sampling range")
  }
  draw < rate
}

///|
/// Tags: fixed first, then call tags; duplicate keys are rejected, including bare tags.
/// prefix is concatenated exactly (include a trailing dot if desired).
/// Gauge and Set require rate=1. Timer is milliseconds. No random selection here.
pub fn encode(
  name : String,
  metric : Metric,
  mode~ : Mode = StatsD,
  prefix~ : String = "",
  fixed_tags~ : Array[String] = [],
  tags~ : Array[String] = [],
  rate~ : Double = 1.0,
  max_bytes~ : Int32 = 512,
) -> Message raise EncodeError {
  if max_bytes < 1 || max_bytes > 65507 {
    raise Invalid("datagram limit 1..65507")
  }
  // Bound individual inputs before concatenation. UTF-8 is checked again on final output.
  if name.length() > max_bytes ||
    prefix.length() > max_bytes ||
    tags.length() + fixed_tags.length() > max_bytes {
    raise TooLarge(max_bytes + 1, max_bytes)
  }
  field(name, ":|,@# ")
  field(prefix, ":|,@# ", empty=true)
  if mode == DogStatsD {
    for c in prefix + name {
      let code = c.to_int()
      if !((code >= 65 && code <= 90) ||
        (code >= 97 && code <= 122) ||
        (code >= 48 && code <= 57) ||
        c == '_' ||
        c == '.') {
        raise Invalid(
          "DogStatsD metric names require ASCII alphanumerics, underscore or period",
        )
      }
    }
  }
  finite(rate)
  if rate <= 0 || rate > 1 {
    raise Invalid("rate must be in (0,1]")
  }
  let (value, kind) = match metric {
    Set(s) => {
      if s.length() > max_bytes {
        raise TooLarge(s.length(), max_bytes)
      }
      field(s, ":|")
      (s, "s")
    }
    Counter(v) => {
      finite(v)
      (v.to_string(), "c")
    }
    Gauge(v) => {
      finite(v)
      if v < 0 {
        raise Invalid(
          "negative absolute gauge is unsupported; use explicit delta or reject at caller",
        )
      }
      (if v == 0 { "0" } else { v.to_string() }, "g")
    }
    GaugeDelta(v) => {
      if mode == DogStatsD {
        raise Invalid("gauge delta is only supported in StatsD mode")
      }
      finite(v)
      (
        if v == 0 {
          "+0"
        } else if v > 0 {
          "+" + v.to_string()
        } else {
          v.to_string()
        },
        "g",
      )
    }
    Timer(v) => {
      finite(v)
      if v < 0 {
        raise Invalid("negative duration")
      }
      (v.to_string(), "ms")
    }
    Histogram(v) => {
      finite(v)
      if mode != DogStatsD {
        raise Invalid("histogram needs DogStatsD")
      }
      (v.to_string(), "h")
    }
    Distribution(v) => {
      finite(v)
      if mode != DogStatsD {
        raise Invalid("distribution needs DogStatsD")
      }
      (v.to_string(), "d")
    }
  }
  match metric {
    Gauge(_) | GaugeDelta(_) | Set(_) =>
      if rate != 1 {
        raise Invalid("sampling unsupported for gauge/set")
      }
    _ => ()
  }
  let all_tags = fixed_tags + tags
  if mode == StatsD && !all_tags.is_empty() {
    raise Invalid("tags need DogStatsD")
  }
  let keys : Array[String] = []
  let mut tag_bytes = 0
  for tag in all_tags {
    if tag.length() > max_bytes {
      raise TooLarge(tag.length(), max_bytes)
    }
    tag_bytes += @utf8.encode(tag).length() + 1
    if tag_bytes > max_bytes {
      raise TooLarge(tag_bytes, max_bytes)
    }
    field(tag, "|, ")
    let key = tag.split(":").to_array()[0].to_owned()
    field(key, ":|, ")
    if keys.contains(key) {
      raise Invalid("duplicate tag key")
    }
    keys.push(key)
  }
  let mut text = prefix + name + ":" + value + "|" + kind
  if rate < 1 {
    text += "|@" + rate.to_string()
  }
  if !all_tags.is_empty() {
    text += "|#" + all_tags.join(",")
  }
  let wire = @utf8.encode(text)
  if wire.length() > max_bytes {
    raise TooLarge(wire.length(), max_bytes)
  }
  { wire, }
}

///|
/// Batch complete messages; no splitting. Bound both datagram size and total input bytes.
pub fn pack(
  messages : Array[Message],
  max_bytes~ : Int32 = 512,
  max_total_bytes~ : Int32 = 1048576,
) -> Array[Bytes] raise EncodeError {
  if max_bytes < 1 || max_bytes > 65507 || max_total_bytes < 1 {
    raise Invalid("invalid pack limit")
  }
  let out : Array[Bytes] = []
  let mut current = b""
  let mut total = 0
  for message in messages {
    let wire = message.wire
    if wire.length() > max_bytes {
      raise TooLarge(wire.length(), max_bytes)
    }
    if wire.length() > max_total_bytes - total {
      raise Invalid("batch total byte limit")
    }
    total += wire.length()
    if current.length() == 0 {
      current = wire
    } else if current.length() + 1 + wire.length() <= max_bytes {
      current = current + b"\n" + wire
    } else {
      out.push(current)
      current = wire
    }
  }
  if current.length() > 0 {
    out.push(current)
  }
  out
}

///|
pub extend Mode with Eq::{equal, not_equal}

///|
pub extend Mode with @debug.Debug::{to_repr}

///|
pub extend Metric with @debug.Debug::{to_repr}

///|
pub extend EncodeError with @debug.Debug::{to_repr}