///|
/// An incoming HTTP request containing the method, URL, headers, and body.
pub(all) struct HttpRequest {
  http_method : HttpMethod
  url : String
  headers : Map[String, String]
  mut raw_body : Bytes
  // Lazily cached parsed path and query params to avoid re-parsing per request
  priv mut cached_path : String?
  priv mut cached_query_params : Map[String, String]?

  /// Constructor — creates a new HttpRequest.
  fn new(
    http_method : HttpMethod,
    url : String,
    headers : Map[String, String],
    raw_body? : Bytes,
  ) -> HttpRequest
}

///|
/// Creates a new `HttpRequest` with the given method, URL, headers, and optional body.
pub fn HttpRequest::new(
  http_method : HttpMethod,
  url : String,
  headers : Map[String, String],
  raw_body? : Bytes,
) -> HttpRequest {
  {
    http_method,
    url,
    headers,
    raw_body: raw_body.unwrap_or(b""),
    cached_path: None,
    cached_query_params: None,
  }
}

///|
/// Creates a new `HttpRequest` from a method string, parsing it into an `HttpMethod`.
pub fn HttpRequest::from_method_string(
  http_method : String,
  url : String,
  headers : Map[String, String],
  raw_body : Bytes,
) -> HttpRequest {
  HttpRequest(HttpMethod::from_string(http_method), url, headers, raw_body~)
}

///|
/// Returns the HTTP method as a string (e.g., "GET", "POST").
pub fn HttpRequest::method_string(self : HttpRequest) -> String {
  self.http_method.to_method_string()
}

///|
/// Trait for types that can be deserialized from an HTTP request body.
pub(open) trait BodyReader {
  from_request(req : HttpRequest) -> Self raise
}

///|
/// Returns the path component of the request URL, excluding query string and fragment.
pub fn HttpRequest::path(self : HttpRequest) -> String {
  match self.cached_path {
    Some(p) => p
    None => {
      let p = @mhttp.request_target_path(self.url)
      self.cached_path = Some(p)
      p
    }
  }
}

///|
/// Returns the raw query string from the request URL, or `None` if absent.
pub fn HttpRequest::query_string(self : HttpRequest) -> String? {
  @mhttp.request_target_query_string(self.url)
}

///|
/// Returns the Content-Type header value, or None if not set.
pub fn HttpRequest::content_type(self : HttpRequest) -> String? {
  self.get_header("content-type")
}

/// Returns the cached parsed query params, parsing the URL on first access.
/// Private — external callers should use `query_params()` or `get_query()`
/// which return owned copies.

///|
fn HttpRequest::cached_query(self : HttpRequest) -> Map[String, String] {
  match self.cached_query_params {
    Some(params) => params
    None => {
      let params : Map[String, String] = match self.query_string() {
        Some(query) => @mhttp.parse_form_data(@utf8.encode(query)[:])
        None => {}
      }
      self.cached_query_params = Some(params)
      params
    }
  }
}

///|
/// Parses the query string into a map of key-value pairs.
///
/// Returns a fresh copy on every call — mutating the returned map does NOT
/// affect subsequent `get_query()` calls. The underlying parse is cached, so
/// calling this multiple times only parses the URL once.
pub fn HttpRequest::query_params(self : HttpRequest) -> Map[String, String] {
  let cached = self.cached_query()
  let copy : Map[String, String] = {}
  cached.each((k, v) => copy[k] = v)
  copy
}

///|
/// Looks up a single query parameter by key, returning `None` if not found.
/// Uses the cached query params — calling this multiple times with different
/// keys only parses the URL once.
pub fn HttpRequest::get_query(self : HttpRequest, key : String) -> String? {
  self.cached_query().get(key)
}

///|
/// Deserializes the request body into a value of type `T` via the `BodyReader` trait.
pub fn[T : BodyReader] HttpRequest::body(self : HttpRequest) -> T raise {
  T::from_request(self)
}

///|
/// Parses the request body as JSON and deserializes into type `T`.
pub fn[T : FromJson] HttpRequest::json(self : HttpRequest) -> T raise {
  let text = @utf8.decode(self.raw_body)
  let json = @json.parse(text)
  @json.from_json(json)
}

///|
/// Tries to parse the request body as JSON, returning `Ok(T)` on success
/// or `Err(message)` on failure.
pub fn[T : FromJson] HttpRequest::try_json(
  self : HttpRequest,
) -> Result[T, String] {
  try {
    let text = @utf8.decode(self.raw_body)
    let json = @json.parse(text)
    Ok(@json.from_json(json))
  } catch {
    err => Err(err.to_string())
  }
}

