///|
/// A JSON-Schema type descriptor — the runtime, first-class value that stands in
/// for FastAPI's from-signature reflection. One `Schema` tree is *walked once* to
/// (a) emit a complete OpenAPI request/response body schema — objects, arrays and
/// scalars, with `required`, and named objects hoisted under `components/schemas`
/// and referenced by `$ref` — and (b) drive validation of an inbound JSON value.
/// This is the explicit, MoonBit-idiomatic equivalent of pydantic's type-driven
/// magic (cf. Rust `serde` + macros, Go struct tags + codegen). A named object
/// carries its fields *inline*, so the same tree is fully self-describing for
/// validation; the name is used only to deduplicate it into `components` on emit.
pub(all) enum Schema {
  SStr
  SInt
  SFloat
  SBool
  SNull
  SArray(Schema)
  SObject(ObjectSchema)
  // A closed value set (← a Python `Enum` / `typing.Literal`): the base scalar
  // whose `type` is still emitted, plus the allowed values as raw `Json`.
  SEnum(Schema, Array[Json])
  // A value that may also be null (← `Optional[T]` / `T | None`). Each dialect
  // says it differently, which is why it is a wrapper rather than a flag.
  SNullable(Schema)
  // An object with no declared fields whose values all conform to one schema
  // (← `dict[str, T]`), emitted as `additionalProperties`. `SMap(SAny)` is the
  // free-form object a bare `dict` maps to.
  SMap(Schema)
  // Any JSON value at all (← `Any`), emitted as the empty schema, which is the
  // JSON-Schema way of saying "no constraint".
  SAny
  // A schema refined by an OpenAPI `format`. `format: binary` is what an upload
  // is described as, and the reason this exists.
  SFormat(Schema, String)
} derive(Eq)

///|
/// The body of an object schema: its component `name` (empty = an anonymous
/// inline object; non-empty = hoisted to `components/schemas` and referenced),
/// its ordered `fields`, and an optional `description`.
pub(all) struct ObjectSchema {
  name : String
  fields : Array[Field]
  description : String
} derive(Eq)

///|
/// One field of an object schema: its `name`, its `schema`, whether it is
/// `required`, an optional `description`, its value `constraints`, and the
/// `default` that stands in when a body leaves it out.
pub(all) struct Field {
  name : String
  schema : Schema
  required : Bool
  description : String
  constraints : Array[Constraint]
  default : Json?
} derive(Eq)

///|
/// Build a field. `required` defaults to `true` (FastAPI treats a field without
/// a default as required). `constraints` are the Pydantic-style value constraints
/// (`ge`/`le`/`min_length`/`pattern`/…) that are both emitted into the field's
/// OpenAPI schema and enforced when validating an inbound value. `default` is
/// emitted into the schema and makes the field optional, since a value that has
/// one is never missing.
pub fn Field::new(
  name : String,
  schema : Schema,
  required? : Bool = true,
  description? : String = "",
  constraints? : Array[Constraint] = [],
  default? : Json,
) -> Field {
  { name, schema, required, description, constraints, default, }
}

///|
/// Whether a body must carry this field. A field with a default never must — its
/// absence is answered by the default, exactly as a Python default argument is,
/// so it is left out of the emitted `required` list and not reported missing.
pub fn Field::is_required(self : Field) -> Bool {
  self.required && self.default is None
}

///|
/// A named object schema: hoisted to `components/schemas` under `name` and
/// referenced by `$ref` wherever it is used.
pub fn Schema::object(name : String, fields : Array[Field]) -> Schema {
  SObject({ name, fields, description: "", })
}

///|
/// An array schema whose elements all conform to `item`.
pub fn Schema::array(item : Schema) -> Schema {
  SArray(item)
}

///|
/// A schema that also admits `null` (← `Optional[T]`). Wrapping rather than a
/// flag because the three dialects express it three different ways.
pub fn Schema::nullable(inner : Schema) -> Schema {
  SNullable(inner)
}

