src/testcontainer_compose.gleam

import cowl
import fio
import gleam/dict
import gleam/dynamic/decode
import gleam/int
import gleam/json
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/result
import testcontainer/formula
import testcontainer_compose/error

pub opaque type ComposeConfig {
  ComposeConfig(
    file_path: String,
    project_name: String,
    env_overrides: List(#(String, cowl.Secret(String))),
  )
}

pub opaque type ComposeServices {
  ComposeServices(
    file_path: String,
    project_name: String,
    services: List(Service),
  )
}

pub type Service {
  Service(name: String, image: String, ports: List(#(Int, Int)))
}

pub fn from_file(file_path: String) -> ComposeConfig {
  ComposeConfig(
    file_path: file_path,
    project_name: "testcontainer",
    env_overrides: [],
  )
}

pub fn with_project_name(
  cfg: ComposeConfig,
  project_name: String,
) -> ComposeConfig {
  ComposeConfig(..cfg, project_name: project_name)
}

pub fn with_env_override(
  cfg: ComposeConfig,
  key: String,
  value: String,
) -> ComposeConfig {
  let secret = cowl.secret(value)
  let overrides = list.append(cfg.env_overrides, [#(key, secret)])
  ComposeConfig(..cfg, env_overrides: overrides)
}

pub fn formula(
  cfg: ComposeConfig,
) -> Result(
  formula.StandaloneFormula(ComposeServices, error.Error),
  error.Error,
) {
  use services <- result.try(load_compose_file(cfg))
  let stack =
    ComposeServices(
      file_path: cfg.file_path,
      project_name: cfg.project_name,
      services: services,
    )
  let env_pairs = reveal_env_overrides(cfg.env_overrides)
  Ok(
    formula.new_standalone(
      fn() {
        compose_up(cfg.file_path, cfg.project_name, env_pairs)
        |> result.map(fn(_) { stack })
        |> result.map_error(fn(reason) {
          error.ComposeFailed(path: cfg.file_path, reason: reason)
        })
      },
      fn() {
        compose_down(cfg.file_path, cfg.project_name)
        |> result.map(fn(_) { Nil })
        |> result.map_error(fn(reason) {
          error.ComposeFailed(path: cfg.file_path, reason: reason)
        })
      },
    ),
  )
}

/// Spin up a compose stack, run the body, and always tear it down.
pub fn with_compose(
  cfg: ComposeConfig,
  body: fn(ComposeServices) -> Result(a, error.Error),
) -> Result(a, error.Error) {
  use services <- result.try(load_compose_file(cfg))
  let env_pairs = reveal_env_overrides(cfg.env_overrides)
  use _ <- result.try(
    compose_up(cfg.file_path, cfg.project_name, env_pairs)
    |> result.map_error(fn(reason) {
      error.ComposeFailed(path: cfg.file_path, reason: reason)
    }),
  )

  let stack =
    ComposeServices(
      file_path: cfg.file_path,
      project_name: cfg.project_name,
      services: services,
    )

  let body_result = body(stack)
  let down_result =
    compose_down(cfg.file_path, cfg.project_name)
    |> result.map(fn(_) { Nil })
    |> result.map_error(fn(reason) {
      error.ComposeFailed(path: cfg.file_path, reason: reason)
    })
  case body_result, down_result {
    Ok(_), Error(e) -> Error(e)
    _, _ -> body_result
  }
}

@external(erlang, "testcontainer_compose_ffi", "run_compose_up")
fn compose_up(
  file_path: String,
  project_name: String,
  env_overrides: List(#(String, String)),
) -> Result(String, String)

@external(erlang, "testcontainer_compose_ffi", "run_compose_down")
fn compose_down(
  file_path: String,
  project_name: String,
) -> Result(String, String)

pub fn services(stack: ComposeServices) -> List(Service) {
  stack.services
}

pub fn service_by_name(
  stack: ComposeServices,
  name: String,
) -> Option(Service) {
  case list.find(stack.services, fn(svc) { svc.name == name }) {
    Ok(svc) -> Some(svc)
    Error(_) -> None
  }
}

pub fn service_name(svc: Service) -> String {
  svc.name
}

pub fn service_image(svc: Service) -> String {
  svc.image
}

pub fn service_ports(svc: Service) -> List(#(Int, Int)) {
  svc.ports
}

fn load_compose_file(cfg: ComposeConfig) -> Result(List(Service), error.Error) {
  case fio.exists(cfg.file_path) {
    False -> Error(error.ComposeFileNotFound(path: cfg.file_path))
    True -> {
      let env_pairs = reveal_env_overrides(cfg.env_overrides)
      use json_str <- result.try(
        compose_config_json(cfg.file_path, env_pairs)
        |> result.map_error(fn(reason) {
          error.InvalidYaml(path: cfg.file_path, reason: reason)
        }),
      )
      parse_services_json(cfg.file_path, json_str)
    }
  }
}

@external(erlang, "testcontainer_compose_ffi", "compose_config_json")
fn compose_config_json(
  file_path: String,
  env_overrides: List(#(String, String)),
) -> Result(String, String)

fn reveal_env_overrides(
  overrides: List(#(String, cowl.Secret(String))),
) -> List(#(String, String)) {
  list.map(overrides, fn(pair) {
    let #(key, secret) = pair
    #(key, cowl.reveal(secret))
  })
}

/// Parse a `docker compose config --format json` output string into typed
/// `Service` records. Exposed for testability — useful when you have a
/// pre-rendered compose JSON and want to validate parsing without Docker.
///
/// `path` is only used to enrich error messages.
pub fn parse_services_json(
  path: String,
  json_str: String,
) -> Result(List(Service), error.Error) {
  let decoder = {
    use services_dict <- decode.field(
      "services",
      decode.dict(decode.string, decode.dynamic),
    )
    decode.success(services_dict)
  }
  case json.parse(from: json_str, using: decoder) {
    Ok(services_dict) ->
      case dict.size(services_dict) {
        0 -> Error(error.InvalidYaml(path: path, reason: "no services defined"))
        _ -> Ok(services_from_dict(services_dict))
      }
    Error(_) ->
      Error(error.InvalidYaml(
        path: path,
        reason: "compose config json parse failed",
      ))
  }
}

fn services_from_dict(
  services_dict: dict.Dict(String, decode.Dynamic),
) -> List(Service) {
  services_dict
  |> dict.to_list
  |> list.map(fn(entry) {
    let #(name, svc_dyn) = entry
    let image = decode_service_image(svc_dyn)
    let ports = decode_service_ports(svc_dyn)
    Service(name: name, image: image, ports: ports)
  })
}

fn decode_service_image(svc_dyn: decode.Dynamic) -> String {
  let decoder = decode.field("image", decode.string, decode.success)
  case decode.run(svc_dyn, decoder) {
    Ok(image) -> image
    Error(_) -> ""
  }
}

fn decode_service_ports(svc_dyn: decode.Dynamic) -> List(#(Int, Int)) {
  let port_decoder = {
    use target <- decode.field("target", decode.int)
    use published <- decode.optional_field(
      "published",
      target,
      decode.one_of(decode.int, [
        decode.string |> decode.then(parse_int_string),
      ]),
    )
    decode.success(#(published, target))
  }
  let decoder = decode.field("ports", decode.list(port_decoder), decode.success)
  case decode.run(svc_dyn, decoder) {
    Ok(ports) -> ports
    Error(_) -> []
  }
}

fn parse_int_string(s: String) -> decode.Decoder(Int) {
  case int.parse(s) {
    Ok(n) -> decode.success(n)
    Error(_) -> decode.failure(0, "Int")
  }
}