///|
/// Target OpenAPI / Swagger document version. mctl emits every mainstream version
/// from one `.api` spec — the same shape moonapi's runtime emitter takes, so a
/// generated service and a hand-built moonapi app document the same way.
pub(all) enum DocVersion {
  Swagger20
  OpenApi30
  OpenApi31
} derive(Eq)

///|
/// (root document key, version string) for a doc version.
fn doc_version_field(v : DocVersion) -> (String, String) {
  match v {
    Swagger20 => ("swagger", "2.0")
    OpenApi30 => ("openapi", "3.0.3")
    OpenApi31 => ("openapi", "3.1.0")
  }
}

///|
/// Split a URL path on `/`, dropping empty segments (so `/users/:id` → `["users",
/// ":id"]`).
fn path_segments(path : String) -> Array[String] {
  let out : Array[String] = []
  let n = path.length()
  let mut start = 0
  for i = 0; i <= n; i = i + 1 {
    if i == n || path[i] == '/' {
      if i > start {
        out.push(path[start:i].to_owned())
      }
      start = i + 1
    }
  }
  out
}

///|
/// Rewrite an `.api` route path into OpenAPI form: a `:name` segment becomes
/// `{name}`, the notation both Swagger 2.0 and OpenAPI 3.x expect.
fn openapi_path(path : String) -> String {
  let mut out = "/"
  let segs = path_segments(path)
  for i = 0; i < segs.length(); i = i + 1 {
    if i > 0 {
      out = out + "/"
    }
    let seg = segs[i]
    if seg.length() > 0 && seg[0] == ':' {
      out = out + "{" + seg[1:].to_owned() + "}"
    } else {
      out = out + seg
    }
  }
  out
}

///|
/// The top-level `", "` in a `Map[K, V]` type spelling, tracking `[` `]` depth so a
/// nested `Map[Int, Array[String]]` splits on its own comma, not an inner one.
/// Returns the index of the comma, or `-1` if there is none at depth 0.
fn top_comma(s : String) -> Int {
  let n = s.length()
  let mut depth = 0
  for i = 0; i < n; 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
}

///|
/// The `$ref` prefix for a component schema: `#/definitions/` in Swagger 2.0,
/// `#/components/schemas/` in OpenAPI 3.x.
fn ref_prefix(v : DocVersion) -> String {
  match v {
    Swagger20 => "#/definitions/"
    _ => "#/components/schemas/"
  }
}

///|
/// The JSON Schema for a MoonBit-spelled field type. Scalars carry an OpenAPI
/// `format` where one applies (`int64`, `double`, …); `Array[T]` becomes an array
/// with typed `items`; `Map[K, V]` an object with typed `additionalProperties`; an
/// unrecognised name is treated as another `type` block and emitted as a `$ref`.
fn type_schema(type_ : String, v : DocVersion) -> Json {
  let t = trim(type_)
  if starts_with(t, "Array[") && t[t.length() - 1] == ']' {
    let inner = t[6:t.length() - 1].to_owned()
    let schema : Map[String, Json] = Map([
      ("type", "array".to_json()),
      ("items", type_schema(inner, v)),
    ])
    return schema.to_json()
  }
  if starts_with(t, "Map[") && t[t.length() - 1] == ']' {
    let body = t[4:t.length() - 1].to_owned()
    let comma = top_comma(body)
    let value = if comma >= 0 {
      trim(body[comma + 1:].to_owned())
    } else {
      "String"
    }
    let schema : Map[String, Json] = Map([
      ("type", "object".to_json()),
      ("additionalProperties", type_schema(value, v)),
    ])
    return schema.to_json()
  }
  match t {
    "String" =>
      (Map([("type", "string".to_json())]) : Map[String, Json]).to_json()
    "Bool" =>
      (Map([("type", "boolean".to_json())]) : Map[String, Json]).to_json()
    "Int" | "UInt" =>
      (
        Map([("type", "integer".to_json()), ("format", "int32".to_json())]) :
        Map[String, Json]).to_json()
    "Int64" | "UInt64" =>
      (
        Map([("type", "integer".to_json()), ("format", "int64".to_json())]) :
        Map[String, Json]).to_json()
    "Double" =>
      (
        Map([("type", "number".to_json()), ("format", "double".to_json())]) :
        Map[String, Json]).to_json()
    "Float" =>
      (
        Map([("type", "number".to_json()), ("format", "float".to_json())]) :
        Map[String, Json]).to_json()
    "Byte" | "Bytes" =>
      (Map([("type", "string".to_json())]) : Map[String, Json]).to_json()
    _ =>
      (Map([("$ref", (ref_prefix(v) + t).to_json())]) : Map[String, Json]).to_json()
  }
}

