// Reusable containers (experimental).
//
// Mirrors the `withReuse` feature of the official testcontainers libraries:
// the whole container configuration is hashed, the hash is stored as a
// docker label, and `start` re-attaches to an existing container with the
// same hash instead of creating a new one. Because the lookup goes through
// the docker daemon, reuse works across `moon test` processes and across
// separate test runs — which is exactly what is needed, since MoonBit tests
// have no teardown hook and no shared in-process lifecycle.
//
// Reusable containers carry no session label, so the resource reaper
// (reaper.mbt) never deletes them; they survive until `Container::remove`
// (or `docker rm`) is called explicitly.

///|
/// Is container reuse enabled? Like the official implementations this is an
/// opt-in per environment (`TESTCONTAINERS_REUSE_ENABLE`), meant for local
/// development loops — on CI every run starts fresh anyway.
fn reuse_enabled() -> Bool {
  env_truthy("TESTCONTAINERS_REUSE_ENABLE")
}

///|
/// Set once the "reuse disabled" hint has been printed; it is only shown
/// once per test process, like the official implementations.
let reuse_hint_printed : Ref[Bool] = Ref(false)

///|
/// Warn once that `with_reuse` was requested but reuse is not enabled in
/// this environment.
fn warn_reuse_disabled() -> Unit {
  if !reuse_hint_printed.val {
    reuse_hint_printed.val = true
    println(
      "testcontainers: `with_reuse` ignored; set TESTCONTAINERS_REUSE_ENABLE=true to enable reusable containers",
    )
  }
}

///|
/// Is the environment variable `name` set to a truthy value? Accepts `true`
/// (any casing) and `1`.
fn env_truthy(name : String) -> Bool {
  match @env.get_env_var(name) {
    Some(value) => {
      let normalized = ascii_lower(value.trim().to_owned())
      normalized == "true" || normalized == "1"
    }
    None => false
  }
}

///|
/// ASCII-lowercase a string. Docker label values and env vars are ASCII in
/// practice, so no full Unicode case mapping is needed.
fn ascii_lower(text : String) -> String {
  let out : Array[Char] = []
  for c in text.to_array() {
    if c >= 'A' && c <= 'Z' {
      out.push(('a'.to_int() + c.to_int() - 'A'.to_int()).to_char().unwrap())
    } else {
      out.push(c)
    }
  }
  String::from_array(out)
}

///|
/// Start this request in reuse mode: re-attach to an existing container with
/// the same configuration, or create one. The container keeps running after
/// the test; `Container::cleanup` leaves it alone and `Container::remove`
/// deletes it explicitly.
async fn ContainerRequest::start_reusable(
  self : ContainerRequest,
) -> Container raise TestContainerError {
  self.ensure_image()
  let hash = self.reuse_hash()
  for attempt = 0; attempt < 50; attempt = attempt + 1 {
    match find_containers("\{LABEL_REUSE_HASH}=\{hash}", all=true) {
      [id, ..] =>
        match self.adopt(id) {
          Some(container) => return container
          // Not adoptable (vanished or refused to start); `adopt` already
          // removed broken leftovers, so fall through to the create path.
          None => ()
        }
      [] => ()
    }
    match self.create_reusable(hash) {
      Some(container) => return container
      // Lost the name race; the winner's container shows up in the next
      // lookup round.
      None => @async.sleep(100)
    }
  }
  raise UnexpectedOutput(
    "could not start reusable container `\{self.image}`: still racing after 5s",
  )
}

///|
/// Attach to an existing container: (re)start it when needed, resolve its
/// port bindings and apply the request's wait strategy. Returns `None` when
/// the container vanished or cannot be started — broken leftovers are
/// removed so the create path can replace them.
async fn ContainerRequest::adopt(
  self : ContainerRequest,
  id : String,
) -> Container? raise TestContainerError {
  match container_status(id) {
    None => None
    Some(status) => {
      if status != "running" {
        let output = docker_command(["start", id])
        if output.code != 0 {
          let _ = docker_command(["rm", "--force", "--volumes", id])
          return None
        }
      }
      let container : Container = {
        id,
        request: self,
        ports: [],
        host: docker_host(),
        reusable: true,
      }
      container.resolve_ports()
      if self.wait != WaitStrategy::NoWait {
        container.wait_ready()
      }
      Some(container)
    }
  }
}

