// `mctl model mongo` (← goctl's `model mongo`): a document model per `type`
// block, over a collection port instead of a driver.
//
// A Mongo driver is a dependency the generated code should not have to carry: what
// a repository needs from one is five calls — insert one, find one, find many,
// replace one, delete one — and every driver spells those differently. So the port
// here is `Collection.run`: an operation, a filter, a document in, a document out.
// A driver binds it in a dozen lines, and everything above it is generated and
// typed.
//
// The document itself is a `struct` with `derive(FromJson, ToJson)`, which is what
// makes a value round-trip through the store without a hand-written field walker
// per type. Its keys are the MoonBit field names — the spec's names in
// `snake_case` — so a `json:"…"` tag renames the wire form, not the stored one.

///|
/// Every type block that has a field becomes a document. A block with no field is a
/// placeholder rather than a shape, and a collection of empty documents stores
/// nothing.
fn mongo_document(t : @api.TypeDef, types : Array[@api.TypeDef]) -> String? {
  if @api.flat_fields(t, types).length() == 0 {
    return None
  }
  Some(t.name)
}

///|
/// The field a document is looked up by: the one named `id`, or — when the spec has
/// no such field — the first one that holds a single value, since a filter needs a
/// value a driver can compare, not a whole document. `None` when every field is a
/// list or a map, which leaves a repository that can only insert and list.
fn key_field(fields : Array[@api.Field]) -> @api.Field? {
  for f in fields {
    if f.json_name() == "id" || f.mbt_name() == "id" {
      return Some(f)
    }
  }
  for f in fields {
    let t = @text.trim(f.type_)
    if @text.starts_with(t, "Array[") == false &&
      @text.starts_with(t, "Map[") == false {
      return Some(f)
    }
  }
  None
}

///|
/// The index of the `", "` between a `Map[K, V]`'s two halves, or `-1`.
fn map_comma(s : String) -> Int32 {
  let mut depth = 0
  for i = 0; i < s.length(); i = i + 1 {
    let c = s[i]
    if c == '[' {
      depth = depth + 1
    } else if c == ']' {
      depth = depth - 1
    } else if c == ',' && depth == 0 {
      return i
    }
  }
  -1
}

///|
/// A field's type as the document carries it: a type the spec also models becomes
/// that type's `Doc`, so a document that nests one nests a document — an `owner :
/// User` field is stored as an `owner : UserDoc`. Lists and maps are rewritten
/// element by element; anything the spec does not model is carried across as it is,
/// which leaves the typechecker to reject a spec that names a type it never
/// declared.
fn doc_type(t : String, names : Array[String]) -> String {
  let s = @text.trim(t)
  if s == "" {
    return s
  }
  if @text.ends_with(s, "?") && s.length() > 1 {
    return doc_type(s[0:s.length() - 1].to_owned(), names) + "?"
  }
  if @text.starts_with(s, "Array[") && @text.ends_with(s, "]") {
    return "Array[" + doc_type(s[6:s.length() - 1].to_owned(), names) + "]"
  }
  if @text.starts_with(s, "Map[") && @text.ends_with(s, "]") {
    let body = s[4:s.length() - 1].to_owned()
    let comma = map_comma(body)
    if comma >= 0 {
      return "Map[" +
        doc_type(body[0:comma].to_owned(), names) +
        ", " +
        doc_type(body[comma + 1:].to_owned(), names) +
        "]"
    }
  }
  for n in names {
    if n == s {
      return n + "Doc"
    }
  }
  s
}

///|
/// The field as the document declares it: its type rewritten to the document's own
/// types, and made nullable when its tag says `optional` — a document key that may
/// be absent has to hold `null`, which is what `T?` is.
fn doc_field(f : @api.Field, names : Array[String]) -> String {
  let t = doc_type(f.type_, names)
  if f.optional() && @text.ends_with(t, "?") == false {
    t + "?"
  } else {
    t
  }
}

