// The Dart emitter (goctl leaves Dart to plugins). One class per `type` block with
// `fromJson` / `toJson`, one `Future`-returning method per route. Transport is a
// `Send` port, so the file imports only `dart:convert` and a generated client
// compiles with `dart analyze` without a single `pubspec.yaml` dependency.
//
// Everything Dart-side is written with single quotes, which MoonBit's double-quoted
// strings carry unchanged — only `"` would need escaping, and none is used.

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

///|
/// The expression that reads `expr` — a `dynamic` out of `jsonDecode` — as the
/// value of `ty`, casting through `num` where Dart separates the numeric types and
/// recursing into lists and maps.
fn dart_decode(ty : Ty, expr : String) -> String {
  match ty {
    Opt(t) => "(" + expr + " == null ? null : " + dart_decode(t, expr) + ")"
    List(t) =>
      "(" +
      expr +
      " as List).map((e) => " +
      dart_decode(t, "e") +
      ").toList()"
    Dict(_, v) =>
      "(" +
      expr +
      " as Map).map((k, e) => MapEntry(k, " +
      dart_decode(v, "e") +
      "))"
    Named(n) =>
      match n {
        "String" | "Bytes" => expr + " as String"
        "Bool" => expr + " as bool"
        "Int" | "Int32" | "UInt" | "UInt32" | "Byte" =>
          "(" + expr + " as num).toInt()"
        "Int64" | "UInt64" => "(" + expr + " as num).toInt()"
        "Float" | "Double" => "(" + expr + " as num).toDouble()"
        "Json" | "Unit" => expr
        _ => pascal(n) + ".fromJson(" + expr + " as Map)"
      }
  }
}

///|
/// The expression that makes `expr` JSON-encodable: a model goes through its
/// `toJson`, and only the types that actually hold one are walked.
fn dart_encode(ty : Ty, expr : String) -> String {
  match ty {
    Opt(t) =>
      match t {
        List(e) =>
          if mentions_named(e) {
            expr + "?.map((e) => " + dart_encode(e, "e") + ").toList()"
          } else {
            expr
          }
        Dict(_, v) =>
          if mentions_named(v) {
            expr + "?.map((k, e) => MapEntry(k, " + dart_encode(v, "e") + "))"
          } else {
            expr
          }
        Named(n) => if is_scalar(n) { expr } else { expr + "?.toJson()" }
        _ => expr
      }
    List(t) =>
      if mentions_named(t) {
        expr + ".map((e) => " + dart_encode(t, "e") + ").toList()"
      } else {
        expr
      }
    Dict(_, v) =>
      if mentions_named(v) {
        expr + ".map((k, e) => MapEntry(k, " + dart_encode(v, "e") + "))"
      } else {
        expr
      }
    Named(n) => if is_scalar(n) { expr } else { expr + ".toJson()" }
  }
}

///|
/// One model class: its fields, a constructor naming every one of them, and the
/// pair of methods that move it across the wire.
fn dart_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.")
  out.push("class " + name + " {")
  for f in fields {
    out.push(
      "  final " + dart_ty(field_ty(f)) + " " + camel(f.json_name()) + ";",
    )
  }
  out.push("")
  out.push("  const " + name + "({")
  for f in fields {
    out.push(
      "    " +
      (if is_nullable(field_ty(f)) { "" } else { "required " }) +
      "this." +
      camel(f.json_name()) +
      ",",
    )
  }
  out.push("  });")
  out.push("")
  out.push("  factory " + name + ".fromJson(Map j) {")
  out.push("    return " + name + "(")
  for f in fields {
    out.push(
      "      " +
      camel(f.json_name()) +
      ": " +
      dart_decode(field_ty(f), "j['" + f.json_name() + "']") +
      ",",
    )
  }
  out.push("    );")
  out.push("  }")
  out.push("")
  out.push("  Map toJson() {")
  out.push("    final m = {};")
  for f in fields {
    out.push(
      "    m['" +
      f.json_name() +
      "'] = " +
      dart_encode(field_ty(f), camel(f.json_name())) +
      ";",
    )
  }
  out.push("    return m;")
  out.push("  }")
  out.push("}")
  out.push("")
  out
}

///|
/// The path a method requests, as one Dart string with `${…}` where a `:seg` was.
fn dart_path(op : Op) -> String {
  let mut out = "'"
  for seg in @text.path_segments(op.path) {
    if seg.length() > 0 && seg[0] == ':' {
      out = out +
        "/" +
        interp_open() +
        path_value(Dart, camel(seg[1:].to_owned())) +
        "}"
    } else {
      out = out + "/" + seg
    }
  }
  if out == "'" {
    out = "'/"
  }
  out + "'"
}

