defmodule Tarakan.Scans.CodePatternRule do
  @moduledoc """
  A detector for one code cluster: the vaccine pack.

  Written by a worker, which has both the rule engine and the code, and only
  servable once it has been shown to match the instances it claims to cover.
  An unvalidated rule is a guess about a pattern; a validated one has been run
  against known members of the cluster and hit them.
  """

  use Ecto.Schema
  import Ecto.Changeset

  alias Tarakan.Accounts.Account

  @engines ~w(semgrep)

  schema "code_pattern_rules" do
    field :code_pattern_key, :string
    field :engine, :string, default: "semgrep"
    field :language, :string
    field :rule_yaml, :string
    field :checked_count, :integer, default: 0
    field :matched_count, :integer, default: 0
    field :matched_finding_ids, {:array, Ecto.UUID}, default: []
    field :validated_at, :utc_datetime_usec

    belongs_to :author_account, Account

    timestamps(type: :utc_datetime_usec)
  end

  def engines, do: @engines

  @doc false
  def changeset(rule, attrs) do
    rule
    |> cast(attrs, [
      :code_pattern_key,
      :engine,
      :language,
      :rule_yaml,
      :checked_count,
      :matched_count,
      :matched_finding_ids,
      :author_account_id
    ])
    |> validate_required([:code_pattern_key, :engine, :rule_yaml])
    |> validate_inclusion(:engine, @engines)
    |> validate_length(:rule_yaml, min: 20, max: 40_000)
    |> validate_length(:language, max: 40)
    |> validate_number(:checked_count, greater_than_or_equal_to: 0)
    |> validate_number(:matched_count, greater_than_or_equal_to: 0)
    |> validate_rule_shape()
    |> validate_match_evidence()
    |> stamp_validation()
    |> unique_constraint([:code_pattern_key, :author_account_id])
    |> check_constraint(:matched_count, name: :code_pattern_rules_counts_sane)
    |> check_constraint(:validated_at, name: :code_pattern_rules_validated_needs_a_match)
  end

  @doc "Whether this rule may be served to the public."
  def servable?(%__MODULE__{validated_at: nil}), do: false
  def servable?(%__MODULE__{matched_count: matched}) when is_integer(matched), do: matched > 0
  def servable?(_rule), do: false

  # The whole failure of the previous vaccine pack was a rule whose body matched
  # a literal placeholder. Reject anything that still looks like one, and require
  # the shape of a real Semgrep rule.
  defp validate_rule_shape(changeset) do
    case get_field(changeset, :rule_yaml) do
      nil ->
        changeset

      yaml ->
        cond do
          yaml =~ "tarakan-vaccine-placeholder" ->
            add_error(changeset, :rule_yaml, "is still a placeholder, not a detector")

          not String.contains?(yaml, "rules:") ->
            add_error(changeset, :rule_yaml, "must declare a `rules:` list")

          not (String.contains?(yaml, "pattern") or String.contains?(yaml, "regex")) ->
            add_error(changeset, :rule_yaml, "must declare at least one pattern")

          true ->
            changeset
        end
    end
  end

  # matched_finding_ids is the receipt for matched_count. Without it the count is
  # just a number the submitter chose.
  defp validate_match_evidence(changeset) do
    matched = get_field(changeset, :matched_count) || 0
    ids = get_field(changeset, :matched_finding_ids) || []

    cond do
      matched > (get_field(changeset, :checked_count) || 0) ->
        add_error(changeset, :matched_count, "cannot exceed the number of instances checked")

      matched > 0 and length(ids) != matched ->
        add_error(
          changeset,
          :matched_finding_ids,
          "must list exactly the findings that matched"
        )

      true ->
        changeset
    end
  end

  defp stamp_validation(changeset) do
    if changeset.valid? and (get_field(changeset, :matched_count) || 0) > 0 do
      put_change(changeset, :validated_at, DateTime.utc_now())
    else
      put_change(changeset, :validated_at, nil)
    end
  end
end
