defmodule Tarakan.Work.ReviewTask do
  @moduledoc """
  A commit-pinned unit of security work that a contributor can claim.
  """

  use Ecto.Schema
  import Ecto.Changeset

  alias Tarakan.Accounts.Account
  alias Tarakan.Repositories.Repository
  alias Tarakan.Scans.Scan
  alias Tarakan.Work.Contribution

  @kinds ~w(code_review threat_model privacy_review business_logic verify_findings write_fix
            diff_review refute_finding reproduce_finding calibrate_severity synthesize_rule)

  # Kinds that target one canonical finding rather than a whole tree.
  @finding_targeted_kinds ~w(refute_finding reproduce_finding calibrate_severity)
  @capabilities ~w(human agent hybrid)
  @statuses ~w(proposed open claimed submitted accepted changes_requested rejected cancelled)
  @visibilities ~w(restricted public_summary public)
  @public_statuses ~w(open claimed accepted)
  @claimable_statuses ~w(open changes_requested)
  @terminal_statuses ~w(accepted rejected cancelled)

  schema "review_tasks" do
    field :commit_sha, :string
    # diff_review only: the range under review is base_commit_sha..commit_sha.
    field :base_commit_sha, :string
    # synthesize_rule only: the code cluster a detector is wanted for.
    field :target_code_pattern_key, :string
    field :commit_committed_at, :utc_datetime_usec
    field :kind, :string
    field :capability, :string, default: "human"
    field :title, :string
    field :description, :string
    field :status, :string, default: "proposed"
    field :visibility, :string, default: "public"
    field :claimed_at, :utc_datetime_usec
    field :claim_expires_at, :utc_datetime_usec
    field :published_at, :utc_datetime_usec
    field :submitted_at, :utc_datetime_usec
    field :reviewed_at, :utc_datetime_usec
    field :completed_at, :utc_datetime_usec
    field :disclosed_at, :utc_datetime_usec
    field :sensitive_data_reviewed_at, :utc_datetime_usec

    belongs_to :repository, Repository
    belongs_to :created_by, Account
    belongs_to :claimed_by, Account
    belongs_to :reviewed_by, Account
    belongs_to :disclosed_by, Account
    belongs_to :sensitive_data_reviewed_by, Account
    belongs_to :contribution, Contribution, foreign_key: :latest_contribution_id
    # Latest Review produced when this Request was completed (domain collapse PR 1a).
    belongs_to :linked_review, Scan, foreign_key: :linked_review_id
    # For verify_findings: the Review being independently verified (PR5.1).
    belongs_to :target_review, Scan, foreign_key: :target_review_id
    # For refute_finding / reproduce_finding / calibrate_severity.
    belongs_to :target_canonical_finding, Tarakan.Scans.CanonicalFinding
    has_many :contributions, Contribution
    has_many :decisions, Tarakan.Work.ReviewDecision
    # All Reviews whose source_request_id points here (resubmit history).
    has_many :produced_reviews, Scan, foreign_key: :source_request_id

    timestamps(type: :utc_datetime_usec)
  end

  def kinds, do: @kinds
  def finding_targeted_kinds, do: @finding_targeted_kinds
  def capabilities, do: @capabilities
  def statuses, do: @statuses
  def visibilities, do: @visibilities
  def public_statuses, do: @public_statuses
  def claimable_statuses, do: @claimable_statuses
  def terminal_statuses, do: @terminal_statuses

  def creation_changeset(task, attrs) do
    task
    |> cast(attrs, [
      :commit_sha,
      :base_commit_sha,
      :kind,
      :capability,
      :title,
      :description,
      :target_review_id,
      :target_canonical_finding_id,
      :target_code_pattern_key
    ])
    |> update_change(:commit_sha, &(&1 |> String.trim() |> String.downcase()))
    |> update_change(:base_commit_sha, &(&1 |> String.trim() |> String.downcase()))
    |> update_change(:title, &String.trim/1)
    |> update_change(:description, &String.trim/1)
    |> validate_required([:commit_sha, :kind, :capability, :title, :description])
    |> validate_format(:commit_sha, ~r/^[0-9a-f]{40}$/,
      message: "must be a full 40-character commit SHA"
    )
    |> validate_inclusion(:kind, @kinds)
    |> validate_inclusion(:capability, @capabilities)
    |> validate_length(:title, max: 160)
    |> validate_length(:description, max: 5_000)
    |> validate_target_review_kind()
    |> validate_diff_range()
    |> validate_finding_target()
    |> validate_cluster_target()
    |> foreign_key_constraint(:target_review_id)
    |> foreign_key_constraint(:target_canonical_finding_id)
    |> check_constraint(:base_commit_sha, name: :review_tasks_base_commit_sha_format)
    |> check_constraint(:base_commit_sha, name: :review_tasks_diff_review_needs_base)
    |> check_constraint(:commit_sha, name: :review_tasks_commit_sha_must_be_full_sha)
    |> check_constraint(:kind, name: :review_tasks_kind_must_be_valid)
    |> check_constraint(:capability, name: :review_tasks_capability_must_be_valid)
  end

  defp validate_target_review_kind(changeset) do
    kind = get_field(changeset, :kind)
    target = get_field(changeset, :target_review_id)

    cond do
      kind == "verify_findings" and is_nil(target) ->
        add_error(changeset, :target_review_id, "is required for verify_findings requests")

      kind != "verify_findings" and not is_nil(target) ->
        add_error(changeset, :target_review_id, "is only valid for verify_findings requests")

      true ->
        changeset
    end
  end

  # A diff_review is defined by its range; without a base there is nothing to
  # diff against and the job would silently degrade into a snapshot review.
  defp validate_diff_range(changeset) do
    kind = get_field(changeset, :kind)
    base = get_field(changeset, :base_commit_sha)
    head = get_field(changeset, :commit_sha)

    cond do
      kind == "diff_review" and is_nil(base) ->
        add_error(changeset, :base_commit_sha, "is required for diff_review requests")

      kind != "diff_review" and not is_nil(base) ->
        add_error(changeset, :base_commit_sha, "is only valid for diff_review requests")

      not is_nil(base) and base == head ->
        add_error(changeset, :base_commit_sha, "must differ from the head commit")

      not is_nil(base) and not Regex.match?(~r/^[0-9a-f]{40}$/, base) ->
        add_error(changeset, :base_commit_sha, "must be a full 40-character commit SHA")

      true ->
        changeset
    end
  end

  defp validate_finding_target(changeset) do
    kind = get_field(changeset, :kind)
    target = get_field(changeset, :target_canonical_finding_id)

    cond do
      kind in @finding_targeted_kinds and is_nil(target) ->
        add_error(changeset, :target_canonical_finding_id, "is required for #{kind} requests")

      kind not in @finding_targeted_kinds and not is_nil(target) ->
        add_error(
          changeset,
          :target_canonical_finding_id,
          "is only valid for finding-targeted requests"
        )

      true ->
        changeset
    end
  end

  defp validate_cluster_target(changeset) do
    kind = get_field(changeset, :kind)
    target = get_field(changeset, :target_code_pattern_key)

    cond do
      kind == "synthesize_rule" and is_nil(target) ->
        add_error(changeset, :target_code_pattern_key, "is required for synthesize_rule requests")

      kind != "synthesize_rule" and not is_nil(target) ->
        add_error(
          changeset,
          :target_code_pattern_key,
          "is only valid for synthesize_rule requests"
        )

      true ->
        changeset
    end
  end

  def claim_active?(%__MODULE__{status: "claimed", claim_expires_at: expires_at})
      when not is_nil(expires_at) do
    DateTime.after?(expires_at, DateTime.utc_now())
  end

  def claim_active?(_task), do: false

  # Visibility is the only content gate; status is a workflow label.
  def public?(%__MODULE__{visibility: visibility}) do
    visibility in ["public_summary", "public"]
  end

  def claimable?(%__MODULE__{status: status}), do: status in @claimable_statuses
  def terminal?(%__MODULE__{status: status}), do: status in @terminal_statuses
end