///|
test "path is cached after first access" {
  let req = HttpRequest(Get, "/api/users?q=test", {}, raw_body=b"")
  assert_eq(req.cached_path, None)
  let path1 = req.path()
  assert_eq(path1, "/api/users")
  assert_eq(req.cached_path, Some("/api/users"))
  // Second call returns cached value
  let path2 = req.path()
  assert_eq(path1, path2)
}

///|
test "query_params returns a copy that doesn't affect the cache" {
  let req = HttpRequest(Get, "/search?q=hello&lang=en", {}, raw_body=b"")
  let params1 = req.query_params()
  params1["injected"] = "hacked"
  // The mutation should not affect subsequent gets
  assert_eq(req.get_query("injected"), None)
  let params2 = req.query_params()
  assert_eq(params2.get("injected"), None)
}

///|
test "query_params is cached after first access" {
  let req = HttpRequest(Get, "/search?q=hello&lang=en", {}, raw_body=b"")
  assert_eq(req.cached_query_params, None)
  let params1 = req.query_params()
  assert_eq(params1.get("q"), Some("hello"))
  assert_true(req.cached_query_params is Some(_))
  // get_query uses the cached params
  assert_eq(req.get_query("lang"), Some("en"))
  assert_eq(req.get_query("missing"), None)
}

///|
test "get_header is case-insensitive" {
  let req = HttpRequest(
    Get,
    "/",
    { "X-Test": "value", "content-type": "text/plain" },
    raw_body=b"",
  )
  assert_eq(req.get_header("x-test"), Some("value"))
  assert_eq(req.get_header("X-TEST"), Some("value"))
  assert_eq(req.get_header("Content-Type"), Some("text/plain"))
  assert_eq(req.get_header("missing"), None)
}

///|
test "request target helpers expose path and query" {
  let req = HttpRequest(
    Get,
    "/search/moonbit?q=async%20native&lang=en#frag",
    {},
    raw_body=b"",
  )
  assert_eq(req.path(), "/search/moonbit")
  assert_eq(req.query_string(), Some("q=async%20native&lang=en"))
  assert_eq(req.get_query("q"), Some("async native"))
  assert_eq(req.get_query("lang"), Some("en"))
  assert_eq(req.get_query("missing"), None)
}

///|
pub impl BodyReader for String with from_request(req : HttpRequest) -> String raise {
  // Strict UTF-8 decoding — raises on invalid bytes.
  // For UTF-16 payloads, decode manually via a dedicated helper.
  @utf8.decode(req.raw_body)
}

///|
pub impl BodyReader for Json with from_request(req : HttpRequest) -> Json raise {
  @json.parse(@utf8.decode(req.raw_body))
}

///|
pub impl BodyReader for Bytes with from_request(req : HttpRequest) -> Bytes raise {
  req.raw_body
}

///|
pub impl BodyReader for FixedArray[Byte] with from_request(req : HttpRequest) -> FixedArray[
  Byte,
] raise {
  req.raw_body.to_fixedarray()
}

///|
pub impl BodyReader for Array[Byte] with from_request(req : HttpRequest) -> Array[
  Byte,
] raise {
  req.raw_body.to_array()
}

///|
test "read_body" {
  let req = HttpRequest(
    Post,
    "/",
    Map::new(),
    raw_body=b"{\"Hello\":\"World!\"}",
  )
  let text : String = req.body()
  let json : Json = req.body()
  inspect(
    text,
    content=(
      #|{"Hello":"World!"}
    ),
  )
  json_inspect(json, content={ "Hello": "World!" })
}

///|
#warnings("-unnecessary_annotation")
struct TryJsonTestUser {
  name : String
  age : Int
} derive(FromJson, ToJson, Eq)

///|
impl Show for TryJsonTestUser with output(self, logger) {
  logger.write_string(
    "TryJsonTestUser { name: \{self.name}, age: \{self.age} }",
  )
}

///|
test "try_json returns Ok on valid json" {
  let req = HttpRequest(
    Post,
    "/",
    {},
    raw_body=@utf8.encode("{\"name\":\"Alice\",\"age\":30}"),
  )
  let result : Result[TryJsonTestUser, String] = req.try_json()
  assert_eq(result, Ok({ name: "Alice", age: 30 }))
}

///|
test "try_json returns Err on invalid json" {
  let req = HttpRequest(Post, "/", {}, raw_body=b"not valid json")
  let result : Result[TryJsonTestUser, String] = req.try_json()
  assert_true(result is Err(_))
}

///|
test "try_json returns Err on wrong schema" {
  let req = HttpRequest(
    Post,
    "/",
    {},
    raw_body=@utf8.encode("{\"wrong_field\":123}"),
  )
  let result : Result[TryJsonTestUser, String] = req.try_json()
  assert_true(result is Err(_))
}