// `mctl gateway` (← goctl's `gateway`): an HTTP front door for a gRPC service.
//
// A gRPC service speaks `POST /package.Service/Method` with a protobuf body, which
// is a protocol, not a REST API: no browser speaks it, and no CDN caches it. A
// gateway is the translation layer — HTTP verbs and paths in front, gRPC behind —
// so that a `.proto` a team already has can be reached from a web page.
//
// The gateway here is the routing and the envelope, not the transport: a caller
// port takes the gRPC method's full path and the request document and returns the
// reply document, and everything above that port (the route table, the 400/404/502
// answers, the JSON bodies) is generated and typed. Binding the port to a real
// channel is a dozen lines, the same way the mongo port is.
//
// Routes come from a mapping file when there is one — `POST /v1/users
// demo.UserService/Create` per line — and from the gRPC path itself when there is
// not, so a gateway can be generated before anyone has decided on URLs.

///|
/// One HTTP route and the gRPC method behind it: the verb and path the gateway
/// answers, and the fully-qualified method it forwards to.
pub(all) struct GatewayRoute {
  verb : String
  path : String
  rpc : String
}

///|
/// The fully-qualified gRPC path of an RPC: `/package.Service/Method`, the spelling
/// a channel calls and a gateway forwards to.
fn rpc_path(pkg : String, service : String, rpc : String) -> String {
  let full = if pkg == "" { service } else { pkg + "." + service }
  "/" + full + "/" + rpc
}

///|
/// Whether an RPC can sit behind an HTTP route: a gRPC stream cannot, because an
/// HTTP response is one body with one end. A streaming RPC is left out of the route
/// table, and the generated file says which ones were left out.
fn is_streaming(r : Rpc) -> Bool {
  r.client_streaming || r.server_streaming
}

///|
/// Every RPC of `proto` as the default route: `POST //` — the gRPC
/// path with its package dropped, because a gateway in front of one package does not
/// need the package repeated in the URL. A `POST` is the method gRPC itself uses, so
/// the mapping is the one that always works.
pub fn default_routes(proto : Proto) -> Array[GatewayRoute] {
  let out : Array[GatewayRoute] = []
  for s in proto.services {
    for r in s.rpcs {
      if is_streaming(r) {
        continue
      }
      out.push({
        verb: "POST",
        path: "/" + s.name + "/" + r.name,
        rpc: rpc_path(proto.package_, s.name, r.name),
      })
    }
  }
  out
}

///|
/// Whether `proto` declares the gRPC method `target` (`/package.Service/Method`), so a
/// hand-written route that names a method the spec does not have is caught here
/// rather than at the first request.
pub fn has_method(proto : Proto, target : String) -> Bool {
  for s in proto.services {
    for r in s.rpcs {
      if rpc_path(proto.package_, s.name, r.name) == target {
        return true
      }
    }
  }
  false
}

///|
/// The routes a mapping file declares, in the order it declares them. A line is
/// `VERB /path /package.Service/Method`; `#` starts a comment, and a blank line is
/// skipped. A line naming a method the `.proto` does not declare, or one that
/// streams, is left out — `skipped` reports it so the caller can say which.
pub fn parse_route_map(
  proto : Proto,
  source : String,
) -> (Array[GatewayRoute], Array[String]) {
  let out : Array[GatewayRoute] = []
  let skipped : Array[String] = []
  for raw in @text.split_char(source, '\n') {
    let mut line = @text.trim(raw)
    let hash = @text.index_of_str(line, "#")
    if hash >= 0 {
      line = @text.trim(line[0:hash].to_owned())
    }
    if line == "" {
      continue
    }
    let parts = @text.split_char(line, ' ')
    let words : Array[String] = []
    for p in parts {
      let w = @text.trim(p)
      if w != "" {
        words.push(w)
      }
    }
    if words.length() < 3 {
      skipped.push(line)
      continue
    }
    if has_method(proto, words[2]) == false {
      skipped.push(line)
      continue
    }
    out.push({ verb: words[0].to_upper(), path: words[1], rpc: words[2], })
  }
  (out, skipped)
}

///|
/// The prelude of a generated gateway: the error a request can end in, the reply it
/// produces, the caller port, and the route record.
fn gateway_prelude() -> String {
  "// Code generated by moonctl. DO NOT EDIT.\n" +
  "//\n" +
  "// `mctl gateway`: an HTTP front door for this `.proto`. A request's JSON body is\n" +
  "// forwarded to the gRPC method its route names, and the reply is handed back as\n" +
  "// the response body. The transport is the `Caller` port: bind it to a channel and\n" +
  "// the gateway is live.\n" +
  "//\n" +
  "// This file needs `moonbitlang/core/json` imported as `@json`.\n" +
  "\n" +
  "///|\n/// What a request can end in: a body that will not parse, a path no route answers,\n/// or an upstream call that failed.\npub(all) suberror GatewayError {\n  GatewayBadRequest(why~ : String)\n  GatewayNotFound(route~ : String)\n  GatewayUpstream(status~ : Int32, message~ : String)\n}\n" +
  "\n" +
  "///|\n/// One answer: the HTTP status and the JSON body it carries.\npub(all) struct GatewayReply {\n  status : Int32\n  body : Json\n}\n" +
  "\n" +
  "///|\n/// The upstream a gateway calls: the gRPC method's full path (`/package.Service/\n/// Method`) and the request document, in return for the reply document.\npub(all) struct Caller {\n  call : (String, Json) -> Json raise GatewayError\n}\n" +
  "\n" +
  "///|\n/// One route: the HTTP verb and path the gateway answers, and the gRPC method it\n/// forwards to.\npub(all) struct GatewayRoute {\n  verb : String\n  path : String\n  rpc : String\n}\n" +
  "\n" +
  "///|\n/// A JSON object with one `error` member: what a failed request answers with.\nfn error_body(message : String) -> Json {\n  let m : Map[String, Json] = Map([])\n  m[\"error\"] = ToJson::to_json(message)\n  ToJson::to_json(m)\n}\n" +
  "\n" +
  "///|\n/// The one-line spelling of a failure, which is what the `error` member carries.\nfn describe(e : GatewayError) -> String {\n  match e {\n    GatewayBadRequest(why~) => \"bad request: \" + why\n    GatewayNotFound(route~) => \"no route for \" + route\n    GatewayUpstream(status~, message~) =>\n      \"upstream \" + status.to_string() + \": \" + message\n  }\n}\n\n"
}

