///|
/// go-zero's canonical form for a config key (← the `WithCanonicalKeyFunc` that
/// `conf.Load` installs): lowercased, with `_` and `-` dropped. Every lookup
/// compares on this form, so `MaxBytes`, `maxBytes`, `max_bytes` and `max-bytes`
/// all name one field — which is what lets a genuine go-zero `etc/*.yaml`, whose
/// keys are all PascalCase, load into a config instead of silently yielding
/// defaults.
pub fn canonical_key(key : String) -> String {
  let low = key.to_lower()
  let sb = StringBuilder()
  for i = 0; i < low.length(); i = i + 1 {
    let c = low[i].to_int()
    if c != '_'.to_int() && c != '-'.to_int() {
      sb.write_char(low[i].unsafe_to_char())
    }
  }
  sb.to_string()
}

///|
/// A loaded config document (← go-zero's `conf.Load`): a parsed mapping read
/// through canonical keys and dotted paths, with the `,env=NAME` override and the
/// `default=` / `options=` / `range=` constraints go-zero spells as struct tags.
///
/// A field that is absent and has no default is an error, and so is a value that
/// violates its `options=` or `range=` constraint — a bad value never degrades
/// into the default.
pub struct Conf {
  root : Map[String, Json]
}

///|
/// Load a document from JSON. Raises `ConfigError` on malformed JSON or a
/// non-object root.
pub fn Conf::of_json(src : String) -> Conf raise ConfigError {
  let root = @json.parse(src) catch {
    err => raise ConfigError("invalid JSON: " + err.to_string())
  }
  match root {
    Object(m) => { root: m, }
    _ => raise ConfigError("config root must be a JSON object")
  }
}

///|
/// Load a document from the YAML go-zero ships as `etc/*.yaml`. Raises
/// `ConfigError` on malformed YAML or a non-mapping root.
pub fn Conf::of_yaml(src : String) -> Conf raise ConfigError {
  match yaml_parse(src) {
    Object(m) => { root: m, }
    _ => raise ConfigError("config root must be a YAML mapping")
  }
}

///|
/// Split a dotted path into its segments. A key containing a literal `.` is
/// therefore unreachable; go-zero's own nested lookups have the same shape.
fn path_segments(path : String) -> Array[String] {
  let out : Array[String] = []
  let sb = StringBuilder()
  for i = 0; i < path.length(); i = i + 1 {
    if path[i].to_int() == '.'.to_int() {
      out.push(sb.to_string())
      sb.reset()
    } else {
      sb.write_char(path[i].unsafe_to_char())
    }
  }
  out.push(sb.to_string())
  out
}

///|
/// The entry of `obj` whose key matches `seg` canonically.
fn entry_of(obj : Map[String, Json], seg : String) -> Json? {
  let want = canonical_key(seg)
  for k, v in obj {
    if canonical_key(k) == want {
      return Some(v)
    }
  }
  None
}

///|
/// The value at a dotted `path`, or `None` if any segment is missing or a
/// non-mapping is walked into.
pub fn Conf::at(self : Conf, path : String) -> Json? {
  let mut cur = Json::object(self.root)
  for seg in path_segments(path) {
    match cur {
      Object(m) =>
        match entry_of(m, seg) {
          Some(v) => cur = v
          None => return None
        }
      _ => return None
    }
  }
  Some(cur)
}

///|
/// The value at `path`, treating an explicit `null` as absent so `Host:` with no
/// value falls back to its default the way an omitted key does.
fn Conf::present(self : Conf, path : String) -> Json? {
  match self.at(path) {
    Some(Null) => None
    other => other
  }
}

///|
/// The raw value backing a field: a non-empty `env` variable first (go-zero's
/// `,env=`), then the path, then each alternative spelling in turn.
fn Conf::raw(
  self : Conf,
  path : String,
  also : Array[String],
  env : String?,
) -> Json? {
  match env {
    Some(name) =>
      match @env.get_env_var(name) {
        // go-zero ignores an env var that is set but empty
        Some(v) => if v.length() > 0 { return Some(Json::string(v)) }
        None => ()
      }
    None => ()
  }
  match self.present(path) {
    Some(v) => return Some(v)
    None => ()
  }
  for a in also {
    match self.present(a) {
      Some(v) => return Some(v)
      None => ()
    }
  }
  None
}

