.cr
Crystal
(text/x-crystal)
require "docker"
require "crystar"

require "./logger"

# --- Command struct with Docker interactions ---
struct Command
  # include Gitfoss::Ci::Runner::Logger

  image : String
  name : String
  env : Hash(String, String)
  commands : Array(String)
  cached : Array(String)
  shell : String
  workdir : String
  artifacts_to_set : Hash(String, String)   # was Stash
  artifacts_to_get : Hash(String, String)   # was UnStash

  def initialize(
    @image : String,
    @name : String,
    @env = {} of String => String,
    @commands = [] of String,
    @cached = [] of String,
    @shell = "",
    @workdir = "",
    @artifacts_to_set = {} of String => String,
    @artifacts_to_get = {} of String => String,
  )
    log_pipe "Command initialized:\n- image: #{@image}\n- name: #{@name}\n- env: #{@env}\n- commands: #{@commands}\n- cached: #{@cached}\n- shell: #{@shell}\n- workdir: #{@workdir}\n- artifacts_to_set: #{@artifacts_to_set}\n- artifacts_to_get: #{@artifacts_to_get}"
  end

  def run(
    docker : Docker::Client,
    project : Project,
    pipeline : Pipeline,
    stage : Stage,
  )
    exit_code = 0
    binds = [] of String
    container = nil

    begin
      # log_pipe "Env. vars parsing ..."
      env_array = [] of String
      @env.each do |k, v|
        val = v
        if val.size > 0 && val[0] == '$'
          key = val[1..-1]?
          val = key ? ENV[key]? || "" : ""
        end
        env_array << "#{k}=#{val}"
      end
      log_pipe "Env. vars set up"
    rescue ex : Exception
      err_pipe "Failure: env_array", ex
      exit 1
    end

    begin
      # log_pipe "Binds parsing ..."
      workdir_local = @workdir
      idx = workdir_local.index(":")
      if idx && idx > 0
        host_path = File.realpath(workdir_local[0...idx])
        workdir_local = workdir_local[(idx + 1)..-1]?
        if workdir_local
          binds << "#{host_path}:#{workdir_local}"
        end
      end
      log_pipe "Workdir bind set up"
    rescue ex : Exception
      err_pipe "Failure: workdir.binds", ex
      exit 1
    end

    begin
      log_pipe "Cache volumes parsing ..."
      @cached.each do |path|
        vid = "cache_volume"
        # todo(docker): fork docker shard and add missing modules (volumes, archives, etc)
        volumes = docker.volumes.list(filters: { "name" => [vid] }) rescue [] of Docker::Api::Models::Volume
        if volumes.size == 0
          docker.volumes.create(name: vid)
        end
        log_pipe "Mounted volume: #{vid}:#{path}"
        binds << "#{vid}:#{path}"
      end
      log_pipe "Cache volumes set up"
    rescue ex : Exception
      err_pipe "Failure: cache volumes", ex
      exit 1
    end

    begin
      log_pipe "Pulling image #{@image} ..."
      docker.images.pull(@image)
      log_pipe "Pulled image #{@image}"
    rescue ex : Exception
      err_pipe "Failure: docker.images.pull: cannot pull #{@image}", ex
      exit 1
    end

    begin
      log_pipe "Creating container ..."
      container = docker.containers.create(
        image: @image,
        name: @name,
        host_config: {
          binds: binds
        },
        tty: true,
        attach_stdout: true,
        attach_stderr: true,
        working_dir: workdir_local,
        cmd: ["/tmp/script.sh"],
        env: env_array,
      )
      log_pipe "Container created: #{container.id}"
    rescue ex : Exception
      err_pipe "Failure: docker.containers.create", ex
      exit 1
    end

    begin
      # log_pipe "Send pipeline script to container ..."
      tar_bytes = create_commands_tar
      container.archive.put("/tmp", tar_bytes)
      log_pipe "Written pipeline script in container: /tmp/script.sh"
    rescue ex : Exception
      err_pipe "Failure: container.archive.put", ex
      exit 1
    end

      # begin
      #   container.start
      #   container.exec(["chmod", "a+x", "/tmp/script.sh"])
      # rescue e : Exception
      #   puts "script chmod failed: #{e.message}\n"
      #   exit 1
      # end

    begin
      # log_pipe "Reloading container ..."
      container.reload!
      workdir_actual = container.attrs.config.working_dir
      log_pipe "Changed workdir to: #{workdir_actual}"
      # log_pipe "Container reloaded: #{container.id}"
    rescue ex : Exception
      err_pipe "Failure: container.reload", ex
      exit 1
    end

    begin
      log_pipe "Getting artifacts ..."
      @artifacts_to_get.each do |name, path|
        data = pipeline.get_artifact(name)
        if data.nil?
          raise "Failure: pipeline.get_artifact: #{name} not found"
        end

        dest = path
        dest = "#{workdir_actual}/#{path}" if path[0]? != '/'

        container.archive.put(dest, data)
      end
      log_pipe "Artifacts retrieved!"
    rescue ex : Exception
      err_pipe "Failure: container.archive.put", ex
      exit 1
    end

    # start log streamer
    # spawn do
    #   begin
    #     if container.responds_to?(:logs)
    #       container.logs(follow: true, stdout: true, stderr: true) do |chunk|
    #         STDOUT.print chunk
    #         STDOUT.flush
    #       end
    #     else
    #       # todo(docker): fork docker shard and add missing modules (volumes, archives, api, etc)
    #       # docker.api.server_logs(container.id, follow: true, stdout: true, stderr: true) do |chunk|
    #       #   STDOUT.print chunk
    #       #   STDOUT.flush
    #       # end
    #     end
    #   rescue ex
    #     STDERR.puts "log stream error: #{ex.message}"
    #   end
    # end

    begin
      log_pipe "Starting container ..."
      container.start
      log_pipe "Container started: #{container.id}"
    rescue ex : Exception
      log_pipe "Failed to start container: #{ex.message}"
      exit 1
    end

    begin
      log_pipe "Waiting for container to exit ..."
      exit_code = container.wait
      log_pipe "Container exited: #{exit_code} (#{container.id})"
    rescue ex : Exception
      log_pipe "Failed to wait for container: #{ex.message}"
      exit 1
    end

    begin
      log_pipe "Fetching container logs ..."
      logs = container.logs
      log_pipe "Container logs:\n#{logs}"
    rescue ex : Exception
      log_pipe "Failed to fetch container logs: #{ex.message}"
      exit 1
    end

    if exit_code != 0
      raise Exception.new("Stage #{stage.name} didn't complete.")
    end

    begin
      # log_pipe "Reloading container ..."
      container.reload!
      log_pipe "Container reloaded: #{container.id}"
    rescue ex : Exception
      log_pipe "Failed to reload container: #{ex.message}"
      exit 1
    end

    begin
      # log_pipe "Setting artifacts ..."
      @artifacts_to_set.each do |artifact_name, path|
        tar_bytes_from_container = container.archive.get(path)
        pipeline.set_artifact(
          artifact_name,
          tar_bytes_from_container
        )
      end
      log_pipe "Artifacts set: #{@artifacts_to_set.size}"
    rescue ex : Exception
      err_pipe "Failed to set artifacts:", ex
      exit 1
    end

    if container
      begin
        # log_pipe "Stopping container ..."
        container.stop rescue nil
        log_pipe "Stopped container: #{container.id}"
      ensure
        # log_pipe "Removing container ..."
        container.remove(v: true, force: true) rescue nil
        log_pipe "Removed container: #{container.id}"
      end
    end

    exit_code
  end

  def create_commands_tar : Bytes
    buf = IO::Memory.new
    Crystar::Writer.open(buf) do |tw|
      script = create_script
      hdr = Crystar::Header.new(
        name: "script.sh",
        size: script.size.to_i64,
        mode: 0o644 | 0o111, # results in 0o755
        # uname: "user",
        # gname: "group",
        format: Crystar::Format::USTAR,
      )
      tw.write_header(hdr)
      tw.write(script.to_slice)
    end
    buf.to_s.to_slice
  end

  def create_script : Bytes
    sh = @shell.size > 0 ? @shell : "sh"
    sb = String.build do |io|
      io << "#! /bin/#{sh}\n"
      io << "set -e\n"
      # io << "set -x\n"
      @commands.each do |c|
        io << c
        io << "\n"
      end
    end
    sb.to_slice
  end

  def to_s : String
    s = "Command:#{@image}:#{@shell}:cmds["
    @commands.each_with_index do |c, i|
      s << (i > 0 ? "," : "")
      s << c
    end
    s << "]"

    unless @env.empty?
      s << ":env["
      i = 0
      @env.each do |k, v|
        s << (i > 0 ? "," : "")
        i += 1
        s << "#{k}=#{v}"
      end
      s << "]"
    end

    unless @cached.empty?
      s << ":cached["
      @cached.each_with_index do |c, i|
        s << (i > 0 ? "," : "")
        s << c
      end
      s << "]"
    end
    s
  end
end