///|
/// `#orm.table` on a struct, with `#orm.index`, `#orm.has_many` and
/// `#orm.belongs_to` beside it, and `#orm.column` or `#orm.skip` on its fields,
/// turned into `T::model()` and one function per relation.
priv struct Orm {}

///|
impl @gen.Generator for Orm with fn name(_) {
  "moonorm"
}

///|
let table_spec : @attr.Spec = @attr.Spec::new("orm.table").positional(
  "name",
  Str,
  optional=true,
)

///|
let column_spec : @attr.Spec = @attr.Spec::new("orm.column")
  .optional("name", Str)
  .optional("kind", Str)
  .optional("sqlite", Str)
  .optional("postgres", Str)
  .optional("mysql", Str)
  .optional("primary_key", Bool)
  .optional("autoincrement", Bool)
  .optional("unique", Bool)
  .optional("default", Str)
  .many("check", Str)
  .optional("references", Str)
  .optional("on_delete", Str)
  .optional("on_update", Str)
  .optional("index", Bool)

///|
let index_spec : @attr.Spec = @attr.Spec::new("orm.index")
  .many("column", Str)
  .optional("name", Str)
  .optional("unique", Bool)

///|
/// A relation on a struct: `name` is the function it becomes, `target` the
/// struct on the other side and `foreign_key` the column that links them.
let has_many_spec : @attr.Spec = @attr.Spec::new("orm.has_many")
  .required("name", Str)
  .required("target", Str)
  .required("foreign_key", Str)
  .optional("key", Str)

///|
let belongs_to_spec : @attr.Spec = @attr.Spec::new("orm.belongs_to")
  .required("name", Str)
  .required("target", Str)
  .required("foreign_key", Str)
  .optional("key", Str)

///|
let skip_spec : @attr.Spec = @attr.Spec::new("orm.skip")

///|
/// Where `attr` is written, as `file:line:column`.
fn at(attr : @attribute.Attribute, file : String) -> String {
  "\{file}:\{attr.loc.start.lnum}:\{attr.loc.start.column()}"
}

///|
/// The `#orm.*` attributes among `attrs`. One that does not parse is refused here
/// rather than passed over, since `@attr.under` sees only those that parse.
fn orm_attrs(
  attrs : @list.List[@attribute.Attribute],
  file : String,
) -> Array[@attribute.Attribute] raise {
  for attr in @attr.malformed(attrs, "orm") {
    fail("\{at(attr, file)}: `\{attr.raw}` does not parse as an annotation")
  }
  @attr.under(attrs, "orm")
}

///|
fn quoted(text : String) -> String {
  @quote.string(text).to_string()
}

///|
fn args_str(args : @attr.Args?, key : String) -> String? {
  match args {
    Some(a) => a.str(key)
    None => None
  }
}

///|
fn args_flag(args : @attr.Args?, key : String) -> Bool {
  match args {
    Some(a) => a.bool(key) is Some(true)
    None => false
  }
}

