// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0

///|
/// What to do with a member no field of the model describes.
pub(all) enum Extra {
  /// Carry it through. The default, because a configuration often has more in
  /// it than one model describes: shared settings, another program's section.
  Allow
  /// Refuse it. For a deployment where a misspelled key is a bug that must be
  /// caught at startup, not a typo that silently does nothing.
  Refuse
} derive(Eq, Debug)

///|
/// How to bind.
pub(all) struct Bind {
  /// What an undescribed member means.
  extra : Extra
} derive(Eq, Debug)

///|
/// Bind the members the model describes and carry the rest through.
pub let open : Bind = { extra: Allow, }

///|
/// Bind the members the model describes and refuse the rest.
pub let closed : Bind = { extra: Refuse, }

///|
/// Refuse a value whose shape is not the one the model asks for.
fn wrong(node : Node, want : String, path : String) -> Json raise Refused {
  raise Type(want~, got=node.kind(), path~, source=node.source, at=node.at)
}

///|
/// The model a reference names, if the caller supplied it.
fn resolve(known : Array[@moonmodel.Model], name : String) -> @moonmodel.Model? {
  for model in known {
    if model.name == name {
      return Some(model)
    }
  }
  None
}

///|
/// Text that means a boolean, in the spellings every configuration file uses.
fn as_bool(text : String) -> Bool? {
  match text.to_lower() {
    "true" | "yes" | "on" | "1" => Some(true)
    "false" | "no" | "off" | "0" => Some(false)
    _ => None
  }
}

///|
/// The tree as JSON, keeping the shape and dropping the provenance.
pub fn native(node : Node) -> Json {
  match node.value {
    Null => Json::null()
    Bool(flag) => Json::boolean(flag)
    Int(n) => Json::number(n.to_double(), repr=n.to_string())
    Real(x) => Json::number(x)
    Text(text) => Json::string(text)
    List(items) => Json::array(items.map(fn(item) { native(item) }))
    Map(table) => {
      let out : Map[String, Json] = Map([])
      for entry in table.entries {
        out.set(entry.0, native(entry.1))
      }
      Json::object(out)
    }
  }
}

///|
/// One value, checked against one type of the model, as JSON.
///
/// Text is read as the number or the boolean it spells: an environment variable
/// is always text, and a model that says `Int` should not force every caller to
/// convert before merging. Text that does not spell the type is a refusal, not a
/// guess.
fn as_json(
  node : Node,
  kind : @moonmodel.Type,
  known : Array[@moonmodel.Model],
  path : String,
  knobs : Bind,
) -> Json raise Refused {
  match kind {
    @moonmodel.Bool =>
      match node.value {
        Bool(flag) => Json::boolean(flag)
        Text(text) =>
          match as_bool(text) {
            Some(flag) => Json::boolean(flag)
            None => wrong(node, "boolean", path)
          }
        _ => wrong(node, "boolean", path)
      }
    @moonmodel.Int | @moonmodel.Big =>
      match node.value {
        Int(n) => Json::number(n.to_double(), repr=n.to_string())
        Real(x) =>
          if x == x.trunc() {
            Json::number(x, repr=x.to_int64().to_string())
          } else {
            wrong(node, "integer", path)
          }
        Text(text) => {
          let parsed = @string.parse_int64(text[:]) catch {
            _ =>
              raise Type(
                want="integer",
                got=node.kind(),
                path~,
                source=node.source,
                at=node.at,
              )
          }
          Json::number(parsed.to_double(), repr=parsed.to_string())
        }
        _ => wrong(node, "integer", path)
      }
    @moonmodel.Real =>
      match node.value {
        Real(x) => Json::number(x)
        Int(n) => Json::number(n.to_double())
        Text(text) => {
          let parsed = @string.parse_double(text[:]) catch {
            _ =>
              raise Type(
                want="real",
                got=node.kind(),
                path~,
                source=node.source,
                at=node.at,
              )
          }
          Json::number(parsed)
        }
        _ => wrong(node, "real", path)
      }
    @moonmodel.Text | @moonmodel.Bytes | @moonmodel.Stamp =>
      match node.value {
        Text(text) => Json::string(text)
        Int(n) => Json::string(n.to_string())
        Real(x) => Json::string(x.to_string())
        Bool(flag) => Json::string(flag.to_string())
        _ => wrong(node, "text", path)
      }
    @moonmodel.List(element) => {
      let items = node.list()
      let out = []
      let mut i = 0
      for item in items {
        let child = if path == "" {
          i.to_string()
        } else {
          path + "." + i.to_string()
        }
        out.push(as_json(item, element, known, child, knobs))
        i += 1
      }
      Json::array(out)
    }
    @moonmodel.Dict(element) => {
      let table = node.table()
      let out : Map[String, Json] = Map([])
      for entry in table.entries {
        let child = if path == "" { entry.0 } else { path + "." + entry.0 }
        out.set(entry.0, as_json(entry.1, element, known, child, knobs))
      }
      Json::object(out)
    }
    @moonmodel.Ref(name) =>
      match resolve(known, name) {
        Some(model) =>
          as_json(node, @moonmodel.Type::Nest(model), known, path, knobs)
        None => native(node)
      }
    @moonmodel.Nest(model) => {
      let table = node.table()
      described(table, model, known, path, knobs)
    }
    @moonmodel.Raw(fixed) =>
      if native(node) == fixed {
        fixed
      } else {
        wrong(node, "the value the model fixes", path)
      }
    @moonmodel.Any => native(node)
  }
}