///|
/// An OpenAPI parameter object. Swagger 2.0 puts the type inline; OpenAPI 3.x nests
/// it under `schema` — the one structural difference between the two.
fn param_object(
  name : String,
  in_ : String,
  required : Bool,
  schema : Json,
  v : DocVersion,
) -> Json {
  let param : Map[String, Json] = Map([
    ("name", name.to_json()),
    ("in", in_.to_json()),
    ("required", required.to_json()),
  ])
  match v {
    Swagger20 =>
      match schema {
        Object(m) =>
          for kv in m {
            param[kv.0] = kv.1
          }
        _ => param["type"] = "string".to_json()
      }
    _ => param["schema"] = schema
  }
  param.to_json()
}

///|
/// A `:name` path parameter as an OpenAPI parameter object.
fn path_param(name : String, v : DocVersion) -> Json {
  param_object(
    name,
    "path",
    true,
    (Map([("type", "string".to_json())]) : Map[String, Json]).to_json(),
    v,
  )
}

///|
/// Where a non-body field appears in an OpenAPI operation.
fn bind_in(b : Bind) -> String {
  match b {
    Query => "query"
    Path => "path"
    Header => "header"
    Body => "body"
  }
}

///|
/// The parameters a request type declares through its tags: a `form:` field is a
/// query parameter, a `path:` field a path parameter under the name its tag gives,
/// a `header:` field a header. A path parameter is always required; the others
/// follow the field's `optional`.
fn type_params(
  t : TypeDef,
  types : Array[TypeDef],
  v : DocVersion,
) -> Array[Json] {
  let out : Array[Json] = []
  for f in flat_fields(t, types) {
    let b = f.bind()
    if b == Body {
      continue
    }
    out.push(
      param_object(
        f.json_name(),
        bind_in(b),
        b == Path || f.optional() == false,
        field_schema(f, v),
        v,
      ),
    )
  }
  out
}

///|
/// The parameters of a route: the ones its request type's tags declare, then a path
/// parameter for every `:name` segment the type did not already name.
fn route_params(
  r : Route,
  types : Array[TypeDef],
  v : DocVersion,
) -> Array[Json] {
  let out : Array[Json] = []
  let named : Array[String] = []
  match find_type(types, r.req) {
    Some(t) => {
      for f in flat_fields(t, types) {
        if f.bind() != Body {
          named.push(f.json_name())
        }
      }
      for p in type_params(t, types, v) {
        out.push(p)
      }
    }
    None => ()
  }
  for seg in path_segments(r.path) {
    if seg.length() > 0 && seg[0] == ':' {
      let name = seg[1:].to_owned()
      if named.contains(name) == false {
        out.push(path_param(name, v))
      }
    }
  }
  out
}

///|
/// The JSON literal of a `default=` / `options=` value, typed by the field it
/// constrains: a number for a numeric field, a boolean for a `Bool`, a string
/// otherwise.
fn literal_json(type_ : String, raw : String) -> Json {
  match type_ {
    "Bool" => if raw == "true" { true.to_json() } else { false.to_json() }
    "Int" | "Int64" | "UInt" | "UInt64" | "Double" | "Float" => number_json(raw)
    _ => raw.to_json()
  }
}

