// Output, exit codes and the machine-readable error envelope.
//
// The exit-code map and envelope shape are a contract that scripts branch on, so they
// are reproduced exactly from the reference. See docs/reference-behaviour.md section 3
// for the measured behaviour these mirror.

///|
pub let exit_ok : Int = 0

///|
/// Usage or unexpected error -- the catch-all.
pub let exit_usage : Int = 1

///|
/// `--app-info` found no MCP App on the tool.
pub let exit_no_app : Int = 2

///|
/// The server requires authentication.
pub let exit_auth_required : Int = 3

///|
/// The server is unreachable.
pub let exit_unreachable : Int = 4

///|
/// A tool returned `isError:true`, or the named tool does not exist.
pub let exit_tool_error : Int = 5

///|
/// A failure classified for a programmatic caller.
pub(all) struct ErrorEnvelope {
  /// Stable identifier for the failure class.
  code : String
  message : String
  cause : String?
  status : Int?
  url : String?
} derive(Debug)

///|
pub fn ErrorEnvelope::to_json(self : Self) -> Json {
  let inner : Map[String, Json] = Map([])
  inner["code"] = Json::string(self.code)
  inner["message"] = Json::string(self.message)
  match self.cause {
    Some(c) => inner["cause"] = Json::string(c)
    None => ()
  }
  match self.status {
    Some(s) => inner["status"] = Json::number(s.to_double())
    None => ()
  }
  match self.url {
    Some(u) => inner["url"] = Json::string(u)
    None => ()
  }
  Json::object({ "error": Json::object(inner) })
}

///|
/// Raised to request a specific exit code without going through classification.
pub(all) suberror CliExit {
  CliExit(code~ : Int, envelope~ : ErrorEnvelope)
} derive(Debug)

///|
/// Substrings that mean "we never reached the server".
///
/// Ported from the reference's UNREACHABLE_PATTERN. Structural classification handles
/// most cases; this catches what only shows up in an OS error string.
let unreachable_markers : Array[String] = [
  "ENOTFOUND", "ECONNREFUSED", "ECONNRESET", "EAI_AGAIN", "ETIMEDOUT", "fetch failed",
  "getaddrinfo", "connect timed out", "aborted", "Connection refused", "Name or service not known",
  "No route to host", "Network is unreachable", "timed out",
]

///|
fn looks_unreachable(msg : String) -> Bool {
  for marker in unreachable_markers {
    if msg.contains(marker) {
      return true
    }
  }
  false
}

///|
/// Map an error onto an exit code and envelope.
///
/// Classification is structural where it can be: a `TransportError::Network` is
/// unreachable because of what it is, not because its text matched a regex.
pub fn classify(e : Error, url? : String) -> (Int, ErrorEnvelope) {
  match e {
    CliExit(code~, envelope~) => (code, envelope)
    UsageError(msg) =>
      (
        exit_usage,
        { code: "error", message: msg, cause: None, status: None, url },
      )
    @client.Transport(t) => classify_transport(t, url)
    @client.Protocol(rpc) =>
      (
        exit_usage,
        { code: "error", message: rpc.message, cause: None, status: None, url },
      )
    @client.InputRefused(..) as m =>
      (
        exit_usage,
        {
          code: "error",
          message: m.describe_error(),
          cause: None,
          status: None,
          url,
        },
      )
    @client.InputRoundsExceeded(_) as m =>
      (
        exit_usage,
        {
          code: "error",
          message: m.describe_error(),
          cause: None,
          status: None,
          url,
        },
      )
    @client.Mismatched(_) as m =>
      (
        exit_usage,
        {
          code: "error",
          message: m.describe_error(),
          cause: None,
          status: None,
          url,
        },
      )
    @client.StreamEnded as m =>
      (
        exit_usage,
        {
          code: "error",
          message: m.describe_error(),
          cause: None,
          status: None,
          url,
        },
      )
    @transport.Http(..) as t => classify_transport(t, url)
    @transport.Network(_) as t => classify_transport(t, url)
    @transport.Framing(_) as t => classify_transport(t, url)
    @transport.Closed as t => classify_transport(t, url)
    _ => {
      let msg = e.to_string()
      let code = if looks_unreachable(msg) {
        exit_unreachable
      } else {
        exit_usage
      }
      let slug = if code == exit_unreachable { "unreachable" } else { "error" }
      (code, { code: slug, message: msg, cause: None, status: None, url })
    }
  }
}

///|
fn classify_transport(
  t : @transport.TransportError,
  url : String?,
) -> (Int, ErrorEnvelope) {
  match t {
    @transport.Http(status~, body~, url=target) => {
      // 401/403 mean "authenticate", which is its own exit code so CI can tell a
      // credential problem from a broken server.
      let code = if status == 401 || status == 403 {
        exit_auth_required
      } else {
        exit_usage
      }
      let slug = if code == exit_auth_required {
        "auth_required"
      } else {
        "error"
      }
      // The reference reports a non-200 with its raw body, which is how a 404
      // carrying a JSON-RPC -32601 stays legible.
      let message = match body {
        Some(b) => "Error POSTing to endpoint: \{b.stringify()}"
        None => "HTTP \{status} from \{target}"
      }
      (
        code,
        {
          code: slug,
          message,
          cause: None,
          status: Some(status),
          url: Some(target),
        },
      )
    }
    @transport.Network(msg) =>
      (
        exit_unreachable,
        { code: "unreachable", message: msg, cause: None, status: None, url },
      )
    _ =>
      (
        exit_usage,
        {
          code: "error",
          message: t.describe_error(),
          cause: None,
          status: None,
          url,
        },
      )
  }
}

///|
/// Write the result to stdout in the requested format.
///
/// `text` is pretty-printed JSON, `json` is a single-line `{"result": ...}` envelope
/// so the whole output pipes into jq.
pub async fn emit_result(
  result : Json,
  format : String,
  app_info? : Json,
) -> Unit {
  if format == "json" {
    let envelope : Map[String, Json] = Map([])
    envelope["result"] = result
    match app_info {
      Some(info) => envelope["appInfo"] = info
      None => ()
    }
    @stdio.stdout.write(Json::object(envelope).stringify() + "\n")
  } else {
    @stdio.stdout.write(result.stringify(indent=2) + "\n")
  }
}

///|
/// Write the one-line error envelope to stderr.
pub async fn emit_error(envelope : ErrorEnvelope) -> Unit {
  @stdio.stderr.write(envelope.to_json().stringify() + "\n")
}