# frozen_string_literal: true

module EE
  module Vulnerability
    include ::Gitlab::Utils::StrongMemoize
    extend ActiveSupport::Concern

    prepended do
      include ::CacheMarkdownField
      include ::Redactable
      include ::StripAttribute
      include ::Noteable
      include ::Awardable
      include ::Referable
      include ::Presentable
      include ::Gitlab::SQL::Pattern

      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 :dismissed_findings, -> { dismissed }, 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'

      # keep the order of the values in the state enum, it is used in state_order method to properly order vulnerabilities based on state
      # remember to recreate index_vulnerabilities_on_state_case_id index when you update or extend this enum
      enum state: { detected: 1, confirmed: 4, resolved: 3, dismissed: 2 }
      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 :with_author_and_project, -> { includes(:author, :project) }
      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 :visible_to_user_and_access_level, -> (user, access_level) { where(project_id: ::Project.visible_to_user_and_access_level(user, access_level)) }
      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)) }
      scope :grouped_by_severity, -> { reorder(severity: :desc).group(:severity) }
      scope :by_project_fingerprints, -> (project_fingerprints) { joins(:findings).merge(Vulnerabilities::Finding.by_project_fingerprints(project_fingerprints)) }

      scope :with_resolution, -> (has_resolution = true) { where(resolved_on_default_branch: has_resolution) }
      scope :with_issues, -> (has_issues = true) do
        exist_query = has_issues ? 'EXISTS (?)' : 'NOT EXISTS (?)'
        issue_links = ::Vulnerabilities::IssueLink.arel_table

        where(exist_query, ::Vulnerabilities::IssueLink.select(1).where(issue_links[:vulnerability_id].eq(arel_table[:id])))
      end

      scope :autocomplete_search, -> (query) do
        return self if query.blank?

        id_as_text = Arel::Nodes::NamedFunction.new('CAST', [arel_table[:id].as('TEXT')])

        fuzzy_search(query, [:title])
          .or(where(id_as_text.matches("%#{sanitize_sql_like(query.squish)}%")))
      end

      scope :order_severity_asc, -> { reorder(severity: :asc, id: :desc) }
      scope :order_severity_desc, -> { reorder(severity: :desc, id: :desc) }
      scope :order_title_asc, -> { reorder(title: :asc, id: :desc) }
      scope :order_title_desc, -> { reorder(title: :desc, id: :desc) }
      scope :order_created_at_asc, -> { reorder(created_at: :asc, id: :desc) }
      scope :order_created_at_desc, -> { reorder(created_at: :desc, id: :desc) }
      scope :order_report_type_asc, -> { select(*arel.projections, report_type_order.as('case_order_value')).reorder(report_type_order.asc, id: :desc) }
      scope :order_report_type_desc, -> { select(*arel.projections, report_type_order.as('case_order_value')).reorder(report_type_order.desc, id: :desc) }
      scope :order_state_asc, -> { select(*arel.projections, state_order.as('array_position')).reorder(state_order.asc, id: :desc) }
      scope :order_state_desc, -> { select(*arel.projections, state_order.as('array_position')).reorder(state_order.desc, id: :desc) }
      scope :order_id_desc, -> { reorder(id: :desc) }

      scope :with_limit, -> (maximum) { limit(maximum) }

      delegate :scanner_name, :scanner_external_id, :metadata, :message, :description,
               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

      delegate :solution, :identifiers, :links, :remediations, :file,
               :cve_value, :cwe_value, :other_identifier_values,
               to: :finding, allow_nil: true

      def to_reference(from = nil, full: false)
        project
          .to_reference_base(from, full: full)
          .then { |reference_base| reference_base.present? ? "#{reference_base}/" : nil }
          .then { |reference_base| "#{self.class.reference_prefix}#{reference_base}#{id}#{self.class.reference_postfix}" }
      end

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

      # TODO: Remove this attribute reader overrides with #262112
      def dismissed_at
        return unless dismissed?

        super || fallback_dismissal_feedback&.created_at
      end

      def dismissed_by_id
        return unless dismissed?

        super || fallback_dismissal_feedback&.author_id
      end

      def resource_parent
        project
      end

      def discussions_rendered_on_frontend?
        true
      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

      # TODO: Remove this with #262112
      def fallback_dismissal_feedback
        strong_memoize(:fallback_dismissal_feedback) do
          ::Gitlab::AppJsonLogger.warn(message: 'Fallback dismissal_feedback has been called!', vulnerability_id: id)

          finding&.dismissal_feedback
        end
      end
    end

    class_methods do
      def reference_pattern
        @reference_pattern ||= %r{
          #{Regexp.escape(reference_prefix)}(#{::Project.reference_pattern}\/)?(?<vulnerability>\d+)#{Regexp.escape(reference_postfix)}
        }x
      end

      def link_reference_pattern
        %r{
          (?<url>
            #{Regexp.escape(::Gitlab.config.gitlab.url)}
            \/#{::Project.reference_pattern}
            (?:\/\-)
            \/security\/vulnerabilities
            \/(?<vulnerability>\d+)
            (?<path>
              (\/[a-z0-9_=-]+)*\/*
            )?
            (?<anchor>\#[a-z0-9_-]+)?
          )
        }x
      end

      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 report_type_order
        report_types
          .sort
          .to_h
          .values
          .each
          .with_index
          .reduce(Arel::Nodes::Case.new(arel_table[:report_type])) do |node, (value, index)|
            node.when(value).then(index)
          end
      end

      def state_order
        Arel::Nodes::NamedFunction.new(
          'ARRAY_POSITION',
          [
            Arel.sql("ARRAY#{states.values}::smallint[]"),
            arel_table[:state]
          ]
        )
      end

      def active_states
        ACTIVE_STATES
      end

      def passive_states
        PASSIVE_STATES
      end

      def active_state_values
        states.values_at(*active_states)
      end

      def order_by(method)
        case method.to_s
        when 'severity_desc' then order_severity_desc
        when 'severity_asc' then order_severity_asc
        when 'title_desc' then order_title_desc
        when 'title_asc' then order_title_asc
        when 'detected_desc' then order_created_at_desc
        when 'detected_asc' then order_created_at_asc
        when 'report_type_desc' then order_report_type_desc
        when 'report_type_asc' then order_report_type_asc
        when 'state_desc' then order_state_desc
        when 'state_asc' then order_state_asc
        else
          order_severity_desc
        end
      end
    end
  end
end
