defmodule Edeliver.Relup.RunnableInstruction do
  @moduledoc """
    This module can be used to provide custom instructions executed during the upgrade.

    They can be used in implementations of the `Edeliver.Relup.Modification` behaviours.
    A runnable instruction  must implement a `c:Edeliver.Relup.RunnableInstruction.run/1` function which will be executed
    during the upgrade on the nodes.

    Example:

      defmodule Acme.Relup.PingNodeInstruction do
        use Edeliver.Relup.RunnableInstruction

        def modify_relup(instructions = %Instructions{up_instructions: up_instructions}, _config = %{}) do
          node_name = :"node@host"
          %{instructions|
            up_instructions:   [call_this([node_name]) | instructions.up_instructions],
            down_instructions: [call_this([node_name]) | instructions.down_instructions]
          }
        end

        # executed during hot code upgrade from relup file
        def run(_options = [node_name]) do
          :net_adm.ping(node_name)
        end

        # actually implemented already in this module
        def call_this(arguments) do
          # creates a relup instruction to call `run/1` of this module
          {:apply, {__MODULE__, :run, arguments}}
        end

      end

      # using the instruction
      defmodule Acme.Relup.Modification do
        use Edeliver.Relup.Modification

        def modify_relup(instructions = %Instructions{}, config = %{}) do
          instructions |> Edeliver.Relup.DefaultModification.modify_relup(config) # use default modifications
                       |> Acme.Relup.PingNodeInstruction.modify_relup(config) # apply also custom instructions
        end
      end

  """
  require Logger
  import Edeliver.Relup.ShiftInstruction, only: [
    ensure_module_loaded_before_first_runnable_instructions: 3,
    ensure_module_unloaded_after_last_runnable_instruction: 3,
  ]
  alias Edeliver.Relup.Instructions


  @doc """
    The function to run during hot code upgrade on nodes.

    If it throws an error before the `point_of_no_return` the
    upgrade is aborted. If it throws an error and was executed
    after that point, the release is restarted
  """
  @callback run(options::[term]) :: :ok

  @doc """
    Returns a function which inserts the relup instruction

    that calls the `c:Edeliver.Relup.RunnableInstruction.run/1` function of this module.
    Default is inserting it at the end of the instructions
  """
  @callback insert_where() :: ((%Edeliver.Relup.Instructions{}, Edeliver.Relup.Instruction.instruction) -> %Edeliver.Relup.Instructions{})

  @doc """
    Returns the arguments which will be passed the `c:Edeliver.Relup.RunnableInstruction.run/1` function during the upgrade.

    Default is an empty list.
  """
  @callback arguments(instructions::%Edeliver.Relup.Instructions{}, config::Edeliver.Relup.Config.t) :: [term]

  @doc """
    Returns a list of module names which implement the behaviour `Edeliver.Relup.RunnableInstruction`

    and are used / referenced by this runnable instruction. These modules must be loaded before this instruction
    is executed for upgrades and unloaded after this instruction for downgrades. Default is an empty list.
  """
  @callback dependencies() :: [instruction_module::atom]

  @doc """
    Logs the message of the given type on the node

    which executes the upgrade and displays it as output of
    the `$APP/bin/$APP upgrade $RELEASE` command. The message is
    prefixed with a string derived from the message type.
  """
  @spec log_in_upgrade_script(type:: :error|:warning|:info|:debug, message::String.t) :: no_return
  def log_in_upgrade_script(type, message) do
    message = String.to_charlist(message)
    prefix = case type do
      :error   -> '---> X '
      :warning -> '---> ! '
      :info    -> '---> '
      _        -> '----> ' # debug
    end
    format_in_upgrade_script('~s~s~n', [prefix, message])
  end

  @doc """
    Formats and prints the message on the node

    running the upgrade script which was started by the
    `$APP/bin/$APP upgrade $RELEASE` command.
  """
  @spec format_in_upgrade_script(format::charlist, arguments::[term]) :: no_return
  def format_in_upgrade_script(format, arguments) do
    :erlang.nodes |> Enum.filter(fn node ->
      Regex.match?(~r/upgrader_\d+/, Atom.to_string(node))
    end) |> Enum.each(fn node ->
      :rpc.cast(node, :io, :format, [:user, format, arguments])
    end)
  end

  @doc """
    Logs an error using the `Logger` on the running node which is upgraded.

    In addition the same error message is logged on the node which executes
    the upgrade and is displayed as output of the
    `$APP/bin/$APP upgrade $RELEASE` command.
  """
  @spec error(message::String.t) :: no_return
  def error(message) do
    Logger.error message
    log_in_upgrade_script(:error, message)
  end

  @doc """
    Logs a warning using the `Logger` on the running node which is upgraded.

    In addition the same warning message is logged on the node which executes
    the upgrade and is displayed as output of the
    `$APP/bin/$APP upgrade $RELEASE` command.
  """
  @spec warn(message::String.t) :: no_return
  def warn(message) do
    Logger.warn message
    log_in_upgrade_script(:warning, message)
  end

  @doc """
    Logs an info message using the `Logger` on the running node which is upgraded.

    In addition the same info message is logged on the node which executes
    the upgrade and is displayed as output of the
    `$APP/bin/$APP upgrade $RELEASE` command.
  """
  @spec info(message::String.t) :: no_return
  def info(message) do
    Logger.info message
    log_in_upgrade_script(:info, message)
  end

  @doc """
    Logs a debug message using the `Logger` on the running node which is upgraded.

    In addition the same debug message is logged on the node which executes
    the upgrade and is displayed as output of the
    `$APP/bin/$APP upgrade $RELEASE` command.
  """
  @spec debug(message::String.t) :: no_return
  def debug(message) do
    Logger.debug message
    log_in_upgrade_script(:debug, message)
  end

  @doc """
    Ensures that all `Edeliver.Relup.RunnableInstruction` modules used / referenced by this instruction
    and returned by the `c:Edeliver.Relup.RunnableInstruction.dependencies/0` callback are loaded before this instruction is executed
    during the upgrade.
  """
  @spec ensure_dependencies_loaded_before_instruction_for_upgrade(instructions::Instructions.t, runnable_instruction::{:apply, {module::atom, :run, arguments::[term]}}, dependencies::[instruction_module::atom]) :: Instructions.t
  def ensure_dependencies_loaded_before_instruction_for_upgrade(instructions = %Instructions{}, call_this_instruction, dependencies) do
    dependencies |> Enum.reduce(instructions, fn(dependency, instructions_acc = %Instructions{up_instructions: up_instructions}) ->
      %{instructions_acc| up_instructions: ensure_module_loaded_before_first_runnable_instructions(up_instructions, call_this_instruction, dependency)}
    end)
  end

  @doc """
    Ensures that all `Edeliver.Relup.RunnableInstruction` modules used / referenced by this instruction
    and returned by the `c:Edeliver.Relup.RunnableInstruction.dependencies/0` callback are unloaded after this instruction is executed
    during the downgrade.
  """
  @spec ensure_dependencies_unloaded_after_instruction_for_downgrade(instructions::Instructions.t, runnable_instruction::{:apply, {module::atom, :run, arguments::[term]}}, dependencies::[instruction_module::atom]) :: Instructions.t
  def ensure_dependencies_unloaded_after_instruction_for_downgrade(instructions = %Instructions{}, call_this_instruction, dependencies) do
    dependencies |> Enum.reduce(instructions, fn(dependency, instructions_acc = %Instructions{down_instructions: down_instructions}) ->
      %{instructions_acc| down_instructions: ensure_module_unloaded_after_last_runnable_instruction(down_instructions, call_this_instruction, dependency)}
    end)
  end

  @doc """
    Assumes that the pattern matches or throws an error with the given error message.

    The error message is logged as error to the logfile
    using the `Logger` and displayed as error output by the
    `$APP/bin/$APP upgrade $RELEASE` task using the
    `$APP/ebin/install_upgrade.escript` script. If the pattern matches
    the variables from the matching are assigned.
  """
  defmacro assume({:=, _, [left, right]} = assertion, error_message) do
    code = Macro.escape(assertion)

    left = Macro.expand(left, __CALLER__)
    vars = collect_vars_from_pattern(left)

    quote do
      right = unquote(right)
      expr  = unquote(code)
      unquote(vars) =
        case right do
          unquote(left) ->
            unquote(vars)
          _ ->
            error unquote(error_message)
            # error is shown as erlang term in the upgrade script
            # `$APP/ebin/install_upgrade.escript`. so use an erlang
            # string as error message
            throw {:error, String.to_charlist(unquote(error_message))}
        end
      right
    end
  end

  # Used by the assume macro for pattern assignment
  defp collect_vars_from_pattern(expr) do
    {_, vars} =
      Macro.prewalk(expr, [], fn
        {:::, _, [left, _]}, acc ->
          {[left], acc}
        {skip, _, [_]}, acc when skip in [:^, :@] ->
          {:ok, acc}
        {:_, _, context}, acc when is_atom(context) ->
          {:ok, acc}
        {name, _, context}, acc when is_atom(name) and is_atom(context) ->
          {:ok, [{name, [generated: true], context}|acc]}
        node, acc ->
          {node, acc}
      end)
    Enum.uniq(vars)
  end

  @doc false
  defmacro __using__(_opts) do
    quote do
      use Edeliver.Relup.Instruction
      import Edeliver.Relup.RunnableInstruction
      @behaviour Edeliver.Relup.RunnableInstruction
      alias Edeliver.Relup.Instructions
      require Logger

      def modify_relup(instructions = %Instructions{}, config = %{}) do
        call_this_instruction = call_this(arguments(instructions, config))
        insert_where_fun = insert_where()
        instructions |> insert_where_fun.(call_this_instruction)
                     |> ensure_module_loaded_before_instruction(call_this_instruction, __MODULE__)
                     |> ensure_dependencies_loaded_before_instruction_for_upgrade(call_this_instruction, dependencies())
                     |> ensure_dependencies_unloaded_after_instruction_for_downgrade(call_this_instruction, dependencies())
      end

      @spec arguments(%Edeliver.Relup.Instructions{}, Edeliver.Relup.Config.t) :: term
      def arguments(%Edeliver.Relup.Instructions{}, %{}), do: []

      @spec insert_where()::Instruction.insert_fun
      def insert_where, do: &append/2

      @spec dependencies() :: [instruction_module::atom]
      def dependencies, do: []


      defoverridable [modify_relup: 2, insert_where: 0, arguments: 2, dependencies: 0]

      @doc """
        Calls the `run/1` function of this module

        from the  relup file during hot code upgrade
      """
      @spec call_this(arguments::[term]) :: Instruction.instruction|Instruction.instructions
      def call_this(arguments \\ []) do
        {:apply, {__MODULE__, :run, [arguments]}}
      end

    end # quote

  end # defmacro __using__


end
