lib/ash/flow/chart/mermaid.ex

defmodule Ash.Flow.Chart.Mermaid do
  @moduledoc "Tools to render an Ash.Flow as a mermaid chart."
  @opts [
    expand?: [
      type: :boolean,
      default: true,
      doc: """
      If the flow should be fully expanded (all `run_flow` steps will be inlined)
      """
    ],
    link_to_flows: [
      type: {:fun, 1}
    ]
  ]

  def chart(flow, opts \\ []) do
    opts = Spark.OptionsHelpers.validate!(opts, @opts)

    # This is a hack that may not work forever
    # Eventually, we may need a separate mode/option for `build`
    # that doesn't attempt to substitute arguments into templates
    args =
      flow
      |> Ash.Flow.Info.arguments()
      |> Map.new(fn argument ->
        {argument.name, {:_arg, argument.name}}
      end)

    steps =
      if opts[:expand?] do
        {:ok, %{steps: steps}} = Ash.Flow.Executor.AshEngine.build(flow, args, [])
        unwrap(steps)
      else
        Ash.Flow.Info.steps(flow)
      end

    arguments = flow |> Ash.Flow.Info.arguments()

    init = "flowchart TB"

    init
    |> add_arguments(arguments, flow)
    |> add_steps(steps, steps, opts)
    |> add_links(steps, steps, opts)
    |> IO.iodata_to_binary()
  end

  defp add_arguments(message, arguments, flow) do
    message =
      message
      |> add_line("subgraph Flow")
      |> add_line("direction TB")

    add_line(
      message,
      "_top_level_flow_(\"#{inspect(flow)}#{flow_description(flow)}\")"
    )

    Enum.reduce(arguments, message, fn argument, message ->
      question_mark =
        if argument.allow_nil? do
          "?"
        else
          ""
        end

      add_line(
        message,
        "_arguments.#{argument.name}(\"#{argument.name}#{question_mark}: #{inspect(argument.type)}\")"
      )
    end)
    |> add_line("end")
  end

  defp unwrap(steps) do
    Enum.map(steps, fn %{step: step} ->
      case step do
        %{steps: steps} ->
          %{step | steps: unwrap(steps)}

        step ->
          step
      end
    end)
  end

  defp add_steps(message, steps, all_steps, opts) do
    Enum.reduce(steps, message, fn step, message ->
      case step do
        %Ash.Flow.Step.Map{steps: steps, over: over} = step ->
          id = "#{format_name(step)}.element"

          name = format_name(step)
          template = format_template(over, all_steps)

          message =
            message
            |> add_line("subgraph #{name} [\"Map Over #{template}\"]")
            |> add_line("direction TB")
            |> add_line("#{id}(\"Element in #{template}\")")
            |> add_steps(steps, all_steps, opts)
            |> add_line("end")

          message

        %Ash.Flow.Step.Branch{steps: steps} = step ->
          name = format_name(step)

          message
          |> add_line("subgraph #{name}.subgraph [\"Branch\"]")
          |> add_line("#{format_name(step)}(\"#{short_name(step)} <br/> #{description(step)}\")")
          |> add_line("direction TB")
          |> add_steps(steps, all_steps, opts)
          |> add_line("end")

        %Ash.Flow.Step.Transaction{steps: steps} = step ->
          name = format_name(step)

          message
          |> add_line("subgraph #{name}.subgraph [Transaction]")
          |> add_line("direction TB")
          |> add_steps(steps, all_steps, opts)
          |> add_line("end")

        %Ash.Flow.Step.RunFlow{flow: flow} = step ->
          returns = Ash.Flow.Info.returns(flow)

          if returns && opts[:expand?] do
            escaped_returns = escape(inspect(Ash.Flow.Info.returns(flow)))
            name = format_name(step)

            header =
              if is_atom(returns) do
                "Gather Value"
              else
                "Gather Values"
              end

            message
            |> add_line("#{name}(\"#{header}: #{escaped_returns}\")")
          else
            if opts[:link_to_flows] do
              link = opts[:link_to_flows].(flow)

              message
              |> add_line(
                ~s[#{format_name(step)}("<a href="#{link}">#{inspect(flow)}</a>#{flow_description(flow)}")]
              )
            else
              message
              |> add_line("#{format_name(step)}(\"#{inspect(flow)}#{flow_description(flow)}\")")
            end
          end

        %{input: input} = step when not is_nil(input) ->
          add_line(
            message,
            "#{format_name(step)}(\"#{short_name(step)} <br/> #{description(step)}\")"
          )

        step ->
          add_line(message, "#{format_name(step)}(\"#{short_name(step)}\"#{description(step)})")
      end
    end)
  end

  defp flow_description(flow) do
    case Ash.Flow.Info.description(flow) do
      nil ->
        ""

      description ->
        "<br/>" <> as_html!(description)
    end
  end

  defp description(%{description: description}) when not is_nil(description) do
    "<br/>" <> as_html!(description)
  end

  defp description(%Ash.Flow.Step.Custom{custom: {mod, opts}}) do
    if function_exported?(mod, :describe, 1) do
      case mod.describe(opts) do
        nil ->
          ""

        description ->
          "<br/>" <> as_html!(description)
      end
    else
      ""
    end
  end

  defp description(_), do: ""

  if Code.ensure_loaded?(Earmark) do
    defp as_html!(value) do
      value
      |> escape()
      |> Earmark.as_html!()
      |> String.replace("<br>", "<br/>")
    end
  else
    defp as_html!(value),
      do:
        value
        |> String.replace("\"", "\\\"")
        |> String.split("\n", trim: true)
        |> Enum.join("<br/>")
  end

  defp short_name(%Ash.Flow.Step.Custom{custom: {Ash.Flow.Step.CustomFunction, _}}) do
    "Custom Function"
  end

  defp short_name(%Ash.Flow.Step.Custom{custom: {mod, opts}}) do
    if function_exported?(mod, :short_name, 1) do
      mod.short_name(opts)
    else
      escape(inspect({mod, opts}))
    end
  end

  defp short_name(%Ash.Flow.Step.Branch{steps: steps, output: output, condition: condition}) do
    child_step =
      if output do
        find_step(steps, output)
      else
        List.last(steps)
      end

    "Branch condition: #{escape(inspect(condition))} <br> Branch Result: #{short_name(child_step)}"
  end

  defp short_name(%Ash.Flow.Step.Map{steps: steps, output: output}) do
    child_step =
      if output do
        find_step(steps, output)
      else
        List.last(steps)
      end

    "Element of #{short_name(child_step)}"
  end

  defp short_name(%Ash.Flow.Step.RunFlow{flow: flow}) do
    "Run Flow: #{inspect(flow)}"
  end

  defp short_name(%Ash.Flow.Step.Validate{action: action, resource: resource}) do
    "Validate: #{inspect(resource)}.#{action}"
  end

  defp short_name(%Ash.Flow.Step.Create{action: action, resource: resource}) do
    "Create: #{inspect(resource)}.#{action}"
  end

  defp short_name(%Ash.Flow.Step.Update{action: action, resource: resource}) do
    "Update: #{inspect(resource)}.#{action}"
  end

  defp short_name(%Ash.Flow.Step.Destroy{action: action, resource: resource}) do
    "Destroy: #{inspect(resource)}.#{action}"
  end

  defp short_name(%Ash.Flow.Step.Read{action: action, resource: resource}) do
    "Read: #{inspect(resource)}.#{action}"
  end

  # defp highlight(message, id) do
  #   add_line(message, "style #{id} fill:#4287f5,stroke:#333,stroke-width:4px")
  # end

  def add_links(message, steps, all_steps, opts) do
    steps
    |> do_links(all_steps, opts)
    |> Enum.uniq()
    |> Enum.reject(fn link ->
      [first | rest] = String.split(link, " ")
      last = List.last(rest)
      first == last
    end)
    |> Enum.reduce(message, fn link, message ->
      add_line(message, link)
    end)
  end

  defp do_links(steps, all_steps, opts) do
    Enum.flat_map(steps, fn step ->
      case step do
        %Ash.Flow.Step.Branch{steps: steps, condition: condition} = step ->
          id = "#{format_name(step)}"

          steps
          |> Enum.map(&link(format_name(step), nil, format_name(&1)))
          |> Enum.concat(dependencies(step, all_steps, [condition], id))
          |> Enum.concat(do_links(steps, all_steps, opts))

        %Ash.Flow.Step.Map{steps: steps, over: over} = step ->
          id = "#{format_name(step)}.element"

          dependencies(step, all_steps, [over], id) ++ do_links(steps, all_steps, opts)

        %Ash.Flow.Step.Transaction{steps: steps} = step ->
          dependencies(step, all_steps) ++ do_links(steps, all_steps, opts)

        %Ash.Flow.Step.RunFlow{flow: flow} = run_flow_step ->
          returns = Ash.Flow.Info.returns(flow)
          name = format_name(step)

          returns_links =
            Enum.flat_map(List.wrap(returns), fn
              {key, _} ->
                {source, note} =
                  if opts[:expand?] do
                    link_source(all_steps, List.wrap(step.name) ++ List.wrap(key))
                  else
                    link_source(all_steps, step.name)
                  end

                [link(source, note, name)]

              value ->
                if opts[:expand?] do
                  {source, note} =
                    link_source(all_steps, List.wrap(step.name) ++ List.wrap(value))

                  [link(source, note, name)]
                else
                  []
                end
            end)

          if opts[:expand?] do
            returns_links
          else
            returns_links ++ dependencies(run_flow_step, all_steps)
          end

        step ->
          dependencies(step, all_steps)
      end
    end)
  end

  defp link(source, nil, name) do
    "#{source} --> #{name}"
  end

  defp link(source, note, name) do
    "#{source} --> |#{note}| #{name}"
  end

  defp format_template(template, all_steps) do
    do_format_template(template, all_steps)
  end

  defp do_format_template(template, all_steps) when is_map(template) do
    body =
      Enum.map_join(template, ",<br/>", fn {key, value} ->
        result = do_format_template(key, all_steps)

        if String.starts_with?(result, ":") do
          "#{String.trim_leading(result, ":")}: #{do_format_template(value, all_steps)}"
        else
          "#{inspect(result)} => #{do_format_template(value, all_steps)}"
        end
      end)

    "%{<br/>#{escape(body)}<br/>}"
  end

  defp do_format_template(template, all_steps) when is_list(template) do
    "[#{Enum.map_join(template, ", ", &do_format_template(&1, all_steps))}]"
  end

  defp do_format_template({:_path, value, path}, all_steps) do
    "get_in(#{do_format_template(value, all_steps)}, #{Enum.map_join(path, ", ", &do_format_template(&1, all_steps))})"
  end

  defp do_format_template({:_result, step_name}, all_steps) do
    "result(#{short_name(find_step(all_steps, step_name))})"
  end

  defp do_format_template({:_element, step_name}, all_steps) do
    "element(#{short_name(find_step(all_steps, step_name))})"
  end

  defp do_format_template(value, all_steps) when is_tuple(value) do
    "{#{value |> Tuple.to_list() |> Enum.map_join(", ", &do_format_template(&1, all_steps))}}"
  end

  defp do_format_template(value, _), do: inspect(value)

  defp find_step(steps, name) do
    case do_find_step(steps, name) do
      nil ->
        raise "Could not find step called #{inspect(name)} in #{steps |> step_names() |> Enum.map_join(", ", &inspect/1)}"

      step ->
        step
    end
  end

  defp do_find_step(steps, name) when is_list(steps),
    do: Enum.find_value(steps, &do_find_step(&1, name))

  defp do_find_step(%{name: name} = step, name), do: step
  defp do_find_step(%{steps: steps}, name), do: do_find_step(steps, name)
  defp do_find_step(_, _), do: nil

  defp step_names(steps) do
    Enum.flat_map(steps, fn step ->
      case step do
        %{name: name, steps: steps} ->
          [name | step_names(steps)]

        %{name: name} ->
          [name]
      end
    end)
  end

  defp escape(string) do
    String.replace(string, "\"", "'")
  end

  defp dependencies(step, all_steps, additional_inputs \\ [], name \\ nil) do
    deps =
      Enum.flat_map(Ash.Flow.Executor.AshEngine.deps_keys(), fn key ->
        case Map.fetch(step, key) do
          {:ok, value} ->
            [value]

          :error ->
            []
        end
      end)

    deps(deps ++ additional_inputs, name || format_name(step), all_steps)
  end

  defp deps(template, destination, all_steps) do
    result_refs = Ash.Flow.Template.result_refs(template) |> Enum.uniq()
    arg_refs = Ash.Flow.Template.arg_refs(template) |> Enum.uniq()
    element_refs = Ash.Flow.Template.element_refs(template) |> Enum.uniq()

    element_ref_deps =
      Enum.map(element_refs, fn element ->
        "#{do_format_name(element)}.element --> #{destination}"
      end)

    arg_ref_deps =
      Enum.map(arg_refs, fn arg ->
        "_arguments.#{arg} -.-> #{destination}"
      end)

    result_ref_deps =
      Enum.map(result_refs, fn dep ->
        {source, note} = link_source(all_steps, dep)

        link(source, note, destination)
      end)

    Enum.concat([element_ref_deps, arg_ref_deps, result_ref_deps])
  end

  defp link_source(all_steps, dep, note \\ nil) do
    case find_step(all_steps, dep) do
      %Ash.Flow.Step.Map{steps: steps, output: output} = step ->
        output_step =
          if output do
            find_step(steps, output)
          else
            List.last(steps)
          end

        case output_step do
          nil ->
            {format_name(step), note}

          output_step ->
            link_source(all_steps, output_step.name, "list")
        end

      step ->
        {format_name(step), note}
    end
  end

  defp add_line(message, line) do
    [message, "\n", line]
  end

  defp format_name(step) do
    do_format_name(step.name)
  end

  defp do_format_name(name) do
    name
    |> List.wrap()
    |> List.flatten()
    |> Enum.join(".")
  end
end