///|
/// The members of `table` as the model describes them.
///
/// A field that is absent takes its default; a field with neither a default nor
/// a value is [`Missing`] when the model requires it, and left out otherwise.
fn described(
  table : Table,
  model : @moonmodel.Model,
  known : Array[@moonmodel.Model],
  path : String,
  knobs : Bind,
) -> Json raise Refused {
  let out : Map[String, Json] = Map([])
  for field in model.fields {
    let child = if path == "" { field.name } else { path + "." + field.name }
    match table.get(field.name) {
      Some(node) =>
        match (node.value, field.nullable) {
          (Null, true) => out.set(field.name, Json::null())
          (Null, false) =>
            if field.required {
              wrong(node, "a value", child) |> ignore
            }
          _ =>
            out.set(field.name, as_json(node, field.kind, known, child, knobs))
        }
      None =>
        match field.default {
          Some(fixed) => out.set(field.name, fixed)
          None =>
            if field.required {
              raise Missing(path=child, want=shape(field.kind, known))
            }
        }
    }
  }
  if knobs.extra == Refuse {
    for entry in table.entries {
      if model.field(entry.0) is None {
        let child = if path == "" { entry.0 } else { path + "." + entry.0 }
        raise Unknown(path=child, got=entry.1.kind())
      }
    }
  }
  Json::object(out)
}

///|
/// The name of a type, as a person would say it.
fn shape(kind : @moonmodel.Type, known : Array[@moonmodel.Model]) -> String {
  match kind {
    @moonmodel.Bool => "boolean"
    @moonmodel.Int => "integer"
    @moonmodel.Big => "integer"
    @moonmodel.Real => "real"
    @moonmodel.Text => "text"
    @moonmodel.Bytes => "bytes"
    @moonmodel.Stamp => "timestamp"
    @moonmodel.List(element) => "list of " + shape(element, known)
    @moonmodel.Dict(element) => "mapping of " + shape(element, known)
    @moonmodel.Ref(name) => name
    @moonmodel.Nest(model) => model.name
    @moonmodel.Any => "any value"
    @moonmodel.Raw(_) => "the value the model fixes"
  }
}

///|
/// Check a tree against a model and hand back the bound values as JSON, with
/// defaults filled in and text read as the type it spells.
///
/// The JSON is the shape the model describes, so `@json.from_json` turns it into
/// a struct of the caller's own. `known` resolves `Ref` fields by model name; a
/// reference nothing resolves is carried through unchecked rather than guessed
/// at.
pub fn bind(
  node : Node,
  model : @moonmodel.Model,
  known? : Array[@moonmodel.Model],
  knobs? : Bind,
) -> Json raise Refused {
  let table = node.table()
  described(table, model, known.unwrap_or([]), "", knobs.unwrap_or(open))
}

