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

///|
/// A float, and the text it was written as.
///
/// TOML spells one number many ways — `1e06`, `1000000.0`, `1_000_000.0` — and a
/// document read and written again should not turn one spelling into another. The
/// text is kept whenever the source had one and it is also a JSON spelling, since
/// that is what a tree of `Json` can carry; a float built in code has none and is
/// written from its value.
#derive(Debug)
pub(all) struct Real {
  value : Double
  repr : String?
}

///|
/// A float from its value alone.
pub fn Real::new(value : Double) -> Real {
  { value, repr: None, }
}

///|
/// Two floats are equal when they are the same number: the spelling says how it
/// was written, not what it is. A NaN equals a NaN here, because this is the
/// equality a document needs.
pub impl Eq for Real with fn equal(self, other) -> Bool {
  self.value == other.value || (self.value.is_nan() && other.value.is_nan())
}

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

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

///|
/// A string value.
pub fn Value::string(text : String) -> Value {
  String(text)
}

///|
/// An integer value.
pub fn Value::integer(whole : Int64) -> Value {
  Integer(whole)
}

///|
/// A boolean value.
pub fn Value::boolean(flag : Bool) -> Value {
  Boolean(flag)
}

///|
/// An array value.
pub fn Value::array(items : Array[Value]) -> Value {
  Array(items)
}

///|
/// A table value, which is what a document is.
pub fn Value::table(fields : Map[String, Value]) -> Value {
  Table(fields)
}

///|
/// A TOML value, kind for kind.
///
/// A `Json` tree has no date-time and only one number, so `loads` reads a
/// timestamp into text and a 64-bit integer into a double. `decode` reads the same
/// document and keeps what the format says it is, which is what writing it back
/// without guessing needs.
#derive(Debug)
pub(all) enum Value {
  String(String)
  Integer(Int64)
  Float(Real)
  Boolean(Bool)
  Datetime(@moondate.Moment)
  Array(Array[Value])
  Table(Map[String, Value])
}

///|
pub impl Eq for Value with fn equal(self, other) -> Bool {
  match (self, other) {
    (String(a), String(b)) => a == b
    (Integer(a), Integer(b)) => a == b
    (Float(a), Float(b)) => a == b
    (Boolean(a), Boolean(b)) => a == b
    (Datetime(a), Datetime(b)) => same_moment(a, b)
    (Array(a), Array(b)) => {
      if a.length() != b.length() {
        return false
      }
      for i = 0; i < a.length(); i = i + 1 {
        if a[i] != b[i] {
          return false
        }
      }
      true
    }
    (Table(a), Table(b)) => {
      if a.length() != b.length() {
        return false
      }
      for name, held in a {
        guard b.get(name) is Some(far) else { return false }
        if held != far {
          return false
        }
      }
      true
    }
    _ => false
  }
}

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

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

///|
/// Whether two date-times are the same instant.
///
/// Two offsets spell the same instant differently, and TOML's four kinds are not
/// interchangeable: a local date-time is not an offset one, so the kinds must
/// match before the instant is compared.
fn same_moment(a : @moondate.Moment, b : @moondate.Moment) -> Bool {
  match (a, b) {
    (Zoned(date~, time~, ..), Zoned(date=far_date, time=far_time, ..)) =>
      match (a.epoch(), b.epoch()) {
        (Some(here), Some(there)) => here == there
        _ => date == far_date && time == far_time
      }
    (Plain(..), Plain(..)) | (Day(..), Day(..)) | (Clock(..), Clock(..)) =>
      a.to_string() == b.to_string()
    _ => false
  }
}

///|
/// The tree as `Json`, which is what `loads` answers.
///
/// A date-time becomes the RFC 3339 text of the moment it is, the same text
/// `loads` gives, so a caller moving between the two forms sees one spelling.
pub impl ToJson for Value with fn to_json(self) -> Json {
  match self {
    String(s) => Json::string(s)
    Integer(n) => Json::number(n.to_double(), repr=n.to_string())
    Float(real) =>
      match real.repr {
        Some(text) => Json::number(real.value, repr=text)
        None => Json::number(real.value)
      }
    Boolean(b) => Json::boolean(b)
    Datetime(m) => Json::string(m.to_string())
    Array(items) => Json::array(items.map(fn(item) { item.to_json() }))
    Table(fields) => {
      let out : Map[String, Json] = Map([])
      for name, held in fields {
        out[name] = held.to_json()
      }
      Json::object(out)
    }
  }
}

///|
pub extend Value with ToJson::{to_json}

///|
/// The tree a `Json` denotes, for the writer that starts from one.
///
/// A string that is spelled like a date-time becomes one, because a `Json` tree
/// cannot say whether it meant a timestamp; `decode` is the entry point that
/// knows. A number that is whole and small enough becomes an integer, which is how
/// a port stays `12000` rather than `12000.0`.
fn value_of_json(json : Json) -> Value {
  match json {
    Null =>
      // TOML has no null. A caller that needs one spells it, and an empty string
      // is the least surprising, as it is for every encoder in reach.
      String("")
    True => Boolean(true)
    False => Boolean(false)
    Number(n, repr~) =>
      match repr {
        Some(text) =>
          match int64_of_text(text) {
            Some(whole) => Integer(whole)
            None => Float({ value: n, repr: Some(text), })
          }
        None =>
          if n == n.floor() && n.abs() < 1.0e15 {
            Integer(n.to_int64())
          } else {
            Float(Real::new(n))
          }
      }
    String(s) =>
      if is_moment(s) {
        Datetime(@moondate.parse(s[:])) catch {
          _ => String(s)
        }
      } else {
        String(s)
      }
    Array(items) => Array(items.map(value_of_json))
    Object(fields) => {
      let out : Map[String, Value] = Map([])
      for name, held in fields {
        out[name] = value_of_json(held)
      }
      Table(out)
    }
  }
}

///|
/// An integer written the way JSON writes one, or nothing.
fn int64_of_text(text : String) -> Int64? {
  if text.length() == 0 {
    return None
  }
  for i = 0; i < text.length(); i = i + 1 {
    let c = text[i].to_int()
    let digit = (c >= 0x30 && c <= 0x39) || (i == 0 && (c == 0x2d || c == 0x2b))
    if !digit {
      return None
    }
  }
  Some(@string.parse_int64(text[:])) catch {
    _ => None
  }
}