// The `model` tree: tables declared with `#orm` annotations, and the build rule
// that turns them into `T::model()`.
//
// The annotation is the declaration. `moonorm`'s own generator reads these structs
// and writes the `@moonorm.Model[T]` — columns, row decoder, insert projection —
// into `.g.mbt` at build time, so what this generator emits is the table
// itself rather than a hand-built copy of it. Only the parts moonorm cannot know
// are written here: the migrations and, for a SQL schema, the CRUD helpers.

///|
/// The package the annotated models live in.
let orm_pkg = "internal/model"

///|
/// `ty` without the trailing `?` that makes it optional.
fn orm_base(ty : String) -> String {
  if @text.ends_with(ty, "?") {
    ty[0:ty.length() - 1].to_owned()
  } else {
    ty
  }
}

///|
/// The `#orm.column` a field needs beyond what its type already says: a primary
/// key, the SQL kind of a MoonBit type `moonorm` has no default column for,
/// `autoincrement` on an optional integer key, `unique`, and the foreign key the
/// column points at. `None` when the type says it all.
fn orm_annotation(
  ty : String,
  primary_key : Bool,
  default : String?,
  column~ : String = "",
  unique~ : Bool = false,
  references~ : String = "",
  on_delete~ : String = "",
  on_update~ : String = "",
) -> String? {
  let base = orm_base(ty)
  let args : Array[String] = []
  if column != "" {
    args.push("name=" + @text.quote(column))
  }
  if primary_key {
    args.push("primary_key=true")
    // Only an optional key can be left for the database to assign.
    if @text.ends_with(ty, "?") && (base == "Int32" || base == "Int64") {
      args.push("autoincrement=true")
    }
  }
  if unique {
    args.push("unique=true")
  }
  match orm_kind(base) {
    Some(kind) => args.push("kind=" + @text.quote(kind))
    None => ()
  }
  match default {
    Some(text) => args.push("default=" + @text.quote(text))
    None => ()
  }
  if references != "" {
    args.push("references=" + @text.quote(references))
    if on_delete != "" {
      args.push("on_delete=" + @text.quote(on_delete))
    }
    if on_update != "" {
      args.push("on_update=" + @text.quote(on_update))
    }
  }
  if args.length() == 0 {
    None
  } else {
    Some("#orm.column(" + args.join(", ") + ")")
  }
}

///|
/// The `kind=` argument for a MoonBit type `moonorm` has no default column for.
/// `Int32` is 32 bits wide where `moonorm`'s `IntType` is the platform word, so it
/// has to say which column it wants; every other type in the `.api`/DDL vocabulary
/// maps on its own.
fn orm_kind(base : String) -> String? {
  match base {
    "Int32" => Some("int")
    _ => None
  }
}

///|
/// One annotated field: its column annotation, if it needs one, over the field
/// itself. A field whose type has no single-column storage — a slice, a map, a
/// nested `type` — takes `#orm.skip`, which keeps it out of the table and fills it
/// from `Default` when a row is read.
fn orm_field(
  field : String,
  ty : String,
  primary_key : Bool,
  default : String?,
  column~ : String = "",
  unique~ : Bool = false,
  references~ : String = "",
  on_delete~ : String = "",
  on_update~ : String = "",
) -> String {
  let note = match @model.storage_class(orm_base(ty)) {
    Some(_) =>
      match
        orm_annotation(
          ty,
          primary_key,
          default,
          column~,
          unique~,
          references~,
          on_delete~,
          on_update~,
        ) {
        Some(annotation) => "  " + annotation + "\n"
        None => ""
      }
    None => "  #orm.skip\n"
  }
  note + "  " + field + " : " + ty + "\n"
}

///|
/// One field of an annotated struct: its MoonBit `name`, its `ty`, and what the
/// schema said about the column beyond that — `unique`, and the foreign key it
/// points at (`references` as `table.column`, with the two referential actions).
priv struct OrmField {
  name : String
  /// The column the field is stored as. Empty when it is the field's own name,
  /// which is what `#orm.column(name=...)` is left out for.
  column : String
  ty : String
  unique : Bool
  references : String
  on_delete : String
  on_update : String
}