///|
/// An object with no declared fields whose values all conform to `value`
/// (← `dict[str, T]`). `Schema::map(SAny)` is the free-form object.
pub fn Schema::map(value : Schema) -> Schema {
  SMap(value)
}

///|
/// `base` refined by an OpenAPI `format`. The format is documentation: it tells
/// a reader and a client generator what the string holds, and validation still
/// checks only `base`, which is what pydantic does for a format it has no
/// validator for.
pub fn Schema::format(base : Schema, format : String) -> Schema {
  SFormat(base, format)
}

///|
/// The schema of file content: a string with OpenAPI's `binary` format — what an
/// upload is described as, and what a multipart file field carries.
pub fn Schema::binary() -> Schema {
  SFormat(SStr, "binary")
}

///|
/// The `impl`-able version of a type's descriptor. A user struct implements it —
/// the value is ignored; it exists so descriptor-carrying code can be generic
/// over "a type that knows its own schema". The primary, mctl-friendly shape is
/// still a plain associated function `T::schema() -> Schema` (no instance needed,
/// mirroring FastAPI referencing the model *class*); this trait bridges to it.
pub(open) trait ToSchema {
  fn to_schema(Self) -> Schema
}

///|
/// The schema of any `ToSchema` type, without needing a value of it materialised
/// at the call site beyond the one handed in — the generic entry point.
pub fn[T : ToSchema] schema_of(x : T) -> Schema {
  ToSchema::to_schema(x)
}

// -- emission (descriptor tree -> OpenAPI JSON) -------------------------------

///|
/// Where `$ref`s point, which differs between spec families: OpenAPI 3.x hoists
/// schemas under `#/components/schemas/`, Swagger 2.0 under `#/definitions/`.
fn ref_base(v : OpenApiVersion) -> String {
  match v {
    Swagger20 => "#/definitions/"
    _ => "#/components/schemas/"
  }
}

///|
/// A bare `{"type": t}` scalar schema object.
fn scalar_schema(t : String) -> Json {
  let m : Map[String, Json] = Map([("type", t.to_json())])
  m.to_json()
}

///|
/// The scalar OpenAPI type name of a schema, `None` for composite schemas. A
/// format or a nullable wrapper does not change what the underlying value is, so
/// both report their base — which is what lets a Swagger 2.0 parameter, whose
/// type sits on the parameter object itself, carry them at all.
fn scalar_name(s : Schema) -> String? {
  match s {
    SStr => Some("string")
    SInt => Some("integer")
    SFloat => Some("number")
    SBool => Some("boolean")
    SNull => Some("null")
    SFormat(base, _) => scalar_name(base)
    SNullable(inner) => scalar_name(inner)
    _ => None
  }
}

///|
/// Return `j` with `key` set to `value`. Builds a fresh object so nothing shared
/// mutates; a non-object is returned untouched, since there is nothing to set on
/// it.
fn with_key(j : Json, key : String, value : Json) -> Json {
  match j {
    Object(m) => {
      let out : Map[String, Json] = Map([])
      for k, v in m {
        out[k] = v
      }
      out[key] = value
      out.to_json()
    }
    _ => j
  }
}

///|
/// Return `j` with a `description` sibling added (legal beside `$ref` in OpenAPI
/// 3.1 / JSON-Schema 2020-12).
fn with_description(j : Json, desc : String) -> Json {
  with_key(j, "description", desc.to_json())
}

///|
/// Copy every key of the JSON object `src` into `dst`, leaving `dst`'s other
/// keys alone. A non-object `src` contributes nothing.
fn merge_into(dst : Map[String, Json], src : Json) -> Unit {
  if src is Object(o) {
    for k, v in o {
      dst[k] = v
    }
  }
}