///|
/// The prelude every document model shares: the error a store failure becomes, the
/// operations a repository asks for, the port a driver fills, and the two document
/// helpers the generated methods use.
fn mongo_prelude() -> String {
  "// Code generated by moonctl. DO NOT EDIT.\n" +
  "//\n" +
  "// `mctl model mongo`: one document model and one typed repository per `.api`\n" +
  "// `type` block, over the `Collection` port below. A driver binds\n" +
  "// `Collection.run` to its own insert-one / find-one / find / replace-one /\n" +
  "// delete-one, and every repository is typed from there.\n" +
  "//\n" +
  "// This file needs `moonbitlang/core/json` imported as `@json`, which is where\n" +
  "// `from_json` and `JsonDecodeError` live (`Json` and `ToJson` are core itself).\n" +
  "\n" +
  "///|\n/// A document store failure: the key was not there, the reply could not be read\n/// back, or the driver refused the call.\npub(all) suberror MongoError {\n  MongoMissing(collection~ : String, key~ : String)\n  MongoBadDocument(collection~ : String, why~ : String)\n  MongoFailed(collection~ : String, why~ : String)\n}\n" +
  "\n" +
  "///|\n/// The five calls a repository makes of a store, one name each, so a driver binds\n/// `Insert` to `insertOne`, `FindOne` to `findOne`, `Find` to `find`, `Replace` to\n/// `replaceOne` and `Delete` to `deleteOne`.\npub(all) enum MongoOp {\n  Insert\n  FindOne\n  Find\n  Replace\n  Delete\n}\n" +
  "\n" +
  "///|\n/// A collection: its name, and the one call a driver supplies. `filter` selects the\n/// documents an operation applies to (the empty document means all of them), `doc`\n/// is what a write carries (empty for a read), and the reply is whatever the driver\n/// returned — a stored document, an array of them, or `true` for a delete.\npub(all) struct Collection {\n  name : String\n  run : (MongoOp, Json, Json) -> Json raise MongoError\n}\n" +
  "\n" +
  "///|\n/// The empty document: the filter that selects everything, and the document a read\n/// carries.\nfn empty_doc() -> Json {\n  let m : Map[String, Json] = Map([])\n  @json.to_json(m)\n}\n" +
  "\n" +
  "///|\n/// The filter matching one key against one value: `filter_of(\"id\", 7)` is\n/// `{ \"id\": 7 }`.\nfn filter_of(key : String, value : Json) -> Json {\n  let m : Map[String, Json] = Map([])\n  m[key] = value\n  @json.to_json(m)\n}\n\n"
}

