defmodule Tarakan.Infestations.SwarmSweep do
  @moduledoc """
  Nightly pass that opens cross-repository check jobs for the widest patterns.

  This is the only way a swarm happens. It replaced a moderator-pressed button,
  which made coverage of the record track browsing habits rather than the
  graph: a pattern nobody opened was never checked, however widely it spread.
  The press also carried no judgement the eligibility rules in
  `Tarakan.Infestations.swarm_candidates/2` do not already apply.

  Off unless an actor is configured. The jobs it opens are spent on other
  contributors' agents and their subscriptions, and `pattern_key` is a
  normalized *title* - contributors decide what groups into one infestation -
  so nothing fans out until an operator names the account the platform speaks
  as:

      config :tarakan, :swarm_sweep,
        actor_handle: "tarakan",
        patterns_per_run: 25,
        jobs_per_run: 40,
        max_jobs_per_pattern: 8,
        min_repos: 3

  `max_jobs_per_pattern` is the bound that matters: a title generic enough to
  span three hundred repositories still only spends eight of the run's budget.
  """

  use Oban.Worker,
    queue: :infestations,
    max_attempts: 3,
    unique: [period: 3600, states: :incomplete]

  alias Tarakan.Accounts
  alias Tarakan.Accounts.Scope
  alias Tarakan.Infestations
  alias Tarakan.Policy

  require Logger

  @defaults [
    actor_handle: nil,
    patterns_per_run: 25,
    jobs_per_run: 40,
    max_jobs_per_pattern: 8,
    min_repos: 3,
    days: 90
  ]

  @impl Oban.Worker
  def perform(%Oban.Job{}) do
    config = config()

    case actor(config[:actor_handle]) do
      {:ok, scope} -> sweep(scope, config)
      {:error, reason} -> skip(reason, config[:actor_handle])
    end
  end

  @doc "Effective settings, application config merged over the defaults."
  def config do
    Keyword.merge(@defaults, Application.get_env(:tarakan, :swarm_sweep, []))
  end

  defp sweep(scope, config) do
    patterns =
      Infestations.list_infestations(
        min_repos: config[:min_repos],
        days: config[:days],
        limit: config[:patterns_per_run]
      )

    result =
      Enum.reduce(patterns, %{budget: config[:jobs_per_run], opened: 0, patterns: 0, held: 0}, fn
        pattern, acc ->
          sweep_pattern(scope, pattern, acc, config)
      end)

    # A silently truncated sweep reads like full coverage. Say what was left.
    Logger.info(
      "swarm sweep: opened #{result.opened} job(s) across #{result.patterns} pattern(s); " <>
        "#{result.held} pattern(s) still cooling; #{result.budget} of " <>
        "#{config[:jobs_per_run]} job budget unspent"
    )

    :ok
  end

  defp sweep_pattern(_scope, _pattern, %{budget: budget} = acc, _config) when budget <= 0, do: acc

  defp sweep_pattern(scope, pattern, acc, config) do
    limit = min(config[:max_jobs_per_pattern], acc.budget)

    case Infestations.swarm_check_jobs(scope, pattern.pattern_key, limit: limit) do
      {:ok, %{opened: 0}} ->
        acc

      {:ok, %{opened: opened}} ->
        %{
          acc
          | budget: acc.budget - opened,
            opened: acc.opened + opened,
            patterns: acc.patterns + 1
        }

      {:error, {:cooldown, _seconds}} ->
        %{acc | held: acc.held + 1}

      {:error, reason} ->
        Logger.warning("swarm sweep: #{pattern.pattern_key} failed: #{inspect(reason)}")
        acc
    end
  end

  defp actor(handle) when is_binary(handle) and handle != "" do
    case Accounts.get_account_by_handle(handle) do
      nil ->
        {:error, :actor_not_found}

      account ->
        scope = Scope.for_account(account)
        if Policy.moderator?(scope), do: {:ok, scope}, else: {:error, :actor_not_moderator}
    end
  end

  defp actor(_handle), do: {:error, :not_configured}

  # Never an Oban failure: a sweep with no actor is a deployment that has not
  # opted in, not a job worth retrying.
  defp skip(:not_configured, _handle), do: :ok

  defp skip(reason, handle) do
    Logger.warning("swarm sweep skipped (#{reason}): actor_handle=#{inspect(handle)}")
    :ok
  end
end
