defmodule Tarakan.Billing.AlertWorker do
  @moduledoc """
  Daily watchlist alerts (09:00 UTC via the Oban Cron plugin).

  For every watchlist with `notify` enabled and at least one entry, finds
  canonical findings in `verified`/`fixed` state on repositories matching the
  watchlist's `owner/name` entries, first seen since `last_notified_at` (or
  the last 7 days on the first run). Matching watchlists get one digest email
  and `last_notified_at` is stamped; watchlists without matches are skipped
  silently.

  Delivery is free for every account.
  """

  use Oban.Worker, queue: :billing, max_attempts: 3

  import Ecto.Query, warn: false

  alias Tarakan.Billing.{Notifier, Watchlist}
  alias Tarakan.Repo
  alias Tarakan.Repositories.Repository
  alias Tarakan.Scans.{CanonicalFinding, FindingRegression}

  @first_run_lookback_days 7
  @alerted_statuses ~w(verified fixed)

  @impl true
  def perform(_job) do
    Watchlist
    |> where([watchlist], watchlist.notify)
    |> where([watchlist], fragment("cardinality(?) > 0", watchlist.entries))
    |> preload(:account)
    |> Repo.all()
    |> Enum.each(&deliver_alert/1)

    :ok
  end

  defp deliver_alert(%Watchlist{} = watchlist) do
    since =
      watchlist.last_notified_at ||
        DateTime.add(DateTime.utc_now(), -@first_run_lookback_days * 24 * 60 * 60, :second)

    matching =
      watchlist.entries
      |> Enum.map(&String.split(&1, "/", parts: 2))
      |> Enum.reduce(dynamic([_finding, repository], false), fn [owner, name], dynamic ->
        dynamic(
          [_finding, repository],
          ^dynamic or (repository.owner == ^owner and repository.name == ^name)
        )
      end)

    findings =
      Repo.all(
        from finding in CanonicalFinding,
          join: repository in Repository,
          on: repository.id == finding.repository_id,
          where: finding.status in ^@alerted_statuses,
          where: finding.inserted_at >= ^since,
          where: ^matching,
          order_by: [asc: finding.id],
          limit: 50,
          select: %{
            public_id: finding.public_id,
            title: finding.title,
            severity: finding.severity,
            repository: {repository.host, repository.owner, repository.name}
          }
      )

    regressions = recent_regressions(matching, since)

    case {findings, regressions} do
      {[], []} ->
        :ok

      {findings, regressions} ->
        with {:ok, _email} <-
               Notifier.deliver_watchlist_alert(
                 watchlist.account,
                 watchlist,
                 findings,
                 regressions
               ) do
          watchlist
          |> Ecto.Changeset.change(last_notified_at: DateTime.utc_now())
          |> Repo.update()
        end

        :ok
    end
  end

  # Regressions are keyed on their own detection time: the canonical finding
  # they belong to is old by definition, so the `inserted_at` filter used for
  # new findings would never surface a reintroduced bug.
  defp recent_regressions(matching, since) do
    Repo.all(
      from regression in FindingRegression,
        join: repository in Repository,
        on: repository.id == regression.repository_id,
        join: finding in CanonicalFinding,
        on: finding.id == regression.canonical_finding_id,
        where: regression.inserted_at >= ^since,
        where: ^matching,
        order_by: [asc: regression.id],
        limit: 50,
        select: %{
          public_id: finding.public_id,
          title: finding.title,
          severity: finding.severity,
          fixed_at: regression.fixed_at,
          detected_commit_sha: regression.detected_commit_sha,
          repository: {repository.host, repository.owner, repository.name}
        }
    )
  end
end
