lib/foundry/spark_meta/classifier.ex

defmodule Foundry.SparkMeta.Classifier do
  @moduledoc false

  @behaviour SparkMeta.Analyzer

  alias SparkMeta.Analysis

  @impl SparkMeta.Analyzer
  def analyze(context, %Analysis{} = analysis) do
    type =
      cond do
        reactor_module?(context.module) and transfer_module?(analysis) -> :transfer
        reactor_module?(context.module) -> :reactor
        oban_worker?(context.module) -> :job
        trigger_module?(context.module) -> :trigger
        blueprint_module?(context.module) -> :blueprint
        provider_module?(context.module) -> :adapter
        liveview_module?(context.module) -> :page
        liveresource_module?(context.module) -> :liveresource
        agent_module?(context.module) -> :agent
        rule_module?(context.module) -> :rule
        ash_resource?(context.module, analysis) -> :resource
        true -> :resource
      end

    attrs = safe_attributes(context.module)
    extensions = Map.get(analysis.facts, :extensions, [])

    foundry =
      %{
        type: type,
        trigger_kind: if(type == :trigger, do: detect_trigger_kind(context.module), else: nil),
        paper_trail: Enum.any?(extensions, &(to_string(&1) == "Elixir.AshPaperTrail.Resource")),
        archival: Enum.any?(extensions, &(to_string(&1) == "Elixir.AshArchival.Resource")),
        authentication_subject: authentication_subject?(context.module, extensions),
        rate_limited: Enum.any?(extensions, &rate_limit_ext?/1),
        oban_queues: extract_oban_queues(context.module, attrs),
        performs: extract_performs(context.module, attrs),
        last_modified: extract_last_modified(context.module)
      }

    {:ok, Analysis.put_fact(analysis, :foundry_classifier, foundry)}
  end

  defp safe_attributes(module) do
    module.__info__(:attributes)
  rescue
    _ -> []
  end

  defp ash_resource?(module, analysis) do
    Map.get(analysis.facts, :ash_resource, %{})[:attributes] != [] ||
      function_exported?(module, :__ash_resource__, 0)
  rescue
    _ -> false
  end

  defp reactor_module?(module) do
    function_exported?(module, :__reactor__, 0) or function_exported?(module, :reactor, 0)
  rescue
    _ -> false
  end

  defp transfer_module?(analysis) do
    Map.get(analysis.facts, :extensions, [])
    |> Enum.any?(&(to_string(&1) == "Elixir.AshDoubleEntry.Transfers.Transfer"))
  end

  defp trigger_module?(module) do
    module_str = Foundry.SparkMeta.Helpers.format_module_fqn(module)

    String.ends_with?(module_str || "", "Webhook") or
      function_exported?(module, :handle_webhook, 3)
  rescue
    _ -> false
  end

  defp detect_trigger_kind(module) do
    module_str = Foundry.SparkMeta.Helpers.format_module_fqn(module)

    cond do
      String.ends_with?(module_str || "", "Webhook") -> "webhook"
      function_exported?(module, :handle_webhook, 3) -> "webhook"
      true -> nil
    end
  rescue
    _ -> nil
  end

  defp oban_worker?(module) do
    function_exported?(module, :__oban_opts__, 0) or
      Oban.Worker in (safe_attributes(module)[:behaviour] || [])
  rescue
    _ -> false
  end

  defp rule_module?(module) do
    module.__info__(:attributes)
    |> Keyword.get(:behaviour, [])
    |> Enum.any?(fn behaviour ->
      behaviour in [Ash.Policy.Check, Ash.Policy.SimpleCheck] or
        (is_atom(behaviour) and String.ends_with?(Atom.to_string(behaviour), ".Rule"))
    end)
  rescue
    _ -> false
  end

  defp blueprint_module?(module), do: function_exported?(module, :__blueprint__, 0)

  defp provider_module?(module) do
    module.__info__(:attributes)
    |> Keyword.get(:behaviour, [])
    |> Enum.any?(fn behaviour ->
      behaviour_str = to_string(behaviour)
      String.contains?(behaviour_str, ["ProviderAdapter", "Provider"])
    end)
  rescue
    _ -> false
  end

  defp liveview_module?(module) do
    Phoenix.LiveView in (module.__info__(:attributes) |> Keyword.get(:behaviour, []))
  rescue
    _ -> false
  end

  defp liveresource_module?(module) do
    module |> to_string() |> String.contains?("Live")
  rescue
    _ -> false
  end

  defp agent_module?(module) do
    attrs = module.__info__(:attributes)
    Keyword.has_key?(attrs, :agent_type)
  rescue
    _ -> false
  end

  defp authentication_ext?(ext) do
    ext in [AshAuthentication, AshAuthentication.Resource] or
      String.contains?(to_string(ext), "AshAuthentication")
  rescue
    _ -> false
  end

  defp authentication_subject?(module, extensions) do
    Enum.any?(extensions, &authentication_ext?/1) and
      SparkMeta.spark_module?(module) and
      SparkMeta.entities(module, [:authentication, :strategies]) != []
  rescue
    _ -> false
  end

  defp rate_limit_ext?(_ext), do: false

  defp extract_oban_queues(module, attrs) do
    if function_exported?(module, :__oban_opts__, 0) do
      case module.__oban_opts__() |> Keyword.get(:queue) do
        nil -> []
        queue -> [to_string(queue)]
      end
    else
      if Oban.Worker in Keyword.get(attrs, :behaviour, []) do
        extract_oban_queue_from_source(module)
      else
        []
      end
    end
  rescue
    _ -> []
  end

  defp extract_oban_queue_from_source(module) do
    with path when is_binary(path) <- Foundry.SparkMeta.Helpers.module_source_path(module),
         {:ok, source} <- File.read(path),
         [_, queue] <- Regex.run(~r/use\s+Oban\.Worker,\s*queue:\s*:(\w+)/, source) do
      [queue]
    else
      _ -> []
    end
  rescue
    _ -> []
  end

  defp extract_performs(_module, attrs) do
    if Keyword.has_key?(attrs, :performs) or Keyword.has_key?(attrs, :foundry) do
      direct = Foundry.SparkMeta.Helpers.get_attr_raw(attrs, :performs)

      from_foundry =
        with cfg when is_map(cfg) <- Foundry.SparkMeta.Helpers.get_attr_raw(attrs, :foundry),
             value when not is_nil(value) <- Map.get(cfg, :performs) do
          value
        else
          _ -> nil
        end

      case direct || from_foundry do
        value when is_atom(value) -> Foundry.SparkMeta.Helpers.format_module_fqn(value)
        value when is_binary(value) -> value
        _ -> nil
      end
    else
      nil
    end
  rescue
    _ -> nil
  end

  defp extract_last_modified(module) do
    module |> :code.which() |> to_string() |> File.stat!() |> then(& &1.mtime)
  rescue
    _ -> nil
  end
end