///|
/// A field that carries nothing beyond its type, stored in the column `column`
/// when that is not the field's own name.
fn orm_plain_field(
  name : String,
  ty : String,
  column~ : String = "",
) -> OrmField {
  {
    name,
    column,
    ty,
    unique: false,
    references: "",
    on_delete: "",
    on_update: "",
  }
}

///|
/// Whether any field of a struct takes `#orm.skip`, which is what makes
/// `#derive(Default)` necessary.
fn orm_skips(fields : Array[OrmField]) -> Bool {
  for f in fields {
    if @model.storage_class(orm_base(f.ty)) is None {
      return true
    }
  }
  false
}

///|
/// The annotated struct for one table. `table` is the SQL name, `name` the MoonBit
/// type, `fields` its fields in declaration order, `indexes` the `#orm.index`
/// annotations the schema asked for and `relations` the `#orm.has_many` /
/// `#orm.belongs_to` ones it implies.
fn orm_struct(
  name : String,
  table : String,
  doc : String,
  fields : Array[OrmField],
  key : String,
  defaults : Map[String, String],
  indexes~ : Array[String] = [],
  relations~ : Array[String] = [],
) -> String {
  let skips = orm_skips(fields)
  let mut out = "///|\n/// " + doc + "\n"
  if skips {
    out = out + "#derive(Default)\n"
  }
  out = out + "#orm.table(name=" + @text.quote(table) + ")\n"
  for ix in indexes {
    out = out + ix + "\n"
  }
  for rel in relations {
    out = out + rel + "\n"
  }
  out = out + "pub(all) struct " + name + " {\n"
  for f in fields {
    let default = defaults.get(f.name)
    out = out +
      orm_field(
        f.name,
        f.ty,
        f.name == key,
        default,
        column=f.column,
        unique=f.unique,
        references=f.references,
        on_delete=f.on_delete,
        on_update=f.on_update,
      )
  }
  out = out + "}\n"
  if skips {
    // The derive puts the impl in the type's own package, which promotes its
    // methods onto the type; saying so explicitly keeps that a choice.
    out = out +
      "\n///|\n/// `" +
      name +
      "` with every field at its default, which is what a `#orm.skip` field is\n/// filled from when a row is read.\npub extend " +
      name +
      " with Default::{default}\n"
  }
  out
}

///|
/// The `moon.pkg` of the model package: the library, the rule that regenerates the
/// model from the annotations, and the formatter exemption for what it writes.
fn orm_pkg_file(imports : Array[(String, String)]) -> String {
  let mut out = pkg_file(imports)
  out = out +
    "\ndev_build(rule: \"orm\", input: \"models.mbt\", output: \"models.g.mbt\")\n"
  out = out + "\nformatter(ignore: [ \"models.g.mbt\" ])\n"
  out
}

///|
/// The `moon.mod` of a model module: the two libraries and the rule that runs
/// `moonorm`'s generator over the annotated file.
fn orm_mod_file(module_ : String) -> String {
  mod_file(module_, ["moonbitstack/moonorm", "moonbitstack/moondb"]) +
  "\n" +
  rule_line("orm", generator_command("moonbitstack/moonorm", "orm"))
}

