///|
/// A container owned by a Compose project. This handle does not own cleanup.
struct Container {
  container_id : String
  host : String
}

///|
pub fn Container::id(self : Self) -> String {
  self.container_id
}

///|
/// Docker host without a port or IPv6 brackets, not the container's private IP.
pub fn Container::get_host(self : Self) -> String {
  self.host
}

///|
async fn Container::network_settings(self : Self) -> Json {
  let result = docker_checked([
    "inspect",
    "--type",
    "container",
    "--format",
    "{{json .NetworkSettings}}",
    self.container_id,
  ])
  @json.parse(@utf8.decode(result.0))
}

///|
/// Published bindings: "8080/tcp" -> [(host IP, host port), ...].
/// Exposed but unpublished ports have an empty array. Ports remain strings.
pub async fn Container::ports(
  self : Self,
) -> Map[String, Array[(String, String)]] {
  parse_ports(self.network_settings())
}

///|
/// Accepts "8080" (TCP), "8080/tcp" or "8080/udp". None means no IPv4 binding.
/// Multiple distinct IPv4 host ports raise an error; use ports() to select one.
pub async fn Container::get_host_port_ipv4(
  self : Self,
  port : String,
) -> String? {
  find_port(self.ports(), port, ipv6=false)
}

///|
/// None means no IPv6 binding. Docker command failures are raised separately.
pub async fn Container::get_host_port_ipv6(
  self : Self,
  port : String,
) -> String? {
  find_port(self.ports(), port, ipv6=true)
}

///|
/// IPv4 address on the default bridge, or the sole attached network.
/// Multiple non-default networks are ambiguous and raise an error.
/// A private container IP may not be reachable from the host on Docker Desktop.
pub async fn Container::get_bridge_ip_address(self : Self) -> String? {
  let settings = self.network_settings()
  guard settings is { "Networks": Object(networks), .. } else {
    raise DockerError::UnexpectedOutput("missing NetworkSettings.Networks")
  }
  let selected = if networks.get("bridge") is Some(bridge) {
    Some(bridge)
  } else {
    guard networks.length() <= 1 else {
      raise DockerError::UnexpectedOutput(
        "multiple networks; no unambiguous bridge address",
      )
    }
    networks.values().next()
  }
  if selected is Some({ "IPAddress": String(ip), .. }) && ip != "" {
    Some(ip)
  } else {
    None
  }
}

///|
fn parse_ports(settings : Json) -> Map[String, Array[(String, String)]] raise {
  guard settings is { "Ports": ports, .. } else {
    raise DockerError::UnexpectedOutput("missing NetworkSettings.Ports")
  }
  if ports is Null {
    return {}
  }
  guard ports is Object(ports) else {
    raise DockerError::UnexpectedOutput("expected a port mapping")
  }
  let result = Map([])
  for internal_port, bindings in ports {
    let mapped = []
    result.set(internal_port, mapped)
    if bindings is Null {
      continue
    }
    guard bindings is Array(bindings) else {
      raise DockerError::UnexpectedOutput("expected port bindings array")
    }
    for binding in bindings {
      guard binding
        is { "HostIp": String(host_ip), "HostPort": String(host_port), .. } else {
        raise DockerError::UnexpectedOutput("invalid port binding")
      }
      let port = @string.parse_int(host_port)
      guard port > 0 && port <= 65535 else {
        raise DockerError::UnexpectedOutput("invalid host port: \{host_port}")
      }
      mapped.push((host_ip, host_port))
    }
  }
  result
}

///|
fn find_port(
  ports : Map[String, Array[(String, String)]],
  port : String,
  ipv6~ : Bool,
) -> String? raise {
  let internal = if port.contains("/") { port } else { "\{port}/tcp" }
  let mut found = None
  if ports.get(internal) is Some(bindings) {
    for (host_ip, host_port) in bindings {
      if host_ip.contains(":") == ipv6 {
        if found is Some(previous) && previous != host_port {
          raise DockerError::UnexpectedOutput(
            "multiple host ports for \{internal}; use ports() to select a binding",
          )
        }
        found = Some(host_port)
      }
    }
  }
  found
}