// The Kotlin emitter. One `data class` per `type` block with `fromWire`, one
// method per route, and two ports the caller fills in: `Transport` for HTTP and
// `Codec` for JSON. JSON is left to the caller on purpose — a Kotlin project
// already has kotlinx.serialization, Gson or Jackson, and a generated client
// should not force a fourth one into the build.

///|
/// The Kotlin type a MoonBit one maps to.
fn kt_ty(ty : Ty) -> String {
  match ty {
    Opt(t) => kt_ty(t) + "?"
    List(t) => "List<" + kt_ty(t) + ">"
    Dict(k, v) =>
      match k {
        Named(n) =>
          "Map<" +
          (if n == "String" { "String" } else { "Long" }) +
          ", " +
          kt_ty(v) +
          ">"
        _ => "Map"
      }
    Named(n) =>
      match n {
        "String" | "Bytes" => "String"
        "Bool" => "Boolean"
        "Int" | "Int32" | "UInt" | "UInt32" | "Byte" => "Int"
        "Int64" | "UInt64" => "Long"
        "Float" => "Float"
        "Double" => "Double"
        "Json" | "Unit" => "Any?"
        _ => pascal(n)
      }
  }
}

///|
/// The `toInt` / `toLong` / `toDouble` a numeric leaf is read through, or `None`
/// for a leaf that is not a number.
fn kt_num(name : String) -> String? {
  match name {
    "Int" | "Int32" | "UInt" | "UInt32" | "Byte" => Some("Int")
    "Int64" | "UInt64" => Some("Long")
    "Float" => Some("Float")
    "Double" => Some("Double")
    _ => None
  }
}

///|
/// The expression that reads `expr` — an `Any?` out of the codec — as the value of
/// `ty`.
fn kt_decode(ty : Ty, expr : String) -> String {
  match ty {
    Opt(t) =>
      match t {
        List(e) =>
          "(" + expr + " as List?)?.map { " + kt_decode(e, "it") + " }"
        Dict(_, v) =>
          "(" +
          expr +
          " as Map?)?.mapValues { (_, e) -> " +
          kt_decode(v, "e") +
          " }"
        Named(n) =>
          match kt_num(n) {
            Some(c) => "(" + expr + " as Number?)?.to" + c + "()"
            None =>
              if is_scalar(n) {
                "(" + expr + " as " + kt_ty(Named(n)) + "?)"
              } else {
                expr +
                "?.let { " +
                pascal(n) +
                ".fromWire(it as Map) }"
              }
          }
        Opt(_) => kt_decode(t, expr)
      }
    List(t) => "(" + expr + " as List).map { " + kt_decode(t, "it") + " }"
    Dict(_, v) =>
      "(" +
      expr +
      " as Map).mapValues { (_, e) -> " +
      kt_decode(v, "e") +
      " }"
    Named(n) =>
      match kt_num(n) {
        Some(c) => "(" + expr + " as Number).to" + c + "()"
        None =>
          if is_scalar(n) {
            "(" + expr + " as " + kt_ty(Named(n)) + ")"
          } else {
            pascal(n) + ".fromWire(" + expr + " as Map)"
          }
      }
  }
}

///|
/// One model: the properties, the primary constructor, and the `fromWire` a codec's
/// `Map` is read through. `fromWire` lives on the companion so a route can call it
/// without an instance.
fn kt_type(t : @api.TypeDef, types : Array[@api.TypeDef]) -> Array[String] {
  let out : Array[String] = []
  let name = pascal(t.name)
  let fields = @api.flat_fields(t, types)
  out.push("/** One `" + t.name + "` of the spec. */")
  if fields.length() == 0 {
    out.push("class " + name + " {")
    out.push("    companion object {")
    out.push(
      "        fun fromWire(m: Map): " +
      name +
      " = " +
      name +
      "()",
    )
    out.push("    }")
    out.push("}")
    out.push("")
    return out
  }
  out.push("data class " + name + "(")
  for f in fields {
    let decl = field_ty(f)
    out.push(
      "    val " +
      camel(f.json_name()) +
      ": " +
      kt_ty(decl) +
      (if is_nullable(decl) { " = null" } else { "" }) +
      ",",
    )
  }
  out.push(") {")
  out.push("    companion object {")
  out.push(
    "        fun fromWire(m: Map): " + name + " = " + name + "(",
  )
  for f in fields {
    out.push(
      "            " +
      camel(f.json_name()) +
      " = " +
      kt_decode(field_ty(f), "m[\"" + f.json_name() + "\"]") +
      ",",
    )
  }
  out.push("        )")
  out.push("    }")
  out.push("}")
  out.push("")
  out
}

///|
/// The path a method requests, as one Kotlin string concatenation of the literal
/// segments and the escaped parameters between them.
fn kt_path(op : Op) -> String {
  let parts : Array[String] = []
  let mut lit = ""
  for seg in @text.path_segments(op.path) {
    if seg.length() > 0 && seg[0] == ':' {
      if lit != "" {
        parts.push("\"" + lit + "/\"")
        lit = ""
      }
      parts.push("enc(" + camel(seg[1:].to_owned()) + ")")
    } else {
      lit = lit + "/" + seg
    }
  }
  if lit != "" {
    parts.push("\"" + lit + "\"")
  }
  if parts.length() == 0 {
    return "\"/\""
  }
  parts.join(" + ")
}