///|
/// The schema of one field: its type, plus whatever its tag constrained — the
/// `default=`, the `options=` as an `enum`, and the `range=` as bounds. 3.1 spells
/// an exclusive bound as the number itself, 2.0 and 3.0 as a flag beside an
/// inclusive one.
fn field_schema(f : Field, v : DocVersion) -> Json {
  let m = match type_schema(f.type_, v) {
    Object(m) => m
    other => return other
  }
  // A `$ref` may not be qualified by siblings, so a nested message carries nothing.
  if m.contains("$ref") {
    return m.to_json()
  }
  match f.default_() {
    Some(d) => m["default"] = literal_json(f.type_, d)
    None => ()
  }
  let allowed = f.options()
  if allowed.length() > 0 {
    let vals : Array[Json] = []
    for one in allowed {
      vals.push(literal_json(f.type_, one))
    }
    m["enum"] = vals.to_json()
  }
  match f.range() {
    Some(r) => {
      if r.lo != "" {
        let lo = literal_json(f.type_, r.lo)
        if r.lo_inc {
          m["minimum"] = lo
        } else {
          match v {
            OpenApi31 => m["exclusiveMinimum"] = lo
            _ => {
              m["minimum"] = lo
              m["exclusiveMinimum"] = true.to_json()
            }
          }
        }
      }
      if r.hi != "" {
        let hi = literal_json(f.type_, r.hi)
        if r.hi_inc {
          m["maximum"] = hi
        } else {
          match v {
            OpenApi31 => m["exclusiveMaximum"] = hi
            _ => {
              m["maximum"] = hi
              m["exclusiveMaximum"] = true.to_json()
            }
          }
        }
      }
    }
    None => ()
  }
  m.to_json()
}

///|
/// Whether a request type has anything left for the body once its `path:`, `form:`
/// and `header:` fields have gone to the parameters. A type the spec never declared
/// counts as a body, since nothing here can say otherwise.
fn has_body(t : TypeDef?, types : Array[TypeDef]) -> Bool {
  match t {
    None => true
    Some(td) => {
      for f in flat_fields(td, types) {
        if f.bind() == Body {
          return true
        }
      }
      false
    }
  }
}

///|
/// The component schema of a `type` block: an object of its body-bound properties,
/// keyed by wire name, listing the ones no tag marked `optional` as `required`. A
/// block that inlined others is their `allOf` — the shape an embedded Go struct
/// serialises to. `path:`, `form:` and `header:` fields are not in the body, so they
/// are documented as parameters instead of properties.
fn component_schema(t : TypeDef, v : DocVersion) -> Json {
  let props : Map[String, Json] = Map([])
  let required : Array[Json] = []
  for f in t.fields {
    if f.bind() != Body {
      continue
    }
    props[f.json_name()] = field_schema(f, v)
    if f.optional() == false {
      required.push(f.json_name().to_json())
    }
  }
  let own : Map[String, Json] = Map([
    ("type", "object".to_json()),
    ("properties", props.to_json()),
  ])
  if required.length() > 0 {
    own["required"] = required.to_json()
  }
  if t.embeds.length() == 0 {
    return own.to_json()
  }
  let all : Array[Json] = []
  for name in t.embeds {
    all.push(
      (Map([("$ref", (ref_prefix(v) + name).to_json())]) : Map[String, Json]).to_json(),
    )
  }
  if props.length() > 0 {
    all.push(own.to_json())
  }
  (Map([("allOf", all.to_json())]) : Map[String, Json]).to_json()
}

///|
/// The value of an `info(...)` member (← goctl's `info` block), or `None` if the
/// spec declared no such key.
fn info_get(spec : Spec, key : String) -> String? {
  for kv in spec.info {
    if kv.0 == key {
      return Some(kv.1)
    }
  }
  None
}