///|
/// Check one value, throwing the result away.
fn check_one(
  here : Node,
  kind : @moonmodel.Type,
  known : Array[@moonmodel.Model],
  path : String,
  knobs : Bind,
) -> Unit raise Refused {
  as_json(here, kind, known, path, knobs) |> ignore
}

///|
/// Walk one value, collecting every refusal below it.
fn collect(
  here : Node,
  kind : @moonmodel.Type,
  path : String,
  known : Array[@moonmodel.Model],
  knobs : Bind,
  out : Array[Refused],
) -> Unit {
  match kind {
    @moonmodel.Nest(inner) =>
      match here.value {
        Map(table) => {
          for field in inner.fields {
            let child = if path == "" {
              field.name
            } else {
              path + "." + field.name
            }
            match table.get(field.name) {
              Some(value) =>
                collect(value, field.kind, child, known, knobs, out)
              None =>
                if field.required && field.default is None {
                  out.push(Missing(path=child, want=shape(field.kind, known)))
                }
            }
          }
          if knobs.extra == Refuse {
            for entry in table.entries {
              if inner.field(entry.0) is None {
                let child = if path == "" {
                  entry.0
                } else {
                  path + "." + entry.0
                }
                out.push(Unknown(path=child, got=entry.1.kind()))
              }
            }
          }
        }
        _ =>
          out.push(
            Type(
              want=inner.name,
              got=here.kind(),
              path~,
              source=here.source,
              at=here.at,
            ),
          )
      }
    @moonmodel.List(element) =>
      match here.value {
        List(items) =>
          for item in items {
            collect(item, element, path, known, knobs, out)
          }
        _ =>
          out.push(
            Type(
              want="list",
              got=here.kind(),
              path~,
              source=here.source,
              at=here.at,
            ),
          )
      }
    @moonmodel.Dict(element) =>
      match here.value {
        Map(table) =>
          for entry in table.entries {
            let child = if path == "" { entry.0 } else { path + "." + entry.0 }
            collect(entry.1, element, child, known, knobs, out)
          }
        _ =>
          out.push(
            Type(
              want="mapping",
              got=here.kind(),
              path~,
              source=here.source,
              at=here.at,
            ),
          )
      }
    @moonmodel.Ref(name) =>
      match resolve(known, name) {
        Some(inner) =>
          collect(here, @moonmodel.Type::Nest(inner), path, known, knobs, out)
        None => ()
      }
    _ =>
      check_one(here, kind, known, path, knobs) catch {
        error => out.push(error)
      }
  }
}

///|
/// Every way `node` fails to be what `model` describes, in tree order.
///
/// Binding stops at the first refusal, because a caller who is going to fail
/// wants one clear message. A caller who wants to show a person everything wrong
/// with a file at once uses this: it walks the whole tree and collects.
pub fn validate(
  node : Node,
  model : @moonmodel.Model,
  known? : Array[@moonmodel.Model],
  knobs? : Bind,
) -> Array[Refused] {
  let out : Array[Refused] = []
  let known = known.unwrap_or([])
  let knobs = knobs.unwrap_or(open)
  match node.value {
    Map(table) => {
      for field in model.fields {
        let child = field.name
        match table.get(field.name) {
          Some(value) => collect(value, field.kind, child, known, knobs, out)
          None =>
            if field.required && field.default is None {
              out.push(Missing(path=child, want=shape(field.kind, known)))
            }
        }
      }
      if knobs.extra == Refuse {
        for entry in table.entries {
          if model.field(entry.0) is None {
            out.push(Unknown(path=entry.0, got=entry.1.kind()))
          }
        }
      }
    }
    _ =>
      out.push(
        Type(
          want=model.name,
          got=node.kind(),
          path="",
          source=node.source,
          at=node.at,
        ),
      )
  }
  out
}

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

///|
pub extend Extra with Debug::{to_repr}

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

///|
pub extend Bind with Debug::{to_repr}