///|
/// The tree a model module is made of: the module file, the package with its build
/// rule, the annotated models, the migrations, and the CRUD helpers when there are
/// any. `crud` is `Once` — it is the half the author edits.
fn orm_tree(
  module_ : String,
  st : @style.Style,
  dir : String,
  models : String,
  migrate : String,
  crud : String,
) -> Array[TreeFile] {
  let imports : Array[(String, String)] = [("moonbitstack/moonorm", "")]
  if migrate != "" || crud != "" {
    imports.push(("moonbitstack/moondb", ""))
  }
  let out : Array[TreeFile] = [
    { path: at(dir, "moon.mod"), content: orm_mod_file(module_), regen: Once, },
    {
      path: at(dir, orm_pkg + "/moon.pkg"),
      content: orm_pkg_file(imports),
      regen: Once,
    },
    {
      path: at(dir, orm_pkg + "/" + st.format("models") + ".mbt"),
      content: models,
      regen: Always,
    },
  ]
  if migrate != "" {
    out.push({
      path: at(dir, orm_pkg + "/" + st.format("migrate") + ".mbt"),
      content: migrate,
      regen: Always,
    })
  }
  if crud != "" {
    out.push({
      path: at(dir, orm_pkg + "/" + st.format("crud") + ".mbt"),
      content: crud,
      regen: Once,
    })
  }
  out
}

///|
/// The annotated models and migrations for `spec`: every `type` block is a table,
/// its `id` field (when it has one) the primary key, and a field with no single
/// column — a slice, a map, a nested type — is skipped rather than stored.
pub fn generate_orm_tree(
  spec : @api.Spec,
  style? : @style.Style,
  dir~ : String = "",
  naming? : @style.NamingPolicy,
) -> Array[TreeFile] {
  let st = style.unwrap_or(@style.Style::gozero())
  // The spec's `info` block names the tables and columns; a spec that names none
  // leaves every generator spelling them the way it always has.
  let policy = match naming {
    Some(p) => Some(p)
    None => @style.NamingPolicy::of_info(spec.info)
  }
  let mut models = "// Code generated by moonctl from the `.api` spec. DO NOT EDIT.\n//\n// Each struct is a table: `#orm.table` names it, and the `@moonorm.Model` is\n// generated from these annotations into `" +
    st.format("models") +
    ".g.mbt` on build.\n"
  let mut migrate = "// Code generated by moonctl from the `.api` spec. DO NOT EDIT.\n"
  for t in spec.types {
    let fields = @api.flat_fields(t, spec.types)
    let table = match policy {
      Some(p) => p.table_name(t.name)
      None => @text.to_snake(t.name)
    }
    let pairs : Array[OrmField] = []
    for f in fields {
      let field = f.mbt_name()
      let column = match policy {
        Some(p) => p.column_name(f.json_name())
        None => field
      }
      pairs.push(
        orm_plain_field(
          field,
          f.type_,
          column=if column == field { "" } else { column },
        ),
      )
    }
    let has_key = fields.any(f => {
      f.mbt_name() == "id" && @model.storage_class(f.type_) is Some(_)
    })
    models = models +
      "\n" +
      orm_struct(
        t.name,
        table,
        "`" +
        t.name +
        "` table, from the `.api` `type` block of the " +
        spec.service +
        " service.",
        pairs,
        if has_key {
          "id"
        } else {
          ""
        },
        Map([]),
      )
    if has_key {
      migrate = migrate +
        "\n" +
        @model.migration_updown(table, t.name + "::model()")
    }
  }
  orm_tree(
    spec.service,
    st,
    dir,
    models,
    if @text.ends_with(migrate, "DO NOT EDIT.\n") {
      ""
    } else {
      migrate
    },
    "",
  )
}

///|
/// The `(table, column)` a foreign key points at, taking the referenced table's
/// single-column primary key when the DDL named no column.
fn ddl_fk_target(
  fk : @model.DdlForeignKey,
  parent : @model.DdlTable,
) -> String? {
  match fk.ref_columns {
    [one, ..] => Some(one)
    [] => parent_pk(parent)
  }
}

///|
/// The single-column primary key of a table, or `None` for a table with none or a
/// composite one — the only shape a relation can hang on without an explicit key.
fn parent_pk(t : @model.DdlTable) -> String? {
  let pks : Array[String] = []
  for c in t.columns {
    if c.primary_key {
      pks.push(c.name)
    }
  }
  match pks {
    [one] => Some(one)
    _ => None
  }
}

