defmodule Diff do
  @moduledoc """
  Utilities for comparing build artifacts.
  """

  @known_chunks ~w(
    abstract_code
    debug_info
    attributes
    compile_info
    exports
    labeled_exports
    imports
    indexed_imports
    locals
    labeled_locals
    atoms
  )a

  @doc """
  Compares the build artifacts of two build directories.
  """
  @spec compare_dirs(Path.t(), Path.t()) ::
          {
            only1_paths :: list(Path.t()),
            only2_paths :: list(Path.t()),
            diff :: list({Path.t(), diff :: String.t()})
          }
  def compare_dirs(dir1, dir2) do
    dir1 = Path.expand(dir1)
    dir2 = Path.expand(dir2)

    assert_dir!(dir1)
    assert_dir!(dir2)

    dir1_paths = relative_paths(dir1)
    dir2_paths = relative_paths(dir2)

    only1_paths = dir1_paths -- dir2_paths
    only2_paths = dir2_paths -- dir1_paths
    common_paths = dir1_paths -- only1_paths
    common_files = Enum.reject(common_paths, &File.dir?/1)

    diff =
      Enum.flat_map(common_files, fn path ->
        file1 = Path.join(dir1, path)
        file2 = Path.join(dir2, path)

        case compare_files(file1, file2) do
          :eq -> []
          {:diff, diff} -> [{path, diff}]
        end
      end)

    {only1_paths, only2_paths, diff}
  end

  @doc """
  Compares the contents of two files.

  If the files are BEAM files, it performs a more human-friendly
  "BEAM-diff".
  """
  @spec compare_files(Path.t(), Path.t()) :: :eq | {:diff, diff :: String.t()}
  def compare_files(file1, file2) do
    content1 = File.read!(file1)
    content2 = File.read!(file2)

    if content1 == content2 do
      :eq
    else
      diff =
        if String.ends_with?(file1, ".beam") do
          beam_diff(file1, content1, file2, content2)
        else
          file_diff(file1, file2)
        end

      {:diff, diff}
    end
  end

  defp beam_diff(file1, content1, file2, content2) do
    with {:ok, {module, chunks1}} <- :beam_lib.chunks(content1, @known_chunks),
         {:ok, {^module, chunks2}} <- :beam_lib.chunks(content2, @known_chunks),
         true <- chunks1 != chunks2 do
      for {chunk1, chunk2} <- Enum.zip(chunks1, chunks2), chunk1 != chunk2 do
        tmp_file1 =
          chunk1
          |> inspect(pretty: true, limit: :infinity)
          |> write_tmp()

        tmp_file2 =
          chunk2
          |> inspect(pretty: true, limit: :infinity)
          |> write_tmp()

        file_diff(tmp_file1, tmp_file2)
      end
    else
      _ ->
        tmp_file1 =
          file1
          |> xxd_dump()
          |> write_tmp()

        tmp_file2 =
          file2
          |> xxd_dump()
          |> write_tmp()

        file_diff(tmp_file1, tmp_file2)
    end
  end

  defp xxd_dump(file) do
    {dump, _} = System.cmd("xxd", [file])
    dump
  end

  defp file_diff(file1, file2) do
    {diff, _} = System.cmd("diff", ["--suppress-common-lines", file1, file2])
    diff
  end

  defp relative_paths(dir) do
    dir
    |> Path.join("**")
    |> Path.wildcard()
    |> Enum.map(&Path.relative_to(&1, dir))
  end

  defp assert_dir!(dir) do
    unless File.dir?(dir) do
      raise ArgumentError, "#{inspect(dir)} is not a directory"
    end
  end

  defp write_tmp(content) do
    filename = generate_tmp_filename()
    File.mkdir_p!("tmp")
    File.write!(Path.join("tmp", filename), content)
    Path.join("tmp", filename)
  end

  defp generate_tmp_filename do
    sec = :os.system_time(:second)
    rand = :rand.uniform(999_999_999)
    scheduler_id = :erlang.system_info(:scheduler_id)
    "tmp-#{sec}-#{rand}-#{scheduler_id}"
  end
end

case System.argv() do
  [dir1, dir2] ->
    case Diff.compare_dirs(dir1, dir2) do
      {[], [], []} ->
        IO.puts("#{inspect(dir1)} and #{inspect(dir2)} are equal")

      {only1, only2, diff} ->
        for path <- only1, do: IO.puts("Only in #{dir1}: #{path}")
        for path <- only2, do: IO.puts("Only in #{dir2}: #{path}")
        for {path, diff} <- diff, do: IO.puts("Diff #{path}:\n#{diff}")

        System.halt(1)
    end

  _ ->
    IO.puts("Please, provide two directories as arguments")
    System.halt(1)
end
