///|
/// Errors never embed response bodies, passwords, or session tokens.
#derive(@debug.Debug)
pub suberror RedfishError {
  InvalidEndpoint
  InvalidPath
  InvalidOptions
  InvalidResponse(String)
  HttpStatus(Int32)
  ResponseTooLarge
  PageLimit
  PaginationCycle
  SessionAlreadyActive
  NoSession
}

///|
/// A resource keeps its original JSON for optional and OEM fields.
pub struct Resource {
  uri : String
  id : String?
  name : String?
  resource_type : String?
  json : Json
}

///|
/// One collection page. Use `Client::members` for bounded pagination.
pub struct Collection {
  members : Array[String]
  next_link : String?
}

///|
/// Reads an optional string property without inventing a default value.
pub fn Resource::string(self : Resource, key : String) -> String? {
  optional_string(self.json, key)
}

///|
/// Reads a resource link and rejects targets outside the Redfish root.
pub fn Resource::link(
  self : Resource,
  key : String,
) -> String? raise RedfishError {
  match self.json {
    Object(fields) =>
      match fields.get(key) {
        None | Some(Null) => None
        Some(value) => Some(link_uri(value))
      }
    _ => None
  }
}

///|
fn optional_string(value : Json, key : String) -> String? {
  match value {
    Object(fields) =>
      match fields.get(key) {
        Some(String(s)) => Some(s)
        _ => None
      }
    _ => None
  }
}

///|
fn link_uri(value : Json) -> String raise RedfishError {
  guard optional_string(value, "@odata.id") is Some(uri) else {
    raise InvalidResponse("missing @odata.id link")
  }
  validate_path(uri)
  uri
}

///|
/// Parses an object without requiring schema-version-specific optional fields.
pub fn parse_resource(
  uri : String,
  value : Json,
) -> Resource raise RedfishError {
  validate_path(uri)
  guard value is Object(_) else {
    raise InvalidResponse("expected resource object")
  }
  {
    uri,
    id: optional_string(value, "Id"),
    name: optional_string(value, "Name"),
    resource_type: optional_string(value, "@odata.type"),
    json: value,
  }
}

///|
/// Parses collection links. Null or malformed Members is an error, not empty data.
pub fn parse_collection(value : Json) -> Collection raise RedfishError {
  guard value is Object(fields) else {
    raise InvalidResponse("expected collection object")
  }
  guard fields.get("Members") is Some(Array(members)) else {
    raise InvalidResponse("expected Members array")
  }
  let links = members.map(link_uri)
  let next_link = match fields.get("Members@odata.nextLink") {
    None | Some(Null) => None
    Some(String(s)) => {
      validate_path(s)
      Some(s)
    }
    _ => raise InvalidResponse("expected nextLink string")
  }
  { members: links, next_link, }
}

///|
/// Accepts only origin-relative Redfish paths. Encoded path segments, traversal,
/// fragments and controls are rejected. Query strings may contain percent escapes.
fn validate_path(path : String) -> Unit raise RedfishError {
  for c in path {
    if c <= ' ' || c == '\\' || c == '#' || c == '\u{7f}' {
      raise InvalidPath
    }
  }
  let raw = path.split("?").to_array()[0].to_owned()
  if !(raw == "/redfish/v1" || raw.has_prefix("/redfish/v1/")) ||
    raw.contains("%") ||
    raw.contains("//") {
    raise InvalidPath
  }
  for part in raw.split("/") {
    if part == "." || part == ".." {
      raise InvalidPath
    }
  }
}

///|
fn validate_endpoint(
  endpoint : String,
  allow_loopback_http : Bool,
) -> Unit raise RedfishError {
  let secure = endpoint.has_prefix("https://")
  let host = if secure {
    endpoint[8:].to_owned()
  } else if allow_loopback_http && endpoint.has_prefix("http://") {
    endpoint[7:].to_owned()
  } else {
    raise InvalidEndpoint
  }
  guard !host.is_empty() else { raise InvalidEndpoint }
  for c in host {
    if c <= ' ' ||
      c == '/' ||
      c == '\\' ||
      c == '@' ||
      c == '?' ||
      c == '#' ||
      c == '%' ||
      c == '\u{7f}' {
      raise InvalidEndpoint
    }
  }
  if !secure {
    let hostname = if host.has_prefix("[::1]") {
      "[::1]"
    } else {
      host.split(":").to_array()[0].to_owned()
    }
    if hostname != "127.0.0.1" && hostname != "localhost" && hostname != "[::1]" {
      raise InvalidEndpoint
    }
    if hostname == "[::1]" && host != "[::1]" && !host.has_prefix("[::1]:") {
      raise InvalidEndpoint
    }
  }
}

///|
/// Native client. HTTP is permitted only for explicitly enabled loopback mocks.
/// Not safe for concurrent login/logout; serialize session lifecycle operations.
/// Each request opens a bounded, timeout-protected connection and never redirects.
struct Client {
  endpoint : String
  timeout_ms : Int32
  max_response_bytes : Int32
  max_pages : Int32
  trust : @tls.TrustedRoot
  mut token : String?
  mut session_uri : String?
}

///|
/// No connection is made until a method is called. TLS verification cannot be disabled.
pub fn Client::new(
  endpoint : String,
  ca_file? : String,
  allow_loopback_http~ : Bool = false,
  timeout_ms~ : Int32 = 10000,
  max_response_bytes~ : Int32 = 4194304,
  max_pages~ : Int32 = 100,
) -> Client raise RedfishError {
  validate_endpoint(endpoint, allow_loopback_http)
  if timeout_ms <= 0 ||
    max_response_bytes <= 0 ||
    max_response_bytes > 67108864 ||
    max_pages <= 0 ||
    max_pages > 10000 {
    raise InvalidOptions
  }
  {
    endpoint,
    timeout_ms,
    max_response_bytes,
    max_pages,
    trust: match ca_file {
      Some(path) => @tls.CustomPemFile(path)
      None => @tls.SystemRoot
    },
    token: None,
    session_uri: None,
  }
}

