// The JSON form of a message and of an outcome.
//
// The keys are the published protocol's, and the two instants are ISO 8601 text
// with an offset, which is what every implementation of that protocol writes.
// Inside this library time is milliseconds; the conversion lives here and
// nowhere else.

///|
/// An instant as the protocol writes it: ISO 8601, milliseconds, UTC.
fn instant_text(at : Int64) -> String {
  let seconds = if at >= 0L { at / 1000L } else { (at - 999L) / 1000L }
  let nano = ((at - seconds * 1000L) * 1000000L).to_int()
  @moondate.Moment::of_epoch(seconds, nano~, zone=@moondate.Utc).isoformat(
    fraction=3,
    offset=true,
  )
}

///|
/// An instant read back from the protocol's text. A number is accepted too: a
/// producer that writes epoch milliseconds is common enough, and reading it as a
/// date would be wrong by three orders of magnitude.
fn instant_value(
  json : Json,
  path : @json.JsonPath,
) -> Int64 raise @json.JsonDecodeError {
  match json {
    String(text) => instant_of_text(text, path)
    Number(value, ..) => value.to_int64()
    _ => raise @json.JsonDecodeError((path, "expected an instant"))
  }
}

///|
fn instant_of_text(
  text : String,
  path : @json.JsonPath,
) -> Int64 raise @json.JsonDecodeError {
  let moment = @moondate.parse(text) catch {
    _ => raise @json.JsonDecodeError((path, "not an ISO 8601 instant: " + text))
  }
  match moment.timestamp() {
    Some(seconds) => (seconds * 1000.0).round().to_int64()
    None => raise @json.JsonDecodeError((path, "instant out of range: " + text))
  }
}

///|
fn object_value(
  json : Json,
  path : @json.JsonPath,
) -> Map[String, Json] raise @json.JsonDecodeError {
  match json {
    Object(fields) => fields
    _ => raise @json.JsonDecodeError((path, "expected an object"))
  }
}

///|
fn text_value(
  fields : Map[String, Json],
  key : String,
  path : @json.JsonPath,
) -> String raise @json.JsonDecodeError {
  match fields.get(key) {
    Some(String(text)) => text
    Some(_) =>
      raise @json.JsonDecodeError((path.add_key(key), "expected a string"))
    None => raise @json.JsonDecodeError((path, "missing key " + key))
  }
}

///|
fn int_value(
  fields : Map[String, Json],
  key : String,
  path : @json.JsonPath,
) -> Int32 raise @json.JsonDecodeError {
  match fields.get(key) {
    Some(Number(value, ..)) => value.to_int()
    Some(_) =>
      raise @json.JsonDecodeError((path.add_key(key), "expected a number"))
    None => raise @json.JsonDecodeError((path, "missing key " + key))
  }
}

///|
/// The instant under `key`, or `None` when it is absent or null.
fn instant_field(
  fields : Map[String, Json],
  key : String,
  path : @json.JsonPath,
) -> Int64? raise @json.JsonDecodeError {
  match fields.get(key) {
    None | Some(Null) => None
    Some(value) => Some(instant_value(value, path.add_key(key)))
  }
}

///|
pub impl ToJson for Message with fn to_json(self) {
  let out = Map([])
  out["id"] = Json::string(self.id)
  out["task"] = Json::string(self.name)
  out["args"] = self.args
  out["kwargs"] = Json::object(Map([]))
  out["retries"] = Json::number(self.retries.to_double())
  out["queue"] = Json::string(self.queue)
  out["priority"] = Json::number(self.priority.to_double())
  out["utc"] = Json::boolean(true)
  match self.eta {
    Some(at) => out["eta"] = Json::string(instant_text(at))
    None => ()
  }
  match self.expires {
    Some(at) => out["expires"] = Json::string(instant_text(at))
    None => ()
  }
  if self.chain.length() > 0 {
    out["chain"] = Json::array(self.chain.map(fn(m) { Json(m) }))
  }
  match self.chord {
    Some(chord) => out["chord"] = Json(chord)
    None => ()
  }
  if self.single_arg {
    out["single_arg"] = Json::boolean(true)
  }
  if self.immutable {
    out["immutable"] = Json::boolean(true)
  }
  Json::object(out)
}

///|
pub extend Message with @json.ToJson::{to_json}

///|
pub impl FromJson for Message with fn from_json(json, path) {
  Message::read(object_value(json, path), path)
}

///|
pub extend Message with @json.FromJson::{from_json}

///|
pub impl ToJson for Chord with fn to_json(self) {
  let out = Map([])
  out["key"] = Json::string(self.key)
  out["index"] = Json::number(self.index.to_double())
  out["total"] = Json::number(self.total.to_double())
  out["ids"] = Json::array(self.ids.map(fn(id) { Json::string(id) }))
  out["callback"] = Json(self.callback)
  Json::object(out)
}