///|
/// A field as the column it declares: its name, the `field(...)` call, and
/// whether it asks for an index of its own. `None` for a skipped field.
fn column(
  field : @gen.Field,
  where_ : String,
  file : String,
) -> (String, String, Bool)? raise {
  let mut col = None
  let mut skip = None
  for attr in orm_attrs(field.attrs, file) {
    match @attr.name_of(attr) {
      Some("orm.column") if col is None => col = Some(attr)
      Some("orm.skip") if skip is None => skip = Some(attr)
      Some("orm.column" | "orm.skip" as name) =>
        fail("\{at(attr, file)}: `#\{name}` is written twice")
      Some(name) =>
        fail("\{at(attr, file)}: `#\{name}` is not a moonorm field annotation")
      None => fail("\{at(attr, file)}: unnamed")
    }
  }
  if skip is Some(s) {
    ignore(skip_spec.bind(s))
    if col is Some(c) {
      fail("\{at(c, file)}: a skipped field takes no `#orm.column`")
    }
    return None
  }
  let where_ = match col {
    Some(c) => at(c, file)
    None => where_
  }
  let what = "field `\{field.name}`"
  let args = match col {
    Some(c) => Some(column_spec.bind(c))
    None => None
  }
  let (base, optional) = match inner_option(field.ty) {
    Some(inner) => (inner, true)
    None => (field.ty, false)
  }
  let storage = [
    args_str(args, "sqlite"),
    args_str(args, "postgres"),
    args_str(args, "mysql"),
  ]
  if storage.iter().any(s => s is Some(_)) && args_str(args, "kind") is Some(_) {
    fail(
      "\{where_}: \{what}: give `kind`, or `sqlite`, `postgres` and `mysql`, not both",
    )
  }
  let col_type = match args_str(args, "kind") {
    // A call names a column type the program defines, for one it uses on several fields.
    Some(call) if call.has_suffix("()") => call
    Some(name) =>
      match column_named(name) {
        Some(c) => c
        None =>
          fail(
            "\{where_}: \{what}: `kind=\{quoted(name)}` is not a column type; a type of the program's own is `kind=\"its_function()\"`",
          )
      }
    None if storage is [Some(sqlite), Some(postgres), Some(mysql)] =>
      "@moonorm.user_type(sqlite=\{quoted(sqlite)}, postgres=\{quoted(postgres)}, mysql=\{quoted(mysql)})"
    None if storage.iter().any(s => s is Some(_)) =>
      fail(
        "\{where_}: \{what}: a storage type of the program's own names all of `sqlite`, `postgres` and `mysql`",
      )
    None =>
      match column_of(base) {
        Some(c) => c
        None =>
          fail(
            "\{where_}: \{what}: `\{field.ty}` has no default column type; give `#orm.column(kind=...)`",
          )
      }
  }
  let name = args_str(args, "name").unwrap_or(field.name)
  let primary_key = args_flag(args, "primary_key")
  let autoincrement = args_flag(args, "autoincrement")
  if autoincrement {
    if !primary_key {
      fail("\{where_}: \{what}: `autoincrement` needs `primary_key=true`")
    }
    if !optional {
      fail(
        "\{where_}: \{what}: an autoincrement key is `\{field.ty}?`, `None` until the database assigns it",
      )
    }
    guard col_type is ("IntType" | "BigIntType") else {
      fail("\{where_}: \{what}: an autoincrement key is an integer")
    }
  }
  let parts = [quoted(name), col_type, "h => h.\{field.name}"]
  if primary_key {
    parts.push("primary_key=true")
  }
  if autoincrement {
    parts.push("autoincrement=true")
  }
  if !optional {
    parts.push("nullable=false")
  }
  if args_flag(args, "unique") {
    parts.push("unique=true")
  }
  if args_str(args, "default") is Some(d) {
    parts.push("default=\{quoted(d)}")
  }
  let checks = match args {
    Some(a) => a.strs("check")
    None => []
  }
  if !checks.is_empty() {
    parts.push("checks=[\{checks.map(quoted).join(", ")}]")
  }
  match args_str(args, "references") {
    Some(target) => {
      guard target.split(".").map(s => s.to_owned()).collect() is [t, c] &&
        !t.is_empty() &&
        !c.is_empty() else {
        fail("\{where_}: \{what}: `references` is `table.column`")
      }
      parts.push("references=(\{quoted(t)}, \{quoted(c)})")
    }
    None =>
      for key in ["on_delete", "on_update"] {
        if args_str(args, key) is Some(_) {
          fail("\{where_}: \{what}: `\{key}` needs `references`")
        }
      }
  }
  for key in ["on_delete", "on_update"] {
    if args_str(args, key) is Some(action) {
      parts.push("\{key}=\{quoted(action)}")
    }
  }
  let indexed = args_flag(args, "index")
  if indexed && (args_flag(args, "unique") || primary_key) {
    fail(
      "\{where_}: \{what}: a unique or primary key column is indexed already",
    )
  }
  Some((name, "@moonorm.field(\{parts.join(", ")})", indexed))
}

