lib/mix/tasks/preview_rendering.ex

defmodule Mix.Tasks.Preview.Rendering do
  @moduledoc """
  Previews notification rendering through the production library pipeline.

  The canonical preview path is the `Chimeway.preview_rendering/3` API function.
  This task is a data-only wrapper for CLI convenience.

  For advanced executable Elixir preview experimentation, use `mix run -e` or `iex -S mix`
  rather than passing executable inputs to this task.

  Usage:

      mix preview.rendering --notifier MyApp.Notifiers.CommentCreated \\
        --params-json '{"id": 1}' \\
        --recipient-json '{"id": "recipient-1"}' \\
        --channel email
  """

  use Mix.Task

  @shortdoc "Preview one channel rendering without dispatching provider traffic"

  @switches [
    notifier: :string,
    channel: :string,
    params_json: :string,
    params_file: :string,
    param: :keep,
    recipient_json: :string,
    recipient_file: :string,
    recipient_field: :keep,
    help: :boolean
  ]

  @impl Mix.Task
  def run(argv) do
    case OptionParser.parse(argv, strict: @switches) do
      {[help: true], _, _} ->
        Mix.shell().info(usage())

      {opts, _, []} ->
        with :ok <- validate_required_switches(opts),
             {:ok, notifier} <- parse_notifier(Keyword.fetch!(opts, :notifier)),
             {:ok, params} <- build_map_input(opts, :params_file, :params_json, :param),
             {:ok, recipient} <-
               build_map_input(opts, :recipient_file, :recipient_json, :recipient_field),
             {:ok, preview} <-
               Chimeway.preview_rendering(
                 notifier,
                 params,
                 recipient: recipient,
                 channel: Keyword.fetch!(opts, :channel)
               ) do
          Mix.shell().info(format_preview(preview))
        else
          {:error, reason} ->
            Mix.shell().error(format_error(reason))
            exit({:shutdown, 1})
        end

      {_opts, _args, invalid} ->
        Mix.shell().error("Unknown options: #{Enum.join(invalid, ", ")}\n\n#{usage()}")
        exit({:shutdown, 1})
    end
  end

  defp validate_required_switches(opts) do
    has_notifier = Keyword.has_key?(opts, :notifier)
    has_channel = Keyword.has_key?(opts, :channel)

    has_params =
      Keyword.has_key?(opts, :params_json) or Keyword.has_key?(opts, :params_file) or
        Keyword.has_key?(opts, :param)

    has_recipient =
      Keyword.has_key?(opts, :recipient_json) or Keyword.has_key?(opts, :recipient_file) or
        Keyword.has_key?(opts, :recipient_field)

    if has_notifier and has_channel and has_params and has_recipient do
      :ok
    else
      {:error, :missing_required_options}
    end
  end

  defp parse_notifier(value) when is_binary(value) do
    module_name =
      if String.starts_with?(value, "Elixir.") do
        value
      else
        "Elixir." <> value
      end

    try do
      module = String.to_existing_atom(module_name)

      case Code.ensure_loaded(module) do
        {:module, ^module} -> {:ok, module}
        _ -> {:error, {:invalid_notifier, value}}
      end
    rescue
      ArgumentError -> {:error, {:invalid_notifier, value}}
    end
  end

  defp build_map_input(opts, file_key, json_key, kv_key) do
    with {:ok, from_file} <- parse_file_json(Keyword.get(opts, file_key)),
         {:ok, from_json} <- parse_inline_json(Keyword.get(opts, json_key)),
         {:ok, from_kv} <- parse_kv_pairs(Keyword.get_values(opts, kv_key)) do
      merged =
        from_file
        |> Map.merge(from_json)
        |> Map.merge(from_kv)

      {:ok, merged}
    end
  end

  defp parse_file_json(nil), do: {:ok, %{}}

  defp parse_file_json(path) do
    case File.read(path) do
      {:ok, content} ->
        case Jason.decode(content) do
          {:ok, term} when is_map(term) -> {:ok, term}
          {:ok, _other} -> {:error, {:invalid_json_map, :file, path}}
          {:error, _reason} -> {:error, {:invalid_json, :file, path}}
        end

      {:error, reason} ->
        {:error, {:file_read_error, path, reason}}
    end
  end

  defp parse_inline_json(nil), do: {:ok, %{}}

  defp parse_inline_json(content) do
    case Jason.decode(content) do
      {:ok, term} when is_map(term) -> {:ok, term}
      {:ok, _other} -> {:error, {:invalid_json_map, :inline}}
      {:error, _reason} -> {:error, {:invalid_json, :inline}}
    end
  end

  defp parse_kv_pairs([]), do: {:ok, %{}}

  defp parse_kv_pairs(pairs) do
    Enum.reduce_while(pairs, {:ok, %{}}, fn pair, {:ok, acc} ->
      case String.split(pair, "=", parts: 2) do
        [key, value] -> {:cont, {:ok, Map.put(acc, key, value)}}
        _ -> {:halt, {:error, {:invalid_kv_pair, pair}}}
      end
    end)
  end

  defp format_preview(preview) do
    """
    Preview rendering
    render_key: #{preview.render_key}
    render_version: #{preview.render_version}
    channel: #{preview.channel}
    render_data: #{inspect(preview.render_data, pretty: true)}
    """
    |> String.trim_trailing()
  end

  defp format_error(:missing_required_options) do
    "Missing required options.\n\nYou must provide --notifier, --channel, and at least one source for params and recipient.\n\n#{usage()}"
  end

  defp format_error({:invalid_json_map, source}) do
    "Preview rendering failed: JSON from #{source} must be a map/object"
  end

  defp format_error({:invalid_json_map, source, path}) do
    "Preview rendering failed: JSON from #{source} (#{path}) must be a map/object"
  end

  defp format_error({:invalid_json, source, path}) do
    "Preview rendering failed: Invalid JSON syntax in #{source} (#{path})"
  end

  defp format_error({:invalid_json, source}) do
    "Preview rendering failed: Invalid JSON syntax in #{source}"
  end

  defp format_error(reason), do: "Preview rendering failed: #{inspect(reason)}"

  defp usage do
    """
    Usage: mix preview.rendering --notifier MODULE --channel CHANNEL [DATA FLAGS]

    Required options:
      --notifier          notifier module name, for example MyApp.Notifiers.CommentCreated
      --channel           channel name to preview, for example email or in_app

    Data flags (at least one for params, one for recipient):
      --params-json       Inline JSON string for params
      --params-file       Path to JSON file for params
      --param             Key=value pair (can be repeated)

      --recipient-json    Inline JSON string for recipient
      --recipient-file    Path to JSON file for recipient
      --recipient-field   Key=value pair (can be repeated)
    """
    |> String.trim_trailing()
  end
end