///|
/// Whether `t` has a column called `name`.
fn has_column(t : @model.DdlTable, name : String) -> Bool {
  for c in t.columns {
    if c.name == name {
      return true
    }
  }
  false
}

///|
/// The table `name` names, comparing case-insensitively the way SQL does.
fn find_table(
  tables : Array[@model.DdlTable],
  name : String,
) -> @model.DdlTable? {
  let low = name.to_lower()
  for t in tables {
    if t.name.to_lower() == low {
      return Some(t)
    }
  }
  None
}

///|
/// The name a `belongs_to` relation takes: the key column with the suffix a key
/// column wears taken off, so `team_id` is `team` and `country_code` is `country`.
/// A column whose name carries no such suffix keeps it, and the caller adds `_ref`
/// when that would collide with the column itself.
fn relation_name(column : String) -> String {
  for suffix in ["_id", "_key", "_code", "_no", "_uuid"] {
    if @text.ends_with(column, suffix) && column.length() > suffix.length() {
      return column[0:column.length() - suffix.length()].to_owned()
    }
  }
  column
}

///|
/// `value`, or `fallback` when it is `None`.
fn or_default(value : String?, fallback : String) -> String {
  match value {
    Some(v) => v
    None => fallback
  }
}

///|
/// The `#orm.has_many` and `#orm.belongs_to` annotations a SQL schema implies, per
/// table. A foreign key names both sides: the referencing table gets a `belongs_to`
/// (named after the column, `team_id` → `team`) and the referenced one a `has_many`
/// (named after the referencing table). A key onto a table the schema does not
/// declare is left out — an annotation can only point at a type in the same file —
/// and so is a composite key, which has no single field to hang a relation on.
fn ddl_relations(tables : Array[@model.DdlTable]) -> Map[String, Array[String]] {
  let out : Map[String, Array[String]] = Map([])
  // The names already given to a relation on each table, so two keys onto the same
  // table do not both claim `posts`.
  let taken : Map[String, Array[String]] = Map([])
  for t in tables {
    out.set(t.name, [])
    taken.set(t.name, [])
  }
  for t in tables {
    let child = @text.to_pascal(t.name)
    for fk in t.foreign_keys {
      if fk.columns.length() != 1 {
        continue
      }
      let parent = match find_table(tables, fk.ref_table) {
        Some(p) => p
        None => continue
      }
      let column = fk.columns[0]
      let target = match ddl_fk_target(fk, parent) {
        Some(c) => c
        None => continue
      }
      let key_note = if target == or_default(parent_pk(parent), "") {
        ""
      } else {
        ", key=" + @text.quote(target)
      }
      // The referenced side: `::()`.
      let many = out.get(parent.name).unwrap()
      let many_taken = taken.get(parent.name).unwrap()
      let mut many_name = t.name
      if many_taken.contains(many_name) || has_column(parent, many_name) {
        many_name = many_name + "_list"
      }
      many_taken.push(many_name)
      many.push(
        "#orm.has_many(name=" +
        @text.quote(many_name) +
        ", target=" +
        @text.quote(child) +
        ", foreign_key=" +
        @text.quote(column) +
        key_note +
        ")",
      )
      // The referencing side: `::()`.
      let one = out.get(t.name).unwrap()
      let one_taken = taken.get(t.name).unwrap()
      let mut one_name = relation_name(column)
      if one_taken.contains(one_name) || has_column(t, one_name) {
        one_name = one_name + "_ref"
      }
      one_taken.push(one_name)
      one.push(
        "#orm.belongs_to(name=" +
        @text.quote(one_name) +
        ", target=" +
        @text.quote(@text.to_pascal(parent.name)) +
        ", foreign_key=" +
        @text.quote(column) +
        key_note +
        ")",
      )
    }
  }
  out
}