///|
/// The error go-zero reports for a field with no value and no `default=`.
fn missing(path : String) -> ConfigError {
  ConfigError("field " + path + " is not set")
}

///|
/// Check a decoded string against an `options=` list.
fn check_options(
  value : String,
  path : String,
  options : Array[String]?,
) -> Unit raise ConfigError {
  match options {
    None => ()
    Some(allowed) => {
      for o in allowed {
        if o == value {
          return
        }
      }
      raise ConfigError(
        "value \"" +
        value +
        "\" for field " +
        path +
        " is not defined in options",
      )
    }
  }
}

///|
/// A numeric bound and whether it is open (exclusive).
priv struct Bound {
  at : Double?
  open : Bool
}

///|
/// A parsed `range=` tag: go-zero's `[a:b]`, `(a:b)`, `[a:b)` and `(a:b]`, with
/// either end left empty for unbounded.
priv struct RangeSpec {
  low : Bound
  high : Bound
}

///|
/// Parse a `range=` tag body. Raises `ConfigError` on a spec that is not one of
/// go-zero's four bracket forms.
fn range_parse(spec : String) -> RangeSpec raise ConfigError {
  let bad = ConfigError("bad range spec " + spec)
  if spec.length() < 3 {
    raise bad
  }
  let open_low = match spec[0].to_int() {
    c if c == '['.to_int() => false
    c if c == '('.to_int() => true
    _ => raise bad
  }
  let open_high = match spec[spec.length() - 1].to_int() {
    c if c == ']'.to_int() => false
    c if c == ')'.to_int() => true
    _ => raise bad
  }
  let body = spec[1:spec.length() - 1].to_owned()
  let mut colon = -1
  for i = 0; i < body.length(); i = i + 1 {
    if body[i].to_int() == ':'.to_int() {
      colon = i
      break
    }
  }
  if colon < 0 {
    raise bad
  }
  let low_src = yaml_trim(body[0:colon].to_owned())
  let high_src = yaml_trim(body[colon + 1:].to_owned())
  let low = if low_src.length() == 0 {
    None
  } else {
    match parse_number(low_src) {
      Some(n) => Some(n)
      None => raise bad
    }
  }
  let high = if high_src.length() == 0 {
    None
  } else {
    match parse_number(high_src) {
      Some(n) => Some(n)
      None => raise bad
    }
  }
  { low: { at: low, open: open_low, }, high: { at: high, open: open_high, }, }
}

///|
/// Whether `v` falls inside the range.
fn RangeSpec::contains(self : RangeSpec, v : Double) -> Bool {
  let above = match self.low.at {
    None => true
    Some(l) => if self.low.open { v > l } else { v >= l }
  }
  let below = match self.high.at {
    None => true
    Some(h) => if self.high.open { v < h } else { v <= h }
  }
  above && below
}

///|
/// Check a decoded number against a `range=` tag.
fn check_range(
  value : Double,
  path : String,
  range : String?,
) -> Unit raise ConfigError {
  match range {
    None => ()
    Some(spec) =>
      if !range_parse(spec).contains(value) {
        raise ConfigError(
          "value " +
          value.to_string() +
          " for field " +
          path +
          " is out of range " +
          spec,
        )
      }
  }
}

///|
/// Coerce a config value to a number. A quoted scalar that reads as a number is
/// accepted, so `Port: "8888"` and an env override both decode.
fn as_number(v : Json, path : String) -> Double raise ConfigError {
  match v {
    Number(n, ..) => n
    String(s) =>
      match parse_number(s) {
        Some(n) => n
        None => raise ConfigError("field " + path + " must be a number")
      }
    _ => raise ConfigError("field " + path + " must be a number")
  }
}

