defmodule Zig.Module do
  @moduledoc false
  # abstraction representing multiple zig nif functions bound into a single
  # module

  alias Zig.C
  alias Zig.Nif
  alias Zig.Options
  alias Zig.Resources
  alias Zig.Type.Error

  # you'll never see me ever import any other module.
  import Zig.QuoteErl

  # for easy access in EEx files
  @behaviour Access

  @impl true
  defdelegate fetch(function, key), to: Map

  @enforce_keys [:otp_app, :module, :file, :line]

  defstruct @enforce_keys ++
              [
                # user options:
                :precompiled,
                :module_code_path,
                :zig_code_path,
                :version,
                :easy_c,
                :dir,
                :c,
                :nifs,
                # generated by zigler
                :parsed,
                :manifest,
                :manifest_module,
                :sema,
                # defaulted options
                release_mode: {:env, :debug},
                ignore: [],
                packages: [],
                resources: [],
                callbacks: [],
                # assembled from collected `opts` fields
                default_nif_opts: [],
                # generated by zigler
                language: Elixir,
                external_resources: [],
                attributes: [],
                # debug options:  user-supplied, but unchecked.
                dump: false,
                dump_sema: false,
                dump_build_zig: false
              ]

  @type t :: %__MODULE__{
          otp_app: atom,
          module: module,
          file: Path.t(),
          line: pos_integer,
          module_code_path: nil | Path.t(),
          zig_code_path: nil | Path.t(),
          manifest: nil | Manifest.t(),
          manifest_module: nil | module,
          sema: Sema.t(),
          parsed: Parser.t(),
          version: String.t(),
          easy_c: nil | Path.t(),
          dir: Path.t(),
          c: C.t(),
          nifs: {:auto, [Nif.t()]} | [Nif.t()],
          parsed: Parser.t(),
          manifest: nil | Manifest.t(),
          manifest_module: nil | module,
          sema: Sema.t(),
          release_mode: Zig.release_mode(),
          ignore: [atom],
          packages: [Zig.package_spec()],
          resources: [atom],
          callbacks: [on_load: atom, on_upgrade: atom, on_unload: atom],
          default_nif_opts: [cleanup: boolean, leak_check: boolean],
          language: Elixir | :erlang,
          external_resources: [Path.t()],
          attributes: keyword,
          dump: boolean,
          dump_sema: boolean,
          dump_build_zig: boolean | :stdout | :stderr | Path.t()
        }

  @defaultable_nif_opts ~w[cleanup leak_check]a

  @affix %{"Elixir": "`use Zig`", erlang: "`zig_opts(...)`"}

  # this function builds and verifies the module options and turns it into the struct.

  def new(opts, caller) do
    # make sure that the caller has declared otp_app here.
    context =
      case Keyword.fetch(opts, :otp_app) do
        :error ->
          lang = Keyword.fetch!(opts, :language)

          raise CompileError,
            description:
              "(module #{inspect(caller.module)}) you must supply an `otp_app` option to #{@affix[lang]}",
            file: caller.file,
            line: caller.line

        {:ok, otp_app} ->
          Options.initialize_context(caller, otp_app)
      end

    default_nif_opts = Keyword.take(opts, @defaultable_nif_opts)

    common_values = [
      module: caller.module,
      file: caller.file,
      line: caller.line,
      default_nif_opts: default_nif_opts
    ]

    opts
    |> obtain_version
    |> Options.normalize_kw(:c, %C{}, Options.struct_normalizer(C), context)
    |> Options.normalize_kw(
      :callbacks,
      [],
      list_normalizer(&normalize_callback/2, "callback"),
      context
    )
    |> Options.normalize_kw(
      :packages,
      [],
      list_normalizer(&normalize_package/2, "package"),
      context
    )
    |> Options.normalize_path(:dir, context)
    |> Options.normalize_path(:module_code_path, context)
    |> Options.normalize_path(:zig_code_path, context)
    |> Options.normalize_path(:easy_c, context)
    |> Options.normalize_kw(:ignore, [], &normalize_atom_or_atomlist/2, context)
    |> Options.normalize_kw(:resources, [], &normalize_atom_or_atomlist/2, context)
    |> Options.validate(:release_mode, &validate_release_modes/1, context)
    |> Options.validate(:cleanup, :boolean, context)
    |> Options.validate(:leak_check, :boolean, context)
    |> Keyword.drop(@defaultable_nif_opts)
    |> Keyword.merge(common_values)
    |> normalize_nifs(context)
    |> then(&struct!(__MODULE__, &1))
  rescue
    e in KeyError ->
      context = Options.initialize_context(caller, nil)
      lang = Keyword.fetch!(opts, :language)
      Options.raise_with("#{@affix[lang]} was supplied the invalid option `#{e.key}`", context)
  end

  defp obtain_version(opts) do
    otp_app = Keyword.fetch!(opts, :otp_app)

    Keyword.put_new_lazy(opts, :version, fn ->
      cond do
        # try checking the mix project first (this is if the project is being compiled for the first time)
        version = function_exported?(Mix.Project, :config, 0) and Mix.Project.config()[:version] ->
          Version.parse!(version)

        # try checking the application version (this is if we are hot-patching the so file)
        tuple = Application.loaded_applications() |> List.keyfind(otp_app, 0) ->
          tuple
          |> elem(2)
          |> to_string()
          |> Version.parse!()

        :else ->
          Version.parse!("0.0.0")
      end
    end)
  end

  defp list_normalizer(callback, error_msg),
    do: fn
      list, context when is_list(list) ->
        try do
          Enum.map(list, &callback.(&1, context))
        rescue
          _ in FunctionClauseError ->
            Options.raise_with(
              "must be a list of #{error_msg} specifications",
              list,
              context
            )
        end

      other, context ->
        Options.raise_with(
          "must be a list of #{error_msg} specifications",
          other,
          context
        )
    end

  @callbacks ~w[on_load on_upgrade on_unload]a

  defp normalize_callback(callback, _context) when callback in @callbacks,
    do: {callback, callback}

  defp normalize_callback({callback, fun}, _context) when callback in @callbacks and is_atom(fun),
    do: {callback, fun}

  defp normalize_callback({callback, invalid}, context) when callback in @callbacks,
    do: Options.raise_with("must be an atom", invalid, Options.push_key(context, callback))

  defp normalize_callback(other, context),
    do:
      Options.raise_with(
        "must be a keyword list of callback specs",
        other,
        context
      )

  @dependencies_atom_error "must be a list of atoms representing dependencies"
  @package_tuple_error "must be a tuple of the form `{path, [deps...]}`"

  defp normalize_package({k, {path, deps}}, context) when is_atom(k) and is_list(deps) do
    Enum.each(deps, fn dep ->
      is_atom(dep) or
        Options.raise_with(@dependencies_atom_error, dep, Options.push_key(context, k))
    end)

    try do
      {k, {IO.iodata_to_binary(path), deps}}
    rescue
      _ ->
        Options.raise_with(
          @package_tuple_error,
          {:tag, "path", path},
          Options.push_key(context, k)
        )
    end
  end

  defp normalize_package({k, {_, malformed}}, context) when is_atom(k),
    do: Options.raise_with(@dependencies_atom_error, malformed, Options.push_key(context, k))

  defp normalize_package({k, malformed}, context) when is_atom(k),
    do: Options.raise_with(@package_tuple_error, malformed, Options.push_key(context, k))

  defp normalize_package(other, opts),
    do:
      Options.raise_with(
        "must be a list of package specifications",
        other,
        opts
      )

  @atom_or_atomlist_error "must be an atom or a list of atoms"

  defp normalize_atom_or_atomlist(atom, _context) when is_atom(atom), do: [atom]

  defp normalize_atom_or_atomlist(list, context) when is_list(list) do
    Enum.each(list, fn
      item ->
        is_atom(item) or Options.raise_with(@atom_or_atomlist_error, item, context)
    end)

    list
  rescue
    _ in FunctionClauseError -> Options.raise_with(@atom_or_atomlist_error, list, context)
  end

  defp normalize_atom_or_atomlist(other, context),
    do:
      Options.raise_with(
        @atom_or_atomlist_error,
        other,
        context
      )

  defp normalize_nifs(opts, context) do
    common_values = Keyword.take(opts, ~w[module file line module_code_path zig_code_path]a)

    Options.normalize_kw(
      opts,
      :nifs,
      {:auto, []},
      &normalize_nifs(&1, common_values, &2),
      context
    )
  end

  defp normalize_nifs(nifs, common_values, context) when is_list(nifs) do
    Enum.map(nifs, fn
      {atom, opts} when is_atom(atom) and is_list(opts) ->
        Nif.new({atom, common_values ++ opts}, context)

      atom when is_atom(atom) ->
        Nif.new({atom, common_values}, context)

      other ->
        Options.raise_with("", other, context)
    end)
  end

  defp normalize_nifs({:auto, nifs}, common_values, context) do
    {:auto, normalize_nifs(nifs, common_values, context)}
  end

  defp validate_release_modes(mode) when mode in ~w[debug safe fast small env]a, do: :ok
  defp validate_release_modes({:env, mode}) when mode in ~w[debug safe fast small]a, do: :ok

  defp validate_release_modes(other) do
    {:error, "must be one of `:debug`, `:safe`, `:fast`, `:small`, `:env` or `{:env, mode}`",
     other}
  end

  # CODE RENDERING

  # zig file rendering

  require EEx

  nif = Path.join(__DIR__, "templates/module.zig.eex")
  EEx.function_from_file(:def, :render_zig, nif, [:assigns])

  on_load = Path.join(__DIR__, "templates/on_load.zig.eex")
  EEx.function_from_file(:def, :render_on_load, on_load, [:assigns])

  on_upgrade = Path.join(__DIR__, "templates/on_upgrade.zig.eex")
  EEx.function_from_file(:def, :render_on_upgrade, on_upgrade, [:assigns])

  on_unload = Path.join(__DIR__, "templates/on_unload.zig.eex")
  EEx.function_from_file(:def, :render_on_unload, on_unload, [:assigns])

  # internal helpers for rendering the c
  defp table_entries(nifs) when is_list(nifs) do
    nifs
    |> Enum.flat_map(&Nif.table_entries/1)
    |> Enum.join(",")
  end

  @index_of %{major: 0, minor: 1}

  defp nif_version(at) do
    :nif_version
    |> :erlang.system_info()
    |> List.to_string()
    |> String.split(".")
    |> Enum.at(@index_of[at])
  end

  # ast rendering

  def render_elixir(module, zig_code) do
    module_name = "#{module.module}"

    on_load_code =
      if {:__on_load__, 0} in Module.definitions_in(module.module) do
        quote do
          __on_load__()
        end
      else
        0
      end

    external_resources =
      Enum.map(module.external_resources, fn file ->
        quote do
          @external_resource unquote(file)
        end
      end)

    load_nif_fn =
      quote do
        unquote_splicing(external_resources)

        def __load_nifs__ do
          # LOADS the nifs from :code.lib_dir() <> "ebin", which is
          # a path that has files correctly moved in to release packages.
          require Logger

          unquote(module.otp_app)
          |> :code.priv_dir()
          |> Path.join("lib")
          |> Path.join(unquote(module_name))
          |> String.to_charlist()
          |> :erlang.load_nif(unquote(on_load_code))
          |> case do
            :ok ->
              Logger.debug("loaded module at #{unquote(module_name)}")

            error = {:error, any} ->
              Logger.error("loading module #{unquote(module_name)} #{inspect(any)}")
          end
        end
      end

    function_code = Enum.map(module.nifs, &Nif.render_elixir/1)

    manifest_code =
      if Enum.any?(module.nifs, &match?(%Error{}, &1.return.type)) do
        quote do
          require Zig.Manifest
          Zig.Manifest.resolver(unquote(module.manifest), unquote(module.zig_code_path), :defp)
        end
      end

    quote do
      # these two attribs can be persisted for code inspection.
      @zigler_module unquote(Macro.escape(module))
      @zig_code unquote(zig_code)

      unquote_splicing(function_code)
      unquote(load_nif_fn)
      unquote(manifest_code)

      def _format_error(_, [{_, _, _, opts} | _rest] = _stacktrace) do
        if formatted = opts[:zigler_error], do: formatted, else: %{}
      end
    end
  end

  def render_erlang(module, _zig_code) do
    otp_app = module.otp_app
    module_name = Atom.to_charlist(module.module)

    init_function =
      quote_erl(
        """
        '__init__'() ->
          erlang:load_nif(filename:join(code:priv_dir(unquote(otp_app)), unquote(module_id)), []).
        """,
        otp_app: otp_app,
        module_id: ~C'lib/' ++ module_name
      )

    function_code = Enum.map(module.nifs, &Nif.render_erlang/1)

    Enum.flat_map(function_code, & &1) ++ init_function
  end

  # Access behaviour guards
  @impl true
  def get_and_update(_, _, _), do: raise("you should not update a module")

  @impl true
  def pop(_, _), do: raise("you should not pop a module")
end