///|
/// One route as one method. Every parameter but the body is named, which is what
/// Dart style asks for once a call carries more than two arguments.
fn dart_method(op : Op) -> Array[String] {
  let out : Array[String] = []
  let positional : Array[String] = []
  let named : Array[String] = []
  for (name, ty, required) in ordered_params(op) {
    if name == "body" {
      positional.push(op.req + " body")
    } else {
      named.push(
        (if required { "required " } else { "" }) +
        dart_ty(if required { parse_ty(ty) } else { opt_of(parse_ty(ty)) }) +
        " " +
        name,
      )
    }
  }
  let ret = if op.resp == "" { "void" } else { op.resp }
  let sig = if named.length() > 0 {
    positional.join(", ") +
    (if positional.length() > 0 { ", " } else { "" }) +
    "{" +
    named.join(", ") +
    "}"
  } else {
    positional.join(", ")
  }
  if op.summary != "" {
    out.push("  /// " + op.summary)
  }
  out.push("  Future<" + ret + "> " + op.handler + "(" + sig + ") async {")
  out.push("    final path = " + dart_path(op) + ";")
  if op.query_params.length() > 0 {
    out.push("    final query = [];")
    for p in op.query_params {
      out.push(
        "    " +
        (if p.optional { "if (" + p.field + " != null) {" } else { "{" }),
      )
      out.push(
        "      query.add('" +
        p.wire +
        "=" +
        interp_open() +
        path_value(Dart, p.field) +
        "}');",
      )
      out.push("    }")
    }
    out.push(
      "    final qs = query.isEmpty ? '' : '?" +
      interp_open() +
      "query.join('&')}';",
    )
    out.push("    final url = Uri.parse('$baseUrl$path$qs');")
  } else {
    out.push("    final url = Uri.parse('$baseUrl$path');")
  }
  out.push(
    "    final headers = {'accept': 'application/json'};",
  )
  out.push("    if (token != null) {")
  out.push("      headers['authorization'] = 'Bearer $token';")
  out.push("    }")
  for p in op.header_params {
    if p.optional {
      out.push("    if (" + p.field + " != null) {")
      out.push("      headers['" + p.wire + "'] = " + p.field + ".toString();")
      out.push("    }")
    } else {
      out.push("    headers['" + p.wire + "'] = " + p.field + ".toString();")
    }
  }
  let body = if op.has_body {
    out.push("    headers['content-type'] = 'application/json';")
    "jsonEncode(body.toJson())"
  } else {
    "null"
  }
  out.push(
    "    final res = await 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 +
      ".fromJson(jsonDecode(res.body) as Map);",
    )
  }
  out.push("  }")
  out.push("")
  out
}

///|
/// The whole file: the exception, the transport port, the models, then the client.
fn emit_dart(m : Model) -> String {
  let out : Array[String] = []
  out.push(
    "// Code generated by `mctl api dart` 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 named parameters, `form:`",
  )
  out.push(
    "// fields become query parameters, `header:` fields become headers, and the",
  )
  out.push("// `json:` fields of the request type become the body.")
  out.push("//")
  out.push(
    "// The transport is a `Send` port, so this file needs `dart:convert` and",
  )
  out.push(
    "// nothing else: hand it `package:http`, `dart:io` or a test double.",
  )
  out.push("")
  out.push("import 'dart:convert';")
  out.push("")
  out.push("/// An answer the service did not send with a 2xx status.")
  out.push("class ApiException implements Exception {")
  out.push("  ApiException(this.status, this.body);")
  out.push("")
  out.push("  final int status;")
  out.push("  final String body;")
  out.push("")
  out.push("  @override")
  out.push("  String toString() => 'ApiException($status): $body';")
  out.push("}")
  out.push("")
  out.push("/// One raw answer: the status and the text of the body.")
  out.push("class RawResponse {")
  out.push("  const RawResponse(this.status, this.body);")
  out.push("")
  out.push("  final int status;")
  out.push("  final String body;")
  out.push("")
  out.push("  bool get ok => status >= 200 && status < 300;")
  out.push("}")
  out.push("")
  out.push("/// The transport port: send one request, get the raw answer back.")
  out.push("typedef Send = Future Function(")
  out.push("  String method,")
  out.push("  Uri url,")
  out.push("  Map headers,")
  out.push("  String? body,")
  out.push(");")
  out.push("")
  for t in m.types {
    for line in dart_type(t, m.types) {
      out.push(line)
    }
  }
  out.push("class " + m.class_name + " {")
  out.push(
    "  " +
    m.class_name +
    "({required String baseUrl, required this.send, this.token})",
  )
  out.push("      : baseUrl = baseUrl.replaceAll(RegExp(r'/+$'), '');")
  out.push("")
  out.push("  /// Base URL of the service; a trailing slash is dropped.")
  out.push("  final String baseUrl;")
  out.push("  /// The transport every call goes through.")
  out.push("  final Send send;")
  out.push("  /// Bearer token sent with every request while set.")
  out.push("  final String? token;")
  out.push("")
  for op in m.ops {
    for line in dart_method(op) {
      out.push(line)
    }
  }
  out.push("}")
  @text.join_lines(out)
}