///|
/// The `#orm.index` annotations a table's DDL asked for: one per `CREATE INDEX`,
/// `UNIQUE`/`KEY` constraint or column-level `UNIQUE`. `column=` is repeated once
/// per column, the shape `#orm.index` reads.
fn ddl_index_notes(t : @model.DdlTable) -> Array[String] {
  let out : Array[String] = []
  for ix in t.indexes {
    let parts : Array[String] = []
    for c in ix.columns {
      parts.push("column=" + @text.quote(c))
    }
    let mut note = "#orm.index(" + parts.join(", ")
    if ix.name != "" {
      note = note + ", name=" + @text.quote(ix.name)
    }
    if ix.unique {
      note = note + ", unique=true"
    }
    out.push(note + ")")
  }
  out
}

///|
/// The annotated models, migrations and CRUD helpers for a SQL schema. The table
/// and column names are the schema's; a nullable column is declared optional, so
/// `#orm` reads it as one. The schema's indexes become `#orm.index`, and each
/// foreign key a pair of relations — `#orm.has_many` on the referenced table,
/// `#orm.belongs_to` on the referencing one.
pub fn generate_orm_tree_from_ddl(
  tables : Array[@model.DdlTable],
  module_ : String,
  style? : @style.Style,
  dir~ : String = "",
) -> Array[TreeFile] {
  let st = style.unwrap_or(@style.Style::gozero())
  let mut models = "// Code generated by moonctl from the SQL schema. DO NOT EDIT.\n//\n// Each struct is a table: `#orm.table` names it, and the `@moonorm.Model` is\n// generated from these annotations into `" +
    st.format("models") +
    ".g.mbt` on build.\n"
  let mut migrate = "// Code generated by moonctl from the SQL schema. DO NOT EDIT.\n"
  let mut crud = "// Code generated by moonctl from the SQL schema. DO NOT EDIT.\n"
  let relations = ddl_relations(tables)
  for t in tables {
    let name = @text.to_pascal(t.name)
    let pairs : Array[OrmField] = []
    let defaults : Map[String, String] = Map([])
    let mut key = ""
    for c in t.columns {
      let fk = @model.ddl_fk_of(t, c.name)
      let reference = match fk {
        Some(f) => {
          let target = match f.ref_columns {
            [one, ..] => one
            [] =>
              match find_table(tables, f.ref_table) {
                Some(p) => or_default(parent_pk(p), "")
                None => ""
              }
          }
          if target == "" {
            ""
          } else {
            f.ref_table + "." + target
          }
        }
        None => ""
      }
      pairs.push({
        name: c.name,
        column: "",
        ty: ddl_field_type(c),
        unique: c.unique,
        references: reference,
        on_delete: match fk {
          Some(f) => f.on_delete
          None => ""
        },
        on_update: match fk {
          Some(f) => f.on_update
          None => ""
        },
      })
      if c.primary_key && key == "" {
        key = c.name
      }
      match c.default_ {
        Some(text) => defaults.set(c.name, text)
        None => ()
      }
    }
    models = models +
      "\n" +
      orm_struct(
        name,
        t.name,
        "`" + t.name + "` table.",
        pairs,
        key,
        defaults,
        indexes=ddl_index_notes(t),
        relations=relations.get(t.name).unwrap(),
      )
    migrate = migrate + "\n" + @model.migration_for(t, name + "::model()")
    crud = crud +
      "\n" +
      @model.crud_for(t, name + "::model()", nullable_aware=true)
  }
  orm_tree(module_, st, dir, models, migrate, crud)
}

///|
/// The MoonBit type a column is declared as in the annotated tree: a nullable
/// column is optional, which is how `#orm` spells `NULL`. The flat generator keeps
/// the column's own type and says `nullable` on the model instead; here the type
/// carries it, so a `None` has to be bound as SQL `NULL` — which is what
/// `nullable_aware` asks `crud_for` for.
fn ddl_field_type(c : @model.DdlColumn) -> String {
  if c.nullable && !c.primary_key {
    c.type_ + "?"
  } else {
    c.type_
  }
}