///|
pub extend Chord with @json.ToJson::{to_json}

///|
pub impl FromJson for Chord with fn from_json(json, path) {
  let fields = object_value(json, path)
  let ids = match fields.get("ids") {
    Some(Array(items)) =>
      items.map(fn(item) raise @json.JsonDecodeError {
        match item {
          String(id) => id
          _ =>
            raise @json.JsonDecodeError(
              (path.add_key("ids"), "expected strings"),
            )
        }
      })
    _ => raise @json.JsonDecodeError((path, "missing key ids"))
  }
  let callback = match fields.get("callback") {
    Some(value) => @json.from_json(value, path=path.add_key("callback"))
    None => raise @json.JsonDecodeError((path, "missing key callback"))
  }
  {
    key: text_value(fields, "key", path),
    index: int_value(fields, "index", path),
    total: int_value(fields, "total", path),
    ids,
    callback,
  }
}

///|
pub extend Chord with @json.FromJson::{from_json}

///|
/// The message a JSON object describes. Split from the `FromJson` impl so a
/// chord can decode its callback without going through a trait method.
fn Message::read(
  fields : Map[String, Json],
  path : @json.JsonPath,
) -> Message raise @json.JsonDecodeError {
  let chain = match fields.get("chain") {
    None | Some(Null) => []
    Some(Array(items)) => {
      let decoded = []
      let mut index : Int32 = 0
      for item in items {
        decoded.push(
          @json.from_json(item, path=path.add_key("chain").add_index(index)),
        )
        index = index + 1
      }
      decoded
    }
    Some(_) => raise @json.JsonDecodeError((path, "chain is not a list"))
  }
  let chord = match fields.get("chord") {
    None | Some(Null) => None
    Some(value) => Some(@json.from_json(value, path=path.add_key("chord")))
  }
  {
    id: text_value(fields, "id", path),
    name: text_value(fields, "task", path),
    args: fields.get("args").unwrap_or(Json::null()),
    queue: match fields.get("queue") {
      Some(String(queue)) => queue
      _ => "default"
    },
    priority: match fields.get("priority") {
      Some(Number(value, ..)) => value.to_int()
      _ => 0
    },
    eta: instant_field(fields, "eta", path),
    expires: instant_field(fields, "expires", path),
    retries: match fields.get("retries") {
      Some(Number(value, ..)) => value.to_int()
      _ => 0
    },
    chain,
    chord,
    single_arg: match fields.get("single_arg") {
      Some(True) => true
      Some(False) => false
      _ => false
    },
    immutable: match fields.get("immutable") {
      Some(True) => true
      Some(False) => false
      _ => false
    },
  }
}

///|
/// A message read from its JSON text. The `FromJson` impl is the same reading;
/// this is the entry a broker uses, so a bad payload is refused in this
/// library's terms rather than in the JSON module's.
pub fn Message::parse(text : StringView) -> Message raise Refused {
  let json = @json.parse(text) catch {
    error => raise Refused::Malformed(got=error.to_string())
  }
  read_message(json) catch {
    error => raise Refused::Malformed(got=error.to_string())
  }
}

///|
/// A message read at the root of a document, so the default path can be used.
fn read_message(json : Json) -> Message raise @json.JsonDecodeError {
  @json.from_json(json)
}

///|
/// The JSON text of a message, which is what a broker stores.
pub fn Message::text(self : Message) -> String {
  Json(self).stringify()
}

///|
pub impl ToJson for Outcome with fn to_json(self) {
  let out = Map([])
  out["state"] = Json::string(self.state.code())
  match self.result {
    Some(value) => out["result"] = value
    None => ()
  }
  match self.error {
    Some(error) => out["error"] = Json::string(error)
    None => ()
  }
  out["retries"] = Json::number(self.retries.to_double())
  out["at"] = Json::string(instant_text(self.at))
  Json::object(out)
}

///|
pub extend Outcome with @json.ToJson::{to_json}

///|
pub impl FromJson for Outcome with fn from_json(json, path) {
  let fields = object_value(json, path)
  let state = State::of(text_value(fields, "state", path)) catch {
    _ => raise @json.JsonDecodeError((path.add_key("state"), "unknown state"))
  }
  let result = match fields.get("result") {
    None | Some(Null) => None
    Some(value) => Some(value)
  }
  let error = match fields.get("error") {
    Some(String(message)) => Some(message)
    _ => None
  }
  {
    state,
    result,
    error,
    retries: match fields.get("retries") {
      Some(Number(value, ..)) => value.to_int()
      _ => 0
    },
    at: instant_value(
      fields.get("at").unwrap_or(Json::null()),
      path.add_key("at"),
    ),
  }
}

///|
pub extend Outcome with @json.FromJson::{from_json}