///|
/// One document type: the struct the collection stores, its two conversions, and the
/// repository over it. `fields` are already flattened, so an embedded type
/// contributes its fields rather than a second struct.
fn emit_document(
  name : String,
  fields : Array[@api.Field],
  collection : String,
  names : Array[String],
) -> String {
  let doc = name + "Doc"
  let repo = name + "Repo"
  let mut out = "///|\n/// `" +
    name +
    "` as the `" +
    collection +
    "` collection stores it.\npub(all) struct " +
    doc +
    " {\n"
  for f in fields {
    out = out + "  " + f.mbt_name() + " : " + doc_field(f, names) + "\n"
  }
  out = out +
    "} derive(FromJson, ToJson)\n\n" +
    "///|\n/// `" +
    doc +
    "` reads (and writes) itself as a document; the explicit `extend` is what keeps\n/// the derived method from being promoted onto the struct as well.\npub extend " +
    doc +
    " with @json.FromJson::{from_json}\n\n" +
    "///|\npub extend " +
    doc +
    " with ToJson::{to_json}\n\n" +
    "///|\n/// The `" +
    collection +
    "` collection as a typed repository.\npub(all) struct " +
    repo +
    " {\n  coll : Collection\n}\n\n" +
    "///|\n/// A repository over `coll`, the driver-bound `" +
    collection +
    "` collection.\npub fn " +
    repo +
    "::new(coll : Collection) -> " +
    repo +
    " {\n  { coll, }\n}\n\n" +
    "///|\n/// Insert `doc`.\npub fn " +
    repo +
    "::insert(\n  self : " +
    repo +
    ",\n  doc : " +
    doc +
    "\n) -> Unit raise MongoError {\n  ignore((self.coll.run)(Insert, empty_doc(), @json.to_json(doc)))\n}\n\n" +
    "///|\n/// Every document in the collection.\npub fn " +
    repo +
    "::all(self : " +
    repo +
    ") -> Array[" +
    doc +
    "] raise MongoError {\n  let reply = (self.coll.run)(Find, empty_doc(), empty_doc())\n  @json.from_json(reply) catch {\n    e =>\n      raise MongoBadDocument(collection=self.coll.name, why=e.to_string())\n  }\n}\n\n"
  match key_field(fields) {
    None => ()
    Some(key) => {
      let label = key.mbt_name()
      let field = @text.quote(key.mbt_name())
      out = out +
        "///|\n/// The document whose `" +
        label +
        "` is `" +
        label +
        "`, or `MongoMissing` when there is none.\npub fn " +
        repo +
        "::find(\n  self : " +
        repo +
        ",\n  " +
        label +
        " : " +
        doc_type(key.type_, names) +
        "\n) -> " +
        doc +
        " raise MongoError {\n  let reply = (self.coll.run)(FindOne, filter_of(" +
        field +
        ", @json.to_json(" +
        label +
        ")), empty_doc())\n  match reply {\n    Null =>\n      raise MongoMissing(collection=self.coll.name, key=" +
        label +
        ".to_string())\n    _ =>\n      @json.from_json(reply) catch {\n        e =>\n          raise MongoBadDocument(collection=self.coll.name, why=e.to_string())\n      }\n  }\n}\n\n" +
        "///|\n/// Replace the stored document with `doc`, matched by `" +
        label +
        "`.\npub fn " +
        repo +
        "::replace(\n  self : " +
        repo +
        ",\n  doc : " +
        doc +
        "\n) -> Unit raise MongoError {\n  let key = filter_of(" +
        field +
        ", @json.to_json(doc." +
        label +
        "))\n  ignore((self.coll.run)(Replace, key, @json.to_json(doc)))\n}\n\n" +
        "///|\n/// Delete the document whose `" +
        label +
        "` is `" +
        label +
        "`; `false` when there was none.\npub fn " +
        repo +
        "::delete(\n  self : " +
        repo +
        ",\n  " +
        label +
        " : " +
        doc_type(key.type_, names) +
        "\n) -> Bool raise MongoError {\n  let reply = (self.coll.run)(Delete, filter_of(" +
        field +
        ", @json.to_json(" +
        label +
        ")), empty_doc())\n  @json.from_json(reply) catch {\n    e =>\n      raise MongoBadDocument(collection=self.coll.name, why=e.to_string())\n  }\n}\n\n"
    }
  }
  out
}

///|
/// Generate a Mongo document model for every `type` block of `spec`: a `Doc` struct
/// that is the document as stored, and a `Repo` over the `Collection` port with
/// `insert`, `all`, and — when the spec has a key field — `find`, `replace` and
/// `delete`.
///
/// The output compiles against MoonBit core: `Json` and `ToJson` are builtin, and
/// the file imports `moonbitlang/core/json` as `@json` for `from_json`. A driver
/// supplies the port; without one, nothing here needs a database to typecheck.
pub fn generate_mongo(spec : @api.Spec) -> String {
  let mut out = mongo_prelude()
  let names = spec.types.map(t => t.name)
  let mut wrote = false
  for t in spec.types {
    match mongo_document(t, spec.types) {
      None => ()
      Some(name) => {
        out = out +
          emit_document(
            name,
            @api.flat_fields(t, spec.types),
            table_name(name),
            names,
          )
        wrote = true
      }
    }
  }
  if wrote == false {
    out = out +
      "// The spec declares no `type` block with a field, so there is no document\n// to model.\n"
  }
  out
}

///|
/// The file a document model lands in: `greet` → `greet_mongo.mbt`, named after the
/// service so two specs generated into one directory do not collide.
pub fn mongo_file_name(service : String) -> String {
  (if service == "" { "model" } else { @text.to_snake(service) }) + "_mongo.mbt"
}