///|
/// What a model holds that another model's relation needs: its column names by
/// field, and the fields of its primary key.
priv struct Shape {
  columns : Map[String, String]
  keys : Array[String]
}

///|
/// The model of one `#orm.table` struct: a private `let` holding it, so every
/// call hands out the same descriptor and a session's identity map keeps its
/// history, and `T::model()` returning it; then its indexes, and a function
/// for each relation.
fn model(
  table : @attribute.Attribute,
  extras : Array[@attribute.Attribute],
  name : String,
  fields : Array[@gen.Field],
  public : Bool,
  file : String,
  shapes : Map[String, Shape],
) -> String raise {
  let where_ = at(table, file)
  let args = table_spec.bind(table)
  let table_name = args.str("name").unwrap_or(snake(name))
  let calls = []
  let reads = []
  let indexes = []
  let names : Map[String, Unit] = Map([])
  for field in fields {
    match column(field, where_, file) {
      Some((column_name, call, indexed)) => {
        if names.contains(column_name) {
          fail("\{where_}: `\{name}` declares column `\{column_name}` twice")
        }
        names.set(column_name, ())
        reads.push("\{field.name}: r.read(\{calls.length()})")
        calls.push(call)
        if indexed {
          indexes.push("@moonorm.index([\{quoted(column_name)}])")
        }
      }
      None => reads.push("\{field.name}: Default::default()")
    }
  }
  if calls.is_empty() {
    fail("\{where_}: `\{name}` declares no column")
  }
  let shape = shapes.get(name).unwrap()
  let vis = if public { "pub " } else { "" }
  let relations = []
  for extra in extras {
    let here = at(extra, file)
    match @attr.name_of(extra) {
      Some("orm.index") => {
        let a = index_spec.bind(extra)
        let columns = a.strs("column")
        if columns.is_empty() {
          fail("\{here}: `#orm.index` names at least one `column`")
        }
        for c in columns {
          if !names.contains(c) {
            fail("\{here}: `\{name}` has no column `\{c}`")
          }
        }
        let parts = ["[\{columns.map(quoted).join(", ")}]"]
        if a.str("name") is Some(n) {
          parts.push("name=\{quoted(n)}")
        }
        if a.bool("unique") is Some(true) {
          parts.push("unique=true")
        }
        indexes.push("@moonorm.index(\{parts.join(", ")})")
      }
      Some("orm.has_many") => {
        let a = has_many_spec.bind(extra)
        let target = a.str("target").unwrap()
        let key = match a.str("key") {
          Some(k) => k
          None =>
            match shape.keys {
              [k] => k
              _ =>
                fail(
                  "\{here}: `\{name}` has no single primary key; give `key=`",
                )
            }
        }
        if !shape.columns.contains(key) {
          fail("\{here}: `\{name}` has no mapped field `\{key}`")
        }
        relations.push(
          (
            $|\{vis}fn \{name}::\{a.str("name").unwrap()}() -> @moonorm.Relation[\{name}, \{target}] {
            $|  @moonorm.has_many(\{target}::model(), \{quoted(a.str("foreign_key").unwrap())}, h => h.\{key})
            $|}
          ),
        )
      }
      Some("orm.belongs_to") => {
        let a = belongs_to_spec.bind(extra)
        let target = a.str("target").unwrap()
        let field = a.str("foreign_key").unwrap()
        if !shape.columns.contains(field) {
          fail("\{here}: `\{name}` has no mapped field `\{field}`")
        }
        let key = match a.str("key") {
          Some(k) => k
          None =>
            match shapes.get(target) {
              Some({ keys: [k], columns, }) => columns.get(k).unwrap()
              Some(_) =>
                fail(
                  "\{here}: `\{target}` has no single primary key; give `key=`",
                )
              None =>
                fail("\{here}: `\{target}` is not annotated here; give `key=`")
            }
        }
        relations.push(
          (
            $|\{vis}fn \{name}::\{a.str("name").unwrap()}() -> @moonorm.Relation[\{name}, \{target}] {
            $|  @moonorm.belongs_to(\{target}::model(), \{quoted(key)}, h => h.\{field})
            $|}
          ),
        )
      }
      _ => ()
    }
  }
  let holder = snake(name) + "_model"
  let index_arg = if indexes.is_empty() {
    ""
  } else {
    "\n  indexes=[\{indexes.join(", ")}],"
  }
  let code =
    $|let \{holder} : @moonorm.Model[\{name}] = @moonorm.Model::from_fields(
    $|  \{quoted(table_name)},
    $|  [\{calls.join(", ")}],
    $|  r => { \{reads.join(", ")} },\{index_arg}
    $|)
    $|
    $|\{vis}fn \{name}::model() -> @moonorm.Model[\{name}] {
    $|  \{holder}
    $|}
  ([code] + relations).join("\n\n")
}