///|
/// Create the reusable container. Returns `None` on a name conflict, meaning
/// another test process won the race and its container will appear in the
/// next lookup; real failures raise. The deterministic name (the reuse hash
/// when the request has no explicit name) is what deduplicates concurrent
/// creators.
async fn ContainerRequest::create_reusable(
  self : ContainerRequest,
  hash : String,
) -> Container? raise TestContainerError {
  let name = match self.name {
    Some(name) => name
    None => "testcontainers-mbt-reuse-\{hash}"
  }
  let labels = self.labels.copy()
  labels.push((LABEL_REUSE_HASH, hash))
  let request : ContainerRequest = { ..self, name: Some(name), labels, }
  let args = request.run_args()
  let output = docker_command(args)
  if output.code != 0 {
    if command_error_text(output).contains("already in use") {
      return None
    }
    raise DockerCommandFailed(
      command=render_command("docker", args),
      code=output.code,
      stderr=command_error_text(output),
    )
  }
  let id = parse_container_id(output.stdout)
  let container : Container = {
    id,
    request,
    ports: [],
    host: docker_host(),
    reusable: true,
  }
  let prepared : Result[Unit, TestContainerError] = Ok(
    {
      container.resolve_ports()
      if request.wait != WaitStrategy::NoWait {
        container.wait_ready()
      }
    },
  ) catch {
    e => Err(e)
  }
  match prepared {
    Ok(_) => Some(container)
    Err(e) => {
      // Never leave a failed fresh container behind: it would poison the
      // hash lookup of every future run.
      let _ = docker_command(["rm", "--force", "--volumes", id])
      raise e
    }
  }
}

///|
/// A stable hash of everything that defines the container, used as the
/// `org.testcontainers.mbt.reuse-hash` label value. The rendered
/// `docker run` arguments are hashed rather than struct fields, so every
/// option that changes the container is covered automatically. `reuse` is
/// forced on so the session label — different per test process — is left
/// out of the hash.
fn ContainerRequest::reuse_hash(self : ContainerRequest) -> String {
  fnv1a_hex({ ..self, reuse: true, }.run_args())
}

///|
/// FNV-1a (64-bit) over the UTF-16 code units of the joined arguments,
/// rendered as 16 lowercase hex digits. A self-contained hash is used
/// instead of `Hash` so the value is stable across toolchain versions and
/// processes — it is what lets a later `moon test` run find the container a
/// previous run created.
fn fnv1a_hex(args : Array[String]) -> String {
  let mut hash = 0xcbf29ce484222325UL
  for arg in args {
    for c in arg.to_array() {
      hash = (hash ^ c.to_int().to_uint64()) * 0x100000001b3UL
    }
    hash = (hash ^ 0xffUL) * 0x100000001b3UL
  }
  hex64(hash)
}

///|
/// Render a `UInt64` as exactly 16 lowercase hex digits.
fn hex64(value : UInt64) -> String {
  let digits = "0123456789abcdef".to_array()
  let out : Array[Char] = []
  for i = 15; i >= 0; i = i - 1 {
    let nibble = ((value >> (i * 4)) & 0xfUL).to_int()
    out.push(digits[nibble])
  }
  String::from_array(out)
}

///|
/// Ids of containers carrying `label` (`name=value`). `all` includes stopped
/// containers.
async fn find_containers(
  label : String,
  all~ : Bool,
) -> Array[String] raise TestContainerError {
  let args : Array[String] = ["ps"]
  args.push(if all { "-aq" } else { "-q" })
  // Full 64-char ids, so an adopted container reports the same id as the
  // one `docker run` originally printed.
  args.push("--no-trunc")
  args.push("--filter")
  args.push("label=\{label}")
  let stdout = docker(args)
  let ids : Array[String] = []
  for line in stdout.split("\n") {
    let id = line.trim().to_owned()
    if id != "" {
      ids.push(id)
    }
  }
  ids
}

///|
/// The docker-reported state of a container (`running`, `exited`, ...), or
/// `None` when it does not exist.
async fn container_status(id : String) -> String? raise TestContainerError {
  let output = docker_command([
    "inspect", "--type", "container", "--format", "{{.State.Status}}", id,
  ])
  if output.code != 0 {
    None
  } else {
    Some(output.stdout.trim().to_owned())
  }
}

///|
/// Make sure a container is running: start it when it is stopped. Returns
/// `false` when it cannot be started (or no longer exists).
async fn ensure_started(id : String) -> Bool raise TestContainerError {
  match container_status(id) {
    Some("running") => true
    Some(_) => {
      let output = docker_command(["start", id])
      if output.code != 0 {
        let _ = docker_command(["rm", "--force", "--volumes", id])
        return false
      }
      true
    }
    None => false
  }
}