defmodule Bakeware.Assembler do
  @moduledoc false
  defstruct [
    :start_command,
    :compress?,
    :compression_level,
    :cpio,
    :launcher,
    :name,
    :output,
    :path,
    :release,
    :rel_path,
    :trailer
  ]

  @type t() :: %__MODULE__{
          start_command: binary(),
          compress?: boolean(),
          compression_level: 1..19,
          cpio: Path.t(),
          launcher: Path.t(),
          name: String.t(),
          output: Path.t(),
          path: Path.t(),
          release: Mix.Release.t(),
          rel_path: Path.t(),
          trailer: Path.t()
        }

  alias Bakeware.CPIO

  @doc false
  @spec assemble(Mix.Release.t()) :: Mix.Release.t()
  def assemble(%Mix.Release{} = release) do
    %__MODULE__{name: release.name, rel_path: release.path, release: release}
    |> do_assemble()
    # Assembler requires %Mix.Release{} struct returned
    |> Map.get(:release)
  end

  @doc false
  @spec assemble(Path.t(), String.t()) :: Mix.Release.t()
  def assemble(path, name) do
    %__MODULE__{name: name, rel_path: Path.expand(path)}
    |> do_assemble()
    |> Map.get(:release)
  end

  @doc false
  @spec executable_name(term()) :: binary()
  def executable_name(base_name) do
    string_base_name = to_string(base_name)

    case :os.type() do
      {:win32, _} -> string_base_name <> ".exe"
      _ -> string_base_name
    end
  end

  defp create_paths(assembler) do
    bake_path = Path.dirname(assembler.rel_path) |> Path.join("bakeware")
    tmp_name = :crypto.strong_rand_bytes(16) |> Base.encode16()

    _ = File.mkdir_p!(bake_path)

    %{
      assembler
      | path: bake_path,
        cpio: Path.join(bake_path, "#{tmp_name}.cpio"),
        launcher: Application.app_dir(:bakeware, ["launcher", "launcher"]),
        output: Path.join(bake_path, executable_name(assembler.name)),
        trailer: Path.join(bake_path, "#{tmp_name}.trailer")
    }
  end

  defp do_assemble(assembler) do
    IO.puts("""
    #{IO.ANSI.green()}* assembling#{IO.ANSI.default_color()} bakeware #{assembler.name}
    """)

    assembler
    |> create_paths()
    |> set_compression()
    |> set_start_command()
    |> add_start_script()
    |> add_start_batch()
    |> CPIO.build()
    |> build_trailer()
    |> concat_files()
    |> cleanup_files()
  end

  defp add_start_script(assembler) do
    start_path = Path.join(assembler.rel_path, "start")
    start_script_path = "bin/#{assembler.name}"

    script = """
    #!/bin/sh
    SELF=$(readlink "$0" || true)
    if [ -z "$SELF" ]; then SELF="$0"; fi
    ROOT="$(cd "$(dirname "$SELF")" && pwd -P)"

    $ROOT/#{start_script_path} $1
    """

    File.write!(start_path, script)
    File.chmod!(start_path, 0o755)

    assembler
  end

  defp add_start_batch(assembler) do
    start_path = Path.join(assembler.rel_path, "start.bat")
    start_script_path = "bin/#{assembler.name}"

    script = """
    @echo off
    setlocal enabledelayedexpansion

    set ROOT="%~dp0"
    %ROOT%/#{start_script_path} %1
    """

    File.write!(start_path, script)
    File.chmod!(start_path, 0o755)

    assembler
  end

  defp build_trailer(assembler) do
    # maybe stream here to be more efficient
    hash = :crypto.hash(:sha, File.read!(assembler.cpio))
    cmd_len = byte_size(assembler.start_command)
    cmd_padding = :binary.copy(<<0>>, 12 - cmd_len)
    offset = File.stat!(assembler.launcher).size
    cpio_size = File.stat!(assembler.cpio).size

    compression = if assembler.compress?, do: 1, else: 0
    trailer_version = 1
    flags = 0

    trailer_bin =
      <<hash::20-bytes, assembler.start_command::binary, cmd_padding::binary, cpio_size::32,
        offset::32, flags::16, compression::8, trailer_version::8, "BAKE">>

    File.write!(assembler.trailer, trailer_bin)
    assembler
  end

  defp cleanup_files(assembler) do
    _ = File.rm_rf!(assembler.cpio)
    _ = File.rm_rf!(assembler.trailer)

    IO.puts("Bakeware successfully assembled executable at:\n")
    IO.puts("    #{Path.relative_to(assembler.output, File.cwd!())}")

    assembler
  end

  defp concat_files(assembler) do
    _ =
      case :os.type() do
        {:win32, :nt} ->
          :os.cmd(
            'type "#{win_path(assembler.launcher)}" "#{win_path(assembler.cpio)}" "#{win_path(assembler.trailer)}" > "#{win_path(assembler.output)}"'
          )

        _ ->
          :os.cmd(
            'cat #{assembler.launcher} #{assembler.cpio} #{assembler.trailer} > #{assembler.output}'
          )
      end

    File.chmod!(assembler.output, 0o755)
    assembler
  end

  defp win_path(path), do: String.replace(path, "/", "\\")

  defp set_compression(assembler) do
    ##
    # TODO: Make compression required and move this
    compress? =
      case System.find_executable("zstd") do
        nil ->
          # no compression
          IO.puts("""
          #{IO.ANSI.yellow()}* warning#{IO.ANSI.default_color()} [Bakeware] zstd not installed. Skipping compression...
          """)

          false

        _path ->
          true
      end

    check_invalid_option!(assembler.release, :compression_level)

    compression_level = assembler.release.options[:bakeware][:compression_level] || 15

    if compression_level not in 1..19 do
      Mix.raise(
        "[Bakeware] invalid zstd compression level - Must be an integer 1-19. Got: #{inspect(compression_level)}"
      )
    end

    %{assembler | compression_level: compression_level, compress?: compress?}
  end

  defp set_start_command(assembler) do
    check_invalid_option!(assembler.release, :start_command)
    command = assembler.release.options[:bakeware][:start_command] || "start"
    cmd_len = byte_size(command)

    if cmd_len > 12 do
      err = """
      [Bakeware] invalid start command. See the mix release documentation for options

        https://hexdocs.pm/mix/Mix.Tasks.Release.html#module-bin-release_name-commands
      """

      Mix.raise(err)
    end

    %{assembler | start_command: command}
  end

  defp check_invalid_option!(release, option) do
    if value = release.options[option] do
      Mix.raise("""
      [Bakeware] setting :#{option} in the release options outside of the :bakeware key is no longer supported.

      Please adjust your release options in mix.exs to continue:

        def release do
          [
            ...
            steps: [&Bakeware.assemble/1, :assemble],
            bakeware: [#{option}: #{inspect(value)}]
            ...
          ]
        end
      """)
    end

    :ok
  end
end