///|
/// Build the OpenAPI / Swagger document for `spec` as a `Json` value. Routes fold
/// into `paths` → HTTP method → operation (with `operationId`, any `:name` path
/// parameters, and a 200 response); every `type` block becomes a component schema
/// (`definitions` in 2.0, `components/schemas` in 3.x). `version` selects the
/// document dialect; `title`/`api_version` fill the `info` block.
pub fn openapi_document(
  spec : Spec,
  version? : DocVersion = OpenApi31,
  title? : String = "",
  api_version? : String = "0.1.0",
) -> Json {
  let doc_title = if title != "" {
    title
  } else {
    match info_get(spec, "title") {
      Some(t) => t
      None => spec.service
    }
  }
  let paths : Map[String, Json] = Map([])
  for r in spec.routes {
    let op : Map[String, Json] = Map([("operationId", r.handler.to_json())])
    if r.summary != "" {
      op["summary"] = r.summary.to_json()
    }
    // A route's `@server` group is its OpenAPI tag — the same grouping goctl gives
    // handlers, and what a doc UI sections the operations by.
    match r.group {
      Some(g) => if g.name != "" { op["tags"] = [g.name.to_json()].to_json() }
      None => ()
    }
    let params = route_params(r, spec.types, version)
    // A modern route's `(Req)` becomes a typed request body (a 2.0 `in: body`
    // parameter / a 3.x `requestBody`); its `returns (Resp)` types the 200 response.
    // A request type whose fields all bind somewhere else — a query-only filter —
    // has no body to document.
    if r.req != "" && has_body(find_type(spec.types, r.req), spec.types) {
      let schema : Map[String, Json] = Map([
        ("$ref", (ref_prefix(version) + r.req).to_json()),
      ])
      match version {
        Swagger20 =>
          params.push(
            (
              Map([
                ("in", "body".to_json()),
                ("name", "body".to_json()),
                ("required", true.to_json()),
                ("schema", schema.to_json()),
              ]) : Map[String, Json]).to_json(),
          )
        _ => {
          let media : Map[String, Json] = Map([("schema", schema.to_json())])
          op["requestBody"] = (
            Map([
              ("required", true.to_json()),
              (
                "content",
                (
                  Map([("application/json", media.to_json())]) :
                  Map[String, Json]).to_json(),
              ),
            ]) : Map[String, Json]).to_json()
        }
      }
    }
    if params.length() > 0 {
      op["parameters"] = params.to_json()
    }
    let ok : Map[String, Json] = Map([("description", "OK".to_json())])
    if r.resp != "" {
      let schema : Map[String, Json] = Map([
        ("$ref", (ref_prefix(version) + r.resp).to_json()),
      ])
      match version {
        Swagger20 => ok["schema"] = schema.to_json()
        _ => {
          let media : Map[String, Json] = Map([("schema", schema.to_json())])
          ok["content"] = (
            Map([("application/json", media.to_json())]) : Map[String, Json]).to_json()
        }
      }
    }
    op["responses"] = (Map([("200", ok.to_json())]) : Map[String, Json]).to_json()
    let key = openapi_path(r.path)
    let item = match paths.get(key) {
      Some(Object(m)) => m
      _ => Map([])
    }
    item[r.verb.to_lower()] = op.to_json()
    paths[key] = item.to_json()
  }
  let defs : Map[String, Json] = Map([])
  for t in spec.types {
    defs[t.name] = component_schema(t, version)
  }
  let doc_version = match info_get(spec, "version") {
    Some(v) => v
    None => api_version
  }
  let info : Map[String, Json] = Map([
    ("title", doc_title.to_json()),
    ("version", doc_version.to_json()),
  ])
  match info_get(spec, "desc") {
    Some(d) => info["description"] = d.to_json()
    None =>
      match info_get(spec, "description") {
        Some(d) => info["description"] = d.to_json()
        None => ()
      }
  }
  let (root_key, version_str) = doc_version_field(version)
  let doc : Map[String, Json] = Map([
    (root_key, version_str.to_json()),
    ("info", info.to_json()),
    ("paths", paths.to_json()),
  ])
  let tags : Array[Json] = []
  for g in spec.groups {
    if g.name != "" {
      tags.push(
        (Map([("name", g.name.to_json())]) : Map[String, Json]).to_json(),
      )
    }
  }
  if tags.length() > 0 {
    doc["tags"] = tags.to_json()
  }
  if not_empty(defs) {
    match version {
      Swagger20 => doc["definitions"] = defs.to_json()
      _ =>
        doc["components"] = (
          Map([("schemas", defs.to_json())]) : Map[String, Json]).to_json()
    }
  }
  doc.to_json()
}

///|
/// Whether a `Map` has any entries. (`Map::is_empty` needs `Eq` on the value; a
/// direct length check does not.)
fn not_empty(m : Map[String, Json]) -> Bool {
  m.length() > 0
}

///|
/// Generate an OpenAPI / Swagger document from `spec`, stringified with two-space
/// indentation. `version` picks the dialect (Swagger 2.0 / OpenAPI 3.0 / 3.1).
pub fn generate_doc(
  spec : Spec,
  version? : DocVersion = OpenApi31,
  title? : String = "",
  api_version? : String = "0.1.0",
) -> String {
  openapi_document(spec, version~, title~, api_version~).stringify(indent=2)
}

///|
/// A self-contained Swagger UI page for the document served at `spec_url`. The
/// same stub moonapi ships, so a generated service and a live app render alike.
pub fn swagger_ui_stub(
  spec_url? : String = "/openapi.json",
  title? : String = "API Docs",
) -> String {
  "\n\n\n\n" +
  title +
  "\n\n\n\n
\n\n\n\n\n" }