///|
/// Read a string field, honouring `env=`, `default=` and `options=`.
pub fn Conf::string(
  self : Conf,
  path : String,
  default? : String,
  options? : Array[String],
  env? : String,
  also? : Array[String] = [],
) -> String raise ConfigError {
  let value = match self.raw(path, also, env) {
    Some(String(s)) => s
    Some(_) => raise ConfigError("field " + path + " must be a string")
    None =>
      match default {
        Some(d) => d
        None => raise missing(path)
      }
  }
  check_options(value, path, options)
  value
}

///|
/// Read an `Int` field, honouring `env=`, `default=` and `range=`. A fractional
/// value is truncated toward zero after the range check.
pub fn Conf::int(
  self : Conf,
  path : String,
  default? : Int,
  range? : String,
  env? : String,
  also? : Array[String] = [],
) -> Int raise ConfigError {
  let value = match self.raw(path, also, env) {
    Some(v) => as_number(v, path)
    None =>
      match default {
        Some(d) => d.to_double()
        None => raise missing(path)
      }
  }
  check_range(value, path, range)
  value.to_int()
}

///|
/// Read an `Int64` field, honouring `env=`, `default=` and `range=`.
pub fn Conf::int64(
  self : Conf,
  path : String,
  default? : Int64,
  range? : String,
  env? : String,
  also? : Array[String] = [],
) -> Int64 raise ConfigError {
  let value = match self.raw(path, also, env) {
    Some(v) => as_number(v, path)
    None =>
      match default {
        Some(d) => d.to_double()
        None => raise missing(path)
      }
  }
  check_range(value, path, range)
  value.to_int64()
}

///|
/// Read a `Bool` field, honouring `env=` and `default=`. `true`/`false` spelled
/// as a string decode too, which is how an env override arrives.
pub fn Conf::bool(
  self : Conf,
  path : String,
  default? : Bool,
  env? : String,
  also? : Array[String] = [],
) -> Bool raise ConfigError {
  match self.raw(path, also, env) {
    Some(True) => true
    Some(False) => false
    Some(String("true")) => true
    Some(String("false")) => false
    Some(_) => raise ConfigError("field " + path + " must be a boolean")
    None =>
      match default {
        Some(d) => d
        None => raise missing(path)
      }
  }
}

///|
/// Read a string-list field, honouring `env=` and `default=`. An env override is
/// comma-separated, since a shell variable carries one string.
pub fn Conf::strings(
  self : Conf,
  path : String,
  default? : Array[String],
  env? : String,
  also? : Array[String] = [],
) -> Array[String] raise ConfigError {
  match self.raw(path, also, env) {
    Some(Array(items)) => {
      let out : Array[String] = []
      for item in items {
        match item {
          String(s) => out.push(s)
          _ => raise ConfigError("field " + path + " must be a list of strings")
        }
      }
      out
    }
    Some(String(s)) => split_commas(s)
    Some(_) => raise ConfigError("field " + path + " must be a list of strings")
    None =>
      match default {
        Some(d) => d
        None => raise missing(path)
      }
  }
}

///|
/// Split a comma-separated list, trimming each item and dropping empties.
fn split_commas(s : String) -> Array[String] {
  let out : Array[String] = []
  let sb = StringBuilder()
  fn flush() {
    let item = yaml_trim(sb.to_string())
    if item.length() > 0 {
      out.push(item)
    }
    sb.reset()
  }

  for i = 0; i < s.length(); i = i + 1 {
    if s[i].to_int() == ','.to_int() {
      flush()
    } else {
      sb.write_char(s[i].unsafe_to_char())
    }
  }
  flush()
  out
}

///|
/// The list of sub-documents at `path` — the shape a `[]Struct` config field
/// takes, each element read back through the same constraint-checked accessors.
/// An absent path is an empty list; a non-list, or a list holding anything but
/// mappings, is an error.
pub fn Conf::list(self : Conf, path : String) -> Array[Conf] raise ConfigError {
  match self.present(path) {
    None => []
    Some(Array(items)) => {
      let out : Array[Conf] = []
      for item in items {
        match item {
          Object(m) => out.push({ root: m, })
          _ =>
            raise ConfigError("field " + path + " must be a list of mappings")
        }
      }
      out
    }
    Some(_) =>
      raise ConfigError("field " + path + " must be a list of mappings")
  }
}