///|
/// One route as one method. Optional parameters carry `= null`, so a caller writes
/// only the arguments a route needs.
fn kt_method(op : Op) -> Array[String] {
  let out : Array[String] = []
  let params : Array[String] = []
  for (name, ty, required) in ordered_params(op) {
    if name == "body" {
      params.push("body: " + ty)
    } else {
      params.push(
        name +
        ": " +
        kt_ty(if required { parse_ty(ty) } else { opt_of(parse_ty(ty)) }) +
        (if required { "" } else { " = null" }),
      )
    }
  }
  if op.summary != "" {
    out.push("    /** " + op.summary + " */")
  }
  out.push(
    "    fun " +
    op.handler +
    "(" +
    params.join(", ") +
    ")" +
    (if op.resp == "" { "" } else { ": " + op.resp }) +
    " {",
  )
  out.push("        val path = " + kt_path(op))
  if op.query_params.length() > 0 {
    out.push("        val query = mutableListOf()")
    for p in op.query_params {
      if p.optional {
        out.push("        if (" + p.field + " != null) {")
        out.push(
          "            query.add(\"" + p.wire + "=\" + enc(" + p.field + "))",
        )
        out.push("        }")
      } else {
        out.push(
          "        query.add(\"" + p.wire + "=\" + enc(" + p.field + "))",
        )
      }
    }
    out.push(
      "        val url = baseUrl + path + (if (query.isEmpty()) \"\" else \"?\" + query.joinToString(\"&\"))",
    )
  } else {
    out.push("        val url = baseUrl + path")
  }
  out.push(
    "        val headers = linkedMapOf(\"accept\" to \"application/json\")",
  )
  out.push(
    "        token?.let { headers[\"authorization\"] = \"Bearer \" + it }",
  )
  for p in op.header_params {
    if p.optional {
      out.push(
        "        " +
        p.field +
        "?.let { headers[\"" +
        p.wire +
        "\"] = it.toString() }",
      )
    } else {
      out.push(
        "        headers[\"" + p.wire + "\"] = " + p.field + ".toString()",
      )
    }
  }
  let body = if op.has_body {
    out.push("        headers[\"content-type\"] = \"application/json\"")
    "codec.encode(body)"
  } else {
    "null"
  }
  out.push(
    "        val res = transport.send(\"" +
    op.verb +
    "\", url, headers, " +
    body +
    ")",
  )
  out.push("        if (!res.ok) {")
  out.push("            throw ApiException(res.status, res.body)")
  out.push("        }")
  if op.resp != "" {
    out.push("        return " + op.resp + ".fromWire(codec.decode(res.body))")
  }
  out.push("    }")
  out.push("")
  out
}

///|
/// The whole file: the exception, the two ports, the models, then the client.
fn emit_kt(m : Model) -> String {
  let out : Array[String] = []
  out.push(
    "// Code generated by `mctl api kt` from a `.api` spec. DO NOT EDIT.",
  )
  out.push("//")
  out.push(
    "// " +
    m.service +
    " — a typed client for the MoonAPI routes of the spec.",
  )
  out.push("//")
  out.push(
    "// One route is one method: `path:` fields become parameters, `form:` fields",
  )
  out.push(
    "// become query parameters, `header:` fields become headers, and the `json:`",
  )
  out.push("// fields of the request type become the body.")
  out.push("//")
  out.push(
    "// Two ports, both the caller's: `Transport` sends a request, `Codec` turns a",
  )
  out.push(
    "// value into JSON and back. Back the codec with kotlinx.serialization, Gson or",
  )
  out.push(
    "// Jackson — a generated client forces no JSON library into your build.",
  )
  out.push("")
  out.push("import java.net.URLEncoder")
  out.push("")
  out.push("/** An answer the service did not send with a 2xx status. */")
  out.push(
    "class ApiException(val status: Int, val body: String) : RuntimeException(\"HTTP $status\")",
  )
  out.push("")
  out.push("/** One raw answer: the status and the text of the body. */")
  out.push("data class RawResponse(val status: Int, val body: String) {")
  out.push("    val ok: Boolean get() = status in 200..299")
  out.push("}")
  out.push("")
  out.push(
    "/** The transport port: send one request, get the raw answer back. */",
  )
  out.push("fun interface Transport {")
  out.push(
    "    fun send(method: String, url: String, headers: Map, body: String?): RawResponse",
  )
  out.push("}")
  out.push("")
  out.push("/** The JSON port: encode a value, decode an object. */")
  out.push("interface Codec {")
  out.push("    fun encode(value: Any?): String")
  out.push("    fun decode(text: String): Map")
  out.push("}")
  out.push("")
  out.push("/** One URL segment, escaped for a path or a query string. */")
  out.push("internal fun enc(value: Any?): String =")
  out.push(
    "    URLEncoder.encode(value.toString(), \"UTF-8\").replace(\"+\", \"%20\")",
  )
  out.push("")
  for t in m.types {
    for line in kt_type(t, m.types) {
      out.push(line)
    }
  }
  out.push("class " + m.class_name + "(")
  out.push("    baseUrl: String,")
  out.push("    private val transport: Transport,")
  out.push("    private val codec: Codec,")
  out.push("    var token: String? = null,")
  out.push(") {")
  out.push("    /** Base URL of the service; a trailing slash is dropped. */")
  out.push("    val baseUrl: String = baseUrl.trimEnd('/')")
  out.push("")
  for op in m.ops {
    for line in kt_method(op) {
      out.push(line)
    }
  }
  out.push("}")
  @text.join_lines(out)
}