// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0

///|
/// The application a server mounts: one request in, one response out, with the
/// async engine behind it so resolvers that wait work, and with incremental
/// delivery when the client asks for it.
///
/// `graphql_handler` stays the synchronous request-to-response function — it is
/// what moonasgi's `TestClient` drives and what a program embedding moongql
/// calls directly. This application reads the request's events itself, so it can
/// start the response before the last payload is computed.

///|
/// The media type of an incremental response: one multipart document, the boundary
/// the parts are separated by, and the version of the format, so a client knows
/// how to read the parts.
pub fn incremental_content_type(boundary : String) -> String {
  "multipart/mixed; boundary=\"" + boundary + "\"; deferSpec=20220824"
}

///|
/// Whether a client's `Accept` asks for the incremental response. A client that
/// does not is answered with one plain JSON body, which is what every
/// pre-incremental client expects.
fn wants_incremental(accept : String?) -> Bool {
  match accept {
    None => false
    Some(h) => h.contains("multipart/mixed") || h.contains("deferSpec")
  }
}

///|
/// One payload as a multipart part: a boundary line, the part's headers, and the
/// payload itself. Parts end with the closing boundary rather than each carrying
/// it, so `close_multipart` writes the last one.
pub fn multipart_part(boundary : String, payload : Json) -> String {
  let body = payload.stringify()
  "\r\n--" +
  boundary +
  "\r\nContent-Type: application/json; charset=utf-8\r\n\r\n" +
  body +
  "\r\n"
}

///|
/// The closing boundary of a multipart document.
pub fn multipart_close(boundary : String) -> String {
  "\r\n--" + boundary + "--\r\n"
}

///|
/// How an incremental response is written: the boundary its parts use. A client
/// that sends `Accept: multipart/mixed; boundary=...` picks its own; otherwise the
/// server's is used, which is what the specification allows.
fn boundary_of(accept : String?) -> String {
  let header = match accept {
    None => return "-"
    Some(h) => h
  }
  let at = match header.find("boundary=") {
    None => return "-"
    Some(i) => i
  }
  let rest = header[at + 9:].to_owned()
  let stop = match rest.find(";") {
    Some(i) => i
    None => rest.length()
  }
  let raw = rest[:stop].trim().to_owned()
  // A boundary may be quoted; the quotes are not part of it.
  let candidate = if raw.length() >= 2 &&
    raw[0] == '"' &&
    raw[raw.length() - 1] == '"' {
    raw[1:raw.length() - 1].to_owned()
  } else {
    raw
  }
  if candidate == "" {
    "-"
  } else {
    candidate
  }
}

///|
/// Lift the GraphQL handler onto the load-bearing `AsgiApp` a server binds to.
///
/// A `POST` on `path` runs the request with the asynchronous engine: one JSON
/// response, or — when the client's `Accept` asks for `multipart/mixed` — the
/// initial payload followed by one part per deferred fragment or streamed list
/// item. A `GET` serves the IDE, or runs a query operation with `?query=`.
///
/// Mount it in mooncat (or any ASGI server) where the schema should be reachable;
/// `graphql_handler` is the same endpoint as a plain request-to-response function.
pub fn graphql_app(
  schema : @schema.Schema,
  resolvers : @exec.Resolvers,
  path~ : String = "/graphql",
  ide~ : Ide = site,
  limits~ : @exec.Limits = @exec.limits,
) -> @moonasgi.AsgiApp {
  async fn(scope, receive, send) {
    let http = match scope {
      @spec.Http(h) => h
      _ => {
        let body = @utf8.encode("Not Found")
        send(
          @spec.Event::HttpResponseStart(
            status=404,
            headers=[("content-type", "text/plain; charset=utf-8")],
            trailers=false,
          ),
        )
        send(@spec.Event::HttpResponseBody(body~, more_body=false))
        return
      }
    }
    if http.path != path {
      return reply(send, 404, "Not Found")
    }
    let accept = header_of(http.headers, "accept")
    let wire = match wire_of(accept) {
      Some(w) => w
      None =>
        return reply(
          send, 406, "Not Acceptable: this endpoint serves application/graphql-response+json or application/json",
        )
    }
    match http.http_method {
      "GET" | "HEAD" => {
        let params = query_params(http.query_string)
        match params.get("query") {
          Some(q) => run_get(schema, resolvers, wire, params, q, send, limits)
          None =>
            if ide.is_off() {
              reply(send, 405, "Method Not Allowed")
            } else {
              let page = @utf8.encode(ide.html(endpoint=path))
              send(
                @spec.Event::HttpResponseStart(
                  status=200,
                  headers=[("content-type", "text/html; charset=utf-8")],
                  trailers=false,
                ),
              )
              send(@spec.Event::HttpResponseBody(body=page, more_body=false))
            }
        }
      }
      "POST" =>
        match header_of(http.headers, "content-type") {
          None =>
            reply(send, 415, "POST requires a Content-Type of application/json")
          Some(ct) =>
            if !ci_eq(media_type(ct), "application/json") {
              reply(
                send,
                415,
                "Unsupported Content-Type '" + media_type(ct) + "'",
              )
            } else {
              read_body(receive, send, schema, resolvers, wire, accept, limits)
            }
        }
      _ => reply(send, 405, "Method Not Allowed")
    }
  }
}

