defmodule Lissome.GleamProjectNotFoundError do
  defexception [:message]
end

defmodule Lissome.GleamBuilder do
  @moduledoc """
  Wrapper around the Gleam build tool.

  This module provides functionality to build Gleam source files to either JavaScript or Erlang targets using the Gleam CLI.
  """

  @gleam_pattern "**/*.gleam"

  alias Lissome.Utils

  defguard is_valid_target(target) when target in ["javascript", "erlang"]

  @doc """
  Builds Gleam source files to the specified target using the `gleam build` command.

  The target can be `:javascript`, `:erlang`, or a list of both.

  Returns `:ok`.

  ## Options

    * `:gleam_dir` - Path to the Gleam project. Defaults to the `:gleam_dir` config in `lissome`, or `"assets/lustre_app"` if not set.
    * `:compile_package` - If `true`, uses `gleam compile-package` instead of `gleam build` (default: `false`).
    * `:watch` - If `true`, watches for file changes and rebuilds automatically. Requires the `FileSystem` package to be available (default: `false`).
    * `:load_beam_modules` - If `true` and the target is `:erlang`, loads compiled modules automatically (default: `true`).
    * `:erlang_outdir` - Custom output directory for Erlang beam files (only used with the `:erlang` target). Defaults to `"{build_path}/lib/_{gleam_app}/"`, where `gleam_app` is the name from `gleam.toml`.

  ## Examples

      iex> Lissome.GleamBuilder.build_gleam(:erlang)

      iex> Lissome.GleamBuilder.build_gleam([:javascript, :erlang])

      iex> Lissome.GleamBuilder.build_gleam(:javascript,
      ...>   gleam_dir: "assets/my_gleam_app",
      ...>   compile_package: true,
      ...>   watch: true
      ...> )
  """

  def build_gleam(target, opts \\ [])

  def build_gleam(targets, opts) when is_list(targets) and is_list(opts) do
    Enum.each(targets, &build_gleam(&1, opts))
  end

  def build_gleam(target, opts) when is_list(opts) do
    gleam_dir =
      opts[:gleam_dir] || Utils.gleam_dir_path()

    gleam_src = Path.join(gleam_dir, "src")

    gleam_app =
      opts[:gleam_app] || Utils.gleam_app() ||
        raise Lissome.GleamProjectNotFoundError, """
        Gleam project not found. Please set the following in your config/config.exs file:

            config :lissome, gleam_dir: "path/to/my_gleam_project"
        """

    gleam_files =
      gleam_src
      |> Path.join(@gleam_pattern)
      |> Path.wildcard()

    gleam? =
      File.exists?(gleam_src) and not Enum.empty?(gleam_files)

    if gleam? do
      opts = opts |> Keyword.put(:gleam_dir, gleam_dir) |> Keyword.put(:gleam_app, gleam_app)
      build(target, opts)
    end

    :ok
  end

  defp build(target, opts)
       when is_atom(target) do
    target = Atom.to_string(target)
    build(target, opts)
  end

  defp build(target, opts)
       when is_binary(target)
       when is_valid_target(target) do
    compile_package? = Keyword.get(opts, :compile_package, false)
    load_beam_modules? = Keyword.get(opts, :load_beam_modules, true)
    watch? = Keyword.get(opts, :watch, false)

    gleam_dir = Keyword.fetch!(opts, :gleam_dir)
    gleam_app = Keyword.fetch!(opts, :gleam_app)

    args =
      if compile_package?,
        do: compile_package_args(target, gleam_app),
        else: ["build", "--target", target]

    {_, exit_code} = cmd("gleam", args, cd: gleam_dir)

    if load_beam_modules? and exit_code == 0 and target == "erlang" do
      erlang_outdir =
        Mix.Project.build_path()
        |> Path.join(opts[:erlang_outdir] || "lib/#{gleam_app}/")

      compile_and_load_erlang_modules(gleam_dir, gleam_app, erlang_outdir)
    end

    if watch? and Code.ensure_loaded?(Lissome.GleamReloader) do
      apply(Lissome.GleamReloader, :register_target, [target, gleam_dir])
    end

    :ok
  end

  defp compile_package_args(target, gleam_app) do
    build = "build/dev/#{target}"
    out = Path.join(build, gleam_app)

    target_specific_args =
      case target do
        "erlang" -> ["--no-beam"]
        "javascript" -> ["--javascript-prelude", build]
      end

    [
      "compile-package",
      "--target",
      target,
      "--package",
      ".",
      "--out",
      out,
      "--lib",
      build
    ] ++ target_specific_args
  end

  defp cmd(command, args, opts) do
    opts =
      Keyword.merge(
        opts,
        into: IO.stream(:stdio, :line),
        stderr_to_stdout: true
      )

    System.cmd(command, args, opts)
  end

  defp compile_and_load_erlang_modules(gleam_dir, gleam_app, outdir) do
    gleam_build_path = Path.join(gleam_dir, "build/dev/erlang/#{gleam_app}/")
    gleam_artefacts_path = Path.join(gleam_build_path, "_gleam_artefacts/")
    gleam_hrl_path = Path.join(gleam_build_path, "include")

    ebin = Path.join(outdir, "ebin") |> String.to_charlist()
    include = Path.join(outdir, "include") |> String.to_charlist()

    File.mkdir_p!(ebin)
    File.mkdir_p!(include)

    gleam_artefacts_path
    |> Path.join("**/*.erl")
    |> Path.wildcard()
    |> Enum.reject(&String.ends_with?(&1, "@@compile.erl"))
    |> Enum.map(&compile_file(&1, ebin))
    |> :code.atomic_load()

    hrl_files =
      gleam_hrl_path
      |> Path.join("**/*.hrl")
      |> Path.wildcard()

    if not Enum.empty?(hrl_files) do
      Enum.each(hrl_files, fn file ->
        base_name = Path.basename(file)
        File.cp_r!(file, Path.join(include, base_name))
      end)
    end

    Code.prepend_path(ebin, cache: true)

    :ok
  end

  defp compile_file(file, outdir) do
    file = String.replace(file, ".erl", "") |> String.to_charlist()

    opts = [
      :report_errors,
      :report_warnings,
      {:outdir, outdir}
    ]

    case :compile.file(file, opts) do
      {:ok, module} ->
        :code.purge(module)
        module

      _other ->
        raise ErlangError, "Failed to compile #{file} file"
    end
  end
end