///|
/// The route table, as a function so the gateway and its callers read the same list.
fn emit_routes(routes : Array[GatewayRoute]) -> String {
  let lines : Array[String] = []
  for r in routes {
    lines.push(
      "    { verb: " +
      @text.quote(r.verb) +
      ", path: " +
      @text.quote(r.path) +
      ", rpc: " +
      @text.quote(r.rpc) +
      " },",
    )
  }
  "///|\n/// Every route of the gateway, in the order the `.proto` (or the mapping file)\n/// declared them.\npub fn routes() -> Array[GatewayRoute] {\n  [" +
  (if lines.length() == 0 { "" } else { "\n" }) +
  @text.join_lines(lines) +
  (if lines.length() == 0 { "" } else { "\n  " }) +
  "]\n}\n\n"
}

///|
/// Generate the gateway for `proto` with `routes`: the route table, the gateway
/// itself, and the two entry points — `handle`, which takes a JSON body already
/// parsed, and `handle_text`, which parses the body a request carried.
///
/// Status codes are the four an HTTP client cares about: `200` for a reply, `400`
/// for a body that is not JSON, `404` for a path no route answers, and `502` for a
/// call the upstream failed. A gRPC status does not leak through: an HTTP client
/// reads it where it is meaningful — in the reply body.
pub fn generate_gateway(proto : Proto, routes : Array[GatewayRoute]) -> String {
  let mut out = gateway_prelude()
  out = out + emit_routes(routes)
  let streamed : Array[String] = []
  for s in proto.services {
    for r in s.rpcs {
      if is_streaming(r) {
        streamed.push(rpc_path(proto.package_, s.name, r.name))
      }
    }
  }
  if streamed.length() > 0 {
    out = out +
      "// Streaming methods have no HTTP route: " +
      @text.join_lines(streamed) +
      ".\n\n"
  }
  out = out +
    "///|\n/// The gateway: the upstream it calls. Routes come from `routes()`.\npub(all) struct Gateway {\n  caller : Caller\n}\n\n" +
    "///|\n/// A gateway over `caller`.\npub fn Gateway::new(caller : Caller) -> Gateway {\n  { caller, }\n}\n\n" +
    "///|\n/// The gRPC method a verb and path name, or `\"\"` when no route answers them.\nfn method_of(verb : String, path : String) -> String {\n  let mut rpc = \"\"\n  for r in routes() {\n    if rpc == \"\" && r.verb == verb && r.path == path {\n      rpc = r.rpc\n    }\n  }\n  rpc\n}\n\n" +
    "///|\n/// Answer one request: find the route, forward the body, wrap the reply.\npub fn Gateway::handle(\n  self : Gateway,\n  verb : String,\n  path : String,\n  body : Json,\n) -> GatewayReply {\n  let target = method_of(verb, path)\n  if target == \"\" {\n    return {\n      status: 404,\n      body: error_body(\"no route for \" + verb + \" \" + path),\n    }\n  }\n  let reply = (self.caller.call)(target, body) catch {\n    e =>\n      return {\n        status: 502,\n        body: error_body(describe(e)),\n      }\n  }\n  { status: 200, body: reply }\n}\n\n" +
    "///|\n/// Answer one request whose body arrived as text, which is what a server hands its\n/// handler. A body that is not JSON answers `400` rather than raising.\npub fn Gateway::handle_text(\n  self : Gateway,\n  verb : String,\n  path : String,\n  body : String,\n) -> GatewayReply {\n  let doc = @json.parse(body) catch {\n    _ =>\n      return {\n        status: 400,\n        body: error_body(\"body is not JSON\"),\n      }\n  }\n  self.handle(verb, path, doc)\n}\n"
  out
}

///|
/// The file a gateway lands in: the `.proto`'s own name, without its directory and
/// without its extension — `proto/greet.proto` → `greet_gateway.mbt`. A `.proto` may
/// declare several services, so the file name comes from the file rather than from
/// one of them.
pub fn gateway_file_name(proto_path : String) -> String {
  let mut start = 0
  for i = 0; i < proto_path.length(); i = i + 1 {
    let c = proto_path[i]
    if c == '/' || c == '\\' {
      start = i + 1
    }
  }
  let mut stem = proto_path[start:].to_owned()
  if @text.ends_with(stem, ".proto") {
    stem = stem[0:stem.length() - 6].to_owned()
  }
  if stem == "" {
    return "gateway.mbt"
  }
  @text.to_snake(stem) + "_gateway.mbt"
}