# frozen_string_literal: true

class Vulnerability < ApplicationRecord
  include CacheMarkdownField
  include Redactable
  include StripAttribute
  include Noteable
  include Awardable

  TooManyDaysError = Class.new(StandardError)

  MAX_DAYS_OF_HISTORY = 10
  ACTIVE_STATES = %w(detected confirmed).freeze
  PASSIVE_STATES = %w(dismissed resolved).freeze

  cache_markdown_field :title, pipeline: :single_line
  cache_markdown_field :description, issuable_state_filter_enabled: true

  strip_attributes :title

  redact_field :description

  belongs_to :project # keep this association named 'project' for correct work of markdown cache
  belongs_to :milestone
  belongs_to :epic

  belongs_to :author, class_name: 'User' # keep this association named 'author' for correct work of markdown cache
  belongs_to :updated_by, class_name: 'User'
  belongs_to :last_edited_by, class_name: 'User'
  belongs_to :resolved_by, class_name: 'User'
  belongs_to :dismissed_by, class_name: 'User'
  belongs_to :confirmed_by, class_name: 'User'

  has_one :group, through: :project

  has_many :findings, class_name: 'Vulnerabilities::Finding', inverse_of: :vulnerability
  has_many :issue_links, class_name: 'Vulnerabilities::IssueLink', inverse_of: :vulnerability
  has_many :created_issue_links, -> { created }, class_name: 'Vulnerabilities::IssueLink', inverse_of: :vulnerability
  has_many :related_issues, through: :issue_links, source: :issue do
    def with_vulnerability_links
      select('issues.*, vulnerability_issue_links.id AS vulnerability_link_id, '\
             'vulnerability_issue_links.link_type AS vulnerability_link_type')
    end
  end

  has_many :notes, as: :noteable, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
  has_many :user_mentions, class_name: 'VulnerabilityUserMention'

  enum state: { detected: 1, dismissed: 2, resolved: 3, confirmed: 4 }
  enum severity: Vulnerabilities::Finding::SEVERITY_LEVELS, _prefix: :severity
  enum confidence: Vulnerabilities::Finding::CONFIDENCE_LEVELS, _prefix: :confidence
  enum report_type: Vulnerabilities::Finding::REPORT_TYPES

  validates :project, :author, :title, :severity, :confidence, :report_type, presence: true

  # at this stage Vulnerability is not an Issuable, has some important attributes (and their constraints) in common
  validates :title, length: { maximum: Issuable::TITLE_LENGTH_MAX }
  validates :title_html, length: { maximum: Issuable::TITLE_HTML_LENGTH_MAX }, allow_blank: true
  validates :description, length: { maximum: Issuable::DESCRIPTION_LENGTH_MAX }, allow_blank: true
  validates :description_html, length: { maximum: Issuable::DESCRIPTION_HTML_LENGTH_MAX }, allow_blank: true

  scope :ordered, -> { order(severity: :desc) }

  scope :with_findings, -> { includes(:findings) }
  scope :with_findings_and_scanner, -> { includes(findings: :scanner) }
  scope :with_findings_scanner_and_identifiers, -> { includes(findings: [:scanner, :identifiers, finding_identifiers: :identifier]) }
  scope :with_created_issue_links_and_issues, -> { includes(created_issue_links: :issue) }

  scope :for_projects, -> (project_ids) { where(project_id: project_ids) }
  scope :with_report_types, -> (report_types) { where(report_type: report_types) }
  scope :with_severities, -> (severities) { where(severity: severities) }
  scope :with_states, -> (states) { where(state: states) }
  scope :with_scanners, -> (scanners) { joins(findings: :scanner).merge(Vulnerabilities::Scanner.with_external_id(scanners)) }

  class << self
    def parent_class
      ::Project
    end

    def to_ability_name
      model_name.singular
    end

    def counts_by_day_and_severity(start_date, end_date)
      return [] unless Feature.enabled?(:vulnerability_history, default_enabled: true)

      num_days_of_history = end_date - start_date + 1

      # this clause guards against query timeouts
      raise TooManyDaysError, "Cannot fetch counts for more than #{MAX_DAYS_OF_HISTORY} days" if num_days_of_history > MAX_DAYS_OF_HISTORY

      quoted_start_date = connection.quote(start_date)
      quoted_end_date = connection.quote(end_date)

      select(
        'DATE(calendar.entry) AS day, severity, COUNT(*)'
      ).from(
        "generate_series(DATE #{quoted_start_date}, DATE #{quoted_end_date}, INTERVAL '1 day') as calendar(entry)"
      ).joins(
        'INNER JOIN vulnerabilities ON vulnerabilities.created_at <= calendar.entry'
      ).where(
        '(vulnerabilities.dismissed_at IS NULL OR vulnerabilities.dismissed_at > calendar.entry) AND (vulnerabilities.resolved_at IS NULL OR vulnerabilities.resolved_at > calendar.entry)'
      ).group(
        :day, :severity
      )
    end

    def active_states
      ACTIVE_STATES
    end

    def passive_states
      PASSIVE_STATES
    end

    def active_state_values
      states.values_at(*ACTIVE_STATES)
    end
  end

  # There will only be one finding associated with a vulnerability for the foreseeable future
  def finding
    findings.first
  end

  def dismissed_by_id
    super || finding&.dismissal_feedback&.author_id
  end

  delegate :scanner_name, :scanner_external_id, :metadata, :message, :cve,
           to: :finding, prefix: true, allow_nil: true

  delegate :default_branch, :name, to: :project, prefix: true, allow_nil: true
  delegate :name, to: :group, prefix: true, allow_nil: true

  def resource_parent
    project
  end

  def discussions_rendered_on_frontend?
    true
  end

  def resolved_on_default_branch
    return false unless findings.any?

    # We can't just use project.latest_successful_pipeline_for_default_branch
    # because there's no guarantee that it actually ran the security jobs
    # See https://gitlab.com/gitlab-org/gitlab/-/issues/218012
    latest_successful_pipeline = project
      .latest_pipeline_with_security_reports(only_successful: true)

    # Technically this shouldn't ever happen.
    # If an vulnerability was discovered, then we must have ran a scan of the
    # appropriate type at least once.
    return false unless latest_successful_pipeline

    finding.pipelines.exclude?(latest_successful_pipeline)
  end

  def user_notes_count
    user_notes_count_service.count
  end

  def after_note_changed(note)
    user_notes_count_service.delete_cache unless note.system?
  end
  alias_method :after_note_created,   :after_note_changed
  alias_method :after_note_destroyed, :after_note_changed

  def stat_diff
    Vulnerabilities::StatDiff.new(self)
  end

  private

  def user_notes_count_service
    @user_notes_count_service ||= Vulnerabilities::UserNotesCountService.new(self) # rubocop: disable CodeReuse/ServiceClass
  end
end