///|
/// Read the request body from the remaining events, then answer it.
async fn read_body(
  receive : @moonasgi.Receive,
  send : @moonasgi.Send,
  schema : @schema.Schema,
  resolvers : @exec.Resolvers,
  wire : Wire,
  accept : String?,
  limits : @exec.Limits,
) -> Unit {
  let buf : Array[Byte] = []
  let mut more = true
  while more {
    let event = receive()
    match event {
      @spec.Event::HttpRequest(body~, more_body~) => {
        for i in 0.. return
      _ => more = false
    }
  }
  let text = @utf8.decode_lossy(Bytes::from_array(buf[:]))
  let body = @json.parse(text) catch {
    _ => return reply(send, 400, "Request body is not valid JSON")
  }
  match body {
    Array(items) =>
      if items.is_empty() {
        reply(send, 400, "GraphQL batch must not be empty")
      } else {
        let results : Array[Json] = []
        for item in items {
          match read_request(item) {
            Ok((query, vars, op_name)) =>
              results.push(
                run_one(schema, resolvers, query, vars, op_name, limits),
              )
            Err(m) => return reply(send, 400, m)
          }
        }
        send_json(send, wire, Json(results))
      }
    _ =>
      // A parameter of the wrong type is a request error, not an execution result:
      // answering 400 is what tells a client to fix its request rather than read
      // `errors` as a field failure.
      match read_request(body) {
        Err(m) => reply(send, 400, m)
        Ok((None, _, _)) =>
          // No document is a request error: no operation ran, so there is no
          // result to report, and 400 is what tells the client its request is
          // malformed rather than its query wrong.
          reply(send, 400, no_query_message())
        Ok((Some(q), vars, op_name)) =>
          if wants_incremental(accept) {
            incremental(
              send,
              Some(q),
              vars,
              op_name,
              schema,
              resolvers,
              accept,
              limits,
            )
          } else {
            send_json(
              send,
              wire,
              run_one(schema, resolvers, Some(q), vars, op_name, limits),
            )
          }
      }
  }
}

///|
/// Run one request object and answer its response, or a request-level failure.
async fn run_one(
  schema : @schema.Schema,
  resolvers : @exec.Resolvers,
  query : String?,
  variables : Map[String, Json],
  operation_name : String?,
  limits : @exec.Limits,
) -> Json {
  match query {
    None =>
      @exec.build_response(None, [@lang.GqlError::msg(no_query_message())])
    Some(q) =>
      @exec.execute_async(
        schema,
        resolvers,
        q,
        variables~,
        operation_name?,
        limits~,
      )
  }
}

///|
/// The message every request without a query gets.
fn no_query_message() -> String {
  "No GraphQL query found in request body"
}

///|
/// Serve a `GET` carrying `?query=`: query operations only, per the
/// specification's rule that a `GET` is safe and idempotent.
async fn run_get(
  schema : @schema.Schema,
  resolvers : @exec.Resolvers,
  wire : Wire,
  params : Map[String, String],
  query : String,
  send : @moonasgi.Send,
  limits : @exec.Limits,
) -> Unit {
  let operation_name : String? = params.get("operationName")
  let variables : Map[String, Json] = match params.get("variables") {
    None => Map([])
    Some(text) =>
      match
        (@json.parse(text) catch {
          _ =>
            return reply(
              send, 400, "The 'variables' parameter is not valid JSON",
            )
        }) {
        Object(m) => m
        _ => Map([])
      }
  }
  if operation_type_of(schema, query, operation_name) is Ok(op) &&
    !(op is @lang.Query) {
    return reply(
      send, 405, "GET supports query operations only; use POST for a mutation or subscription",
    )
  }
  let result = @exec.execute_async(
    schema,
    resolvers,
    query,
    variables~,
    operation_name?,
    limits~,
  )
  send_json(send, wire, result)
}

///|
/// Run a request and write its payloads as a multipart document, one part each,
/// so a client sees the initial result before the deferred ones are ready.
async fn incremental(
  send : @moonasgi.Send,
  query : String?,
  variables : Map[String, Json],
  operation_name : String?,
  schema : @schema.Schema,
  resolvers : @exec.Resolvers,
  accept : String?,
  limits : @exec.Limits,
) -> Unit {
  let boundary = boundary_of(accept)
  send(
    @spec.Event::HttpResponseStart(
      status=200,
      headers=[("content-type", incremental_content_type(boundary))],
      trailers=false,
    ),
  )
  let payloads = match query {
    None =>
      [@exec.build_response(None, [@lang.GqlError::msg(no_query_message())])]
    Some(q) =>
      @exec.execute_incremental(
        schema,
        resolvers,
        q,
        variables~,
        operation_name?,
        limits~,
      )
  }
  for payload in payloads {
    let chunk = @utf8.encode(multipart_part(boundary, payload))
    send(@spec.Event::HttpResponseBody(body=chunk, more_body=true))
  }
  let last = @utf8.encode(multipart_close(boundary))
  send(@spec.Event::HttpResponseBody(body=last, more_body=false))
}

///|
/// Write a whole JSON response in one body.
async fn send_json(send : @moonasgi.Send, wire : Wire, body : Json) -> Unit {
  let encoded = @utf8.encode(body.stringify())
  send(
    @spec.Event::HttpResponseStart(
      status=wire_status(wire, body),
      headers=[("content-type", wire_content_type(wire))],
      trailers=false,
    ),
  )
  send(@spec.Event::HttpResponseBody(body=encoded, more_body=false))
}

///|
/// Write a plain-text response.
async fn reply(send : @moonasgi.Send, status : Int32, message : String) -> Unit {
  let encoded = @utf8.encode(message)
  send(
    @spec.Event::HttpResponseStart(
      status~,
      headers=[("content-type", "text/plain; charset=utf-8")],
      trailers=false,
    ),
  )
  send(@spec.Event::HttpResponseBody(body=encoded, more_body=false))
}

///|
/// Read a header by name, ignoring case, out of an ASGI header list.
fn header_of(headers : Array[(String, String)], name : String) -> String? {
  for pair in headers {
    if ci_eq(pair.0, name) {
      return Some(pair.1)
    }
  }
  None
}