///|
/// Emit `schema` as inline OpenAPI JSON, hoisting every *named* object into
/// `defs` (keyed by name) and referencing it by `$ref`. Recursion-safe: a name
/// is reserved in `defs` before its body is built, so a self-referential schema
/// terminates instead of looping.
fn emit_schema(
  schema : Schema,
  defs : Map[String, Json],
  version : OpenApiVersion,
) -> Json {
  match schema {
    SStr => scalar_schema("string")
    SInt => scalar_schema("integer")
    SFloat => scalar_schema("number")
    SBool => scalar_schema("boolean")
    SNull => scalar_schema("null")
    SArray(item) => {
      let m : Map[String, Json] = Map([
        ("type", "array".to_json()),
        ("items", emit_schema(item, defs, version)),
      ])
      m.to_json()
    }
    SObject(os) =>
      if os.name != "" {
        register_object(os, defs, version)
        let r : Map[String, Json] = Map([
          ("$ref", (ref_base(version) + os.name).to_json()),
        ])
        r.to_json()
      } else {
        object_body(os, defs, version)
      }
    SEnum(base, values) =>
      match emit_schema(base, defs, version) {
        Object(o) => {
          let m : Map[String, Json] = Map([])
          for k, v in o {
            m[k] = v
          }
          m["enum"] = values.to_json()
          m.to_json()
        }
        other => other
      }
    SNullable(inner) =>
      nullable_json(emit_schema(inner, defs, version), version)
    SMap(value) => {
      let m : Map[String, Json] = Map([
        ("type", "object".to_json()),
        ("additionalProperties", emit_schema(value, defs, version)),
      ])
      m.to_json()
    }
    // The empty schema — JSON-Schema for "anything at all".
    SAny => {
      let m : Map[String, Json] = Map([])
      m.to_json()
    }
    SFormat(base, format) =>
      with_key(emit_schema(base, defs, version), "format", format.to_json())
  }
}

///|
/// `j` widened to admit `null`, the way `version` says it. Swagger 2.0 cannot say
/// it in the specification proper and uses the `x-nullable` extension its tooling
/// reads; 3.0 has a `nullable` flag; 3.1 is JSON-Schema 2020-12, where the type
/// itself becomes a list — and where a `$ref` or a composite, having no `type` to
/// extend, has to be widened with an `anyOf` instead.
fn nullable_json(j : Json, version : OpenApiVersion) -> Json {
  match version {
    Swagger20 => with_key(j, "x-nullable", true.to_json())
    OpenApi30 => with_key(j, "nullable", true.to_json())
    OpenApi31 =>
      if j is Object(o) && o.get("type") is Some(String(t)) {
        with_key(j, "type", [t.to_json(), "null".to_json()].to_json())
      } else {
        let null_type : Map[String, Json] = Map([("type", "null".to_json())])
        let m : Map[String, Json] = Map([
          ("anyOf", [j, null_type.to_json()].to_json()),
        ])
        m.to_json()
      }
  }
}

///|
/// Register a named object into `defs` (once), reserving its slot first so that
/// cycles through its fields resolve to the already-in-progress `$ref`.
fn register_object(
  os : ObjectSchema,
  defs : Map[String, Json],
  version : OpenApiVersion,
) -> Unit {
  match defs.get(os.name) {
    Some(_) => ()
    None => {
      defs[os.name] = Json::null()
      defs[os.name] = object_body(os, defs, version)
    }
  }
}

///|
/// The `{"type":"object","properties":{...},"required":[...]}` body of an object
/// schema, recursively emitting each field's schema (which may hoist further
/// named objects into `defs`).
fn object_body(
  os : ObjectSchema,
  defs : Map[String, Json],
  version : OpenApiVersion,
) -> Json {
  let props : Map[String, Json] = Map([])
  let required : Array[Json] = []
  for f in os.fields {
    let mut fs = with_constraints(
      emit_schema(f.schema, defs, version),
      f.constraints,
      version,
    )
    if f.default is Some(d) {
      fs = with_key(fs, "default", d)
    }
    props[f.name] = if f.description != "" {
      with_description(fs, f.description)
    } else {
      fs
    }
    if f.is_required() {
      required.push(f.name.to_json())
    }
  }
  let m : Map[String, Json] = Map([
    ("type", "object".to_json()),
    ("properties", props.to_json()),
  ])
  if required.length() > 0 {
    m["required"] = required.to_json()
  }
  if os.description != "" {
    m["description"] = os.description.to_json()
  }
  m.to_json()
}