///|
/// 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])
} 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`, and an optional `description`.
pub(all) struct Field {
  name : String
  schema : Schema
  required : Bool
  description : String
  constraints : Array[Constraint]
} 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.
pub fn Field::new(
  name : String,
  schema : Schema,
  required? : Bool = true,
  description? : String = "",
  constraints? : Array[Constraint] = [],
) -> Field {
  { name, schema, required, description, constraints, }
}

///|
/// 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)
}

///|
/// 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.
fn scalar_name(s : Schema) -> String? {
  match s {
    SStr => Some("string")
    SInt => Some("integer")
    SFloat => Some("number")
    SBool => Some("boolean")
    SNull => Some("null")
    _ => None
  }
}

///|
/// Return `j` with a `description` sibling added (legal beside `$ref` in OpenAPI
/// 3.1 / JSON-Schema 2020-12). Builds a fresh object so nothing shared mutates.
fn with_description(j : Json, desc : String) -> Json {
  match j {
    Object(m) => {
      let out : Map[String, Json] = Map([])
      for k, v in m {
        out[k] = v
      }
      out["description"] = desc.to_json()
      out.to_json()
    }
    _ => j
  }
}

///|
/// 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
      }
  }
}

///|
/// 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 fs = with_constraints(
      emit_schema(f.schema, defs, version),
      f.constraints,
      version,
    )
    props[f.name] = if f.description != "" {
      with_description(fs, f.description)
    } else {
      fs
    }
    if f.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()
}