///|
async fn Client::request(
  self : Client,
  meth : @http.RequestMethod,
  path : String,
  body? : Json,
) -> (@http.Response, Bytes) {
  validate_path(path)
  @async.with_timeout(self.timeout_ms, async fn() {
    let headers : @http.Headers = {
      "Accept": "application/json",
      "OData-Version": "4.0",
      "User-Agent": "moonredfish/0.1",
      "Accept-Encoding": "identity",
    }
    if self.token is Some(token) {
      headers["X-Auth-Token"] = token
    }
    let conn = @http.Client(self.endpoint, headers~, trust=self.trust)
    defer conn.close()
    let response = if body is Some(body) {
      conn.request(meth, path, extra_headers={
        "Content-Type": "application/json",
      })
      conn.write(body)
      conn.end_request()
    } else {
      conn.request(meth, path)
      conn.end_request()
    }
    let data = @buffer.Buffer()
    while conn.read_some(max_len=8192) is Some(chunk) {
      if chunk.length() > self.max_response_bytes - data.length() {
        raise ResponseTooLarge
      }
      data.write_bytes(chunk)
    }
    (response, data.to_bytes())
  })
}

///|
fn decode_json(data : Bytes) -> Json raise RedfishError {
  @json.parse(@utf8.decode(data), reject_duplicate_keys=true) catch {
    _ => raise InvalidResponse("invalid UTF-8 or JSON")
  }
}

///|
/// GET only; non-2xx responses (including redirects) produce HttpStatus.
pub async fn Client::get(self : Client, path : String) -> Resource {
  let (response, data) = self.request(Get, path)
  if response.code < 200 || response.code >= 300 {
    raise HttpStatus(response.code)
  }
  parse_resource(path, decode_json(data))
}

///|
pub async fn Client::service_root(self : Client) -> Resource {
  self.get("/redfish/v1/")
}

///|
/// Traverses collection pages with a cycle guard and a page limit.
/// No resource GETs are performed; duplicate links are returned only once.
pub async fn Client::members(self : Client, path : String) -> Array[String] {
  let visited : Map[String, Bool] = Map([])
  let seen : Map[String, Bool] = Map([])
  let result = []
  let mut next = Some(path)
  while next is Some(uri) {
    if visited.contains(uri) {
      raise PaginationCycle
    }
    if visited.length() >= self.max_pages {
      raise PageLimit
    }
    visited[uri] = true
    let page = parse_collection(self.get(uri).json)
    for item in page.members {
      if !seen.contains(item) {
        seen[item] = true
        result.push(item)
      }
    }
    next = page.next_link
  }
  result
}

///|
async fn Client::inventory(self : Client, kind : String) -> Array[Resource] {
  let root = self.service_root()
  guard root.link(kind) is Some(path) else {
    raise InvalidResponse("service root link unavailable")
  }
  let result = []
  for uri in self.members(path) {
    result.push(self.get(uri))
  }
  result
}

///|
pub async fn Client::systems(self : Client) -> Array[Resource] {
  self.inventory("Systems")
}

///|
pub async fn Client::chassis(self : Client) -> Array[Resource] {
  self.inventory("Chassis")
}

///|
pub async fn Client::managers(self : Client) -> Array[Resource] {
  self.inventory("Managers")
}

///|
/// Creates one Redfish session. This is the only POST API. The token is held only
/// in memory; credentials are not stored. No retries are made for ambiguous POSTs.
/// Uses the standard Sessions endpoint; some services require authentication to
/// discover SessionService, so login deliberately does not perform discovery.
pub async fn Client::login(
  self : Client,
  username : String,
  password : String,
) -> Unit {
  if self.token is Some(_) {
    raise SessionAlreadyActive
  }
  let (response, _) = self.request(
    Post,
    "/redfish/v1/SessionService/Sessions",
    body={ "UserName": username, "Password": password },
  )
  if response.code != 201 {
    raise HttpStatus(response.code)
  }
  guard response.headers.get("X-Auth-Token") is Some(token) && !token.is_empty() else {
    raise InvalidResponse("session token unavailable")
  }
  for c in token {
    if c <= ' ' || c >= '\u{7f}' {
      raise InvalidResponse("invalid session token")
    }
  }
  guard response.headers.get("Location") is Some(uri) else {
    raise InvalidResponse("session location unavailable")
  }
  validate_session_location(uri)
  self.token = Some(token)
  self.session_uri = Some(uri)
}

///|
/// Deletes only this client's session. On transport failure it remains active
/// locally, allowing the caller to retry logout. A 401/404 means it already expired.
pub async fn Client::logout(self : Client) -> Unit {
  guard self.session_uri is Some(path) else { raise NoSession }
  let (response, _) = self.request(Delete, path)
  if response.code != 200 &&
    response.code != 204 &&
    response.code != 401 &&
    response.code != 404 {
    raise HttpStatus(response.code)
  }
  self.token = None
  self.session_uri = None
}

///|
pub extend RedfishError with @debug.Debug::{to_repr}

///|
fn validate_session_location(uri : String) -> Unit raise RedfishError {
  validate_path(uri)
  let prefix = "/redfish/v1/SessionService/Sessions/"
  if !uri.has_prefix(prefix) || uri.contains("?") {
    raise InvalidResponse("unexpected session location")
  }
  let suffix = uri[prefix.length():]
  if suffix.is_empty() || suffix.contains("/") {
    raise InvalidResponse("expected one session member")
  }
}