///|
/// The shape of a struct's model as far as relations need it: each mapped
/// field's column name, and the fields of its primary key.
fn shape_of(fields : Array[@gen.Field], file : String) -> Shape raise {
  let columns = Map([])
  let keys = []
  for field in fields {
    let attrs = orm_attrs(field.attrs, file)
    if attrs.iter().any(a => @attr.name_of(a) is Some("orm.skip")) {
      continue
    }
    let mut column_name = field.name
    for a in attrs {
      if @attr.name_of(a) is Some("orm.column") {
        let args = column_spec.bind(a)
        column_name = args.str("name").unwrap_or(field.name)
        if args.bool("primary_key") is Some(true) {
          keys.push(field.name)
        }
      }
    }
    columns[field.name] = column_name
  }
  { columns, keys, }
}

///|
impl @gen.Generator for Orm with fn generate(_, decls) {
  let shapes = Map([])
  for decl in decls {
    if decl is Struct(name~, fields~, attrs~, file~, ..) &&
      orm_attrs(attrs, file)
      .iter()
      .any(a => @attr.name_of(a) is Some("orm.table")) {
      shapes[name] = shape_of(fields, file)
    }
  }
  let models = []
  for decl in decls {
    match decl {
      Struct(name~, fields~, attrs~, public~, file~, ..) => {
        let all = orm_attrs(attrs, file)
        let extras = []
        let found = []
        for attr in all {
          match @attr.name_of(attr) {
            Some("orm.table") => found.push(attr)
            Some("orm.index" | "orm.has_many" | "orm.belongs_to") =>
              extras.push(attr)
            Some(n) =>
              fail(
                "\{at(attr, file)}: `#\{n}` is not a moonorm struct annotation",
              )
            None => ()
          }
        }
        if found.is_empty() && extras is [first, ..] {
          fail("\{at(first, file)}: `\{name}` has no `#orm.table`")
        }
        match found {
          [table] =>
            models.push(
              model(table, extras, name, fields, public, file, shapes),
            )
          [_, second, ..] =>
            fail("\{at(second, file)}: `#orm.table` is written twice")
          [] =>
            for field in fields {
              if orm_attrs(field.attrs, file) is [first, ..] {
                fail(
                  "\{at(first, file)}: `\{name}` has no `#orm.table`, so its fields take no `#orm.*`",
                )
              }
            }
        }
      }
      Enum(attrs~, file~, ..) | Func(attrs~, file~, ..) =>
        if orm_attrs(attrs, file) is [first, ..] {
          fail("\{at(first, file)}: `#orm.table` goes on a struct")
        }
      Other(..) => ()
    }
  }
  @quote.items(models.join("\n\n")).render()
}