# frozen_string_literal: true

module WPScan
  # Vulnerability model.
  class Vulnerability
    include References

    attr_reader :title, :type, :affected_versions, :cvss, :poc, :uuid

    # Version of the scanned item (plugin/theme/WordPress), used to resolve
    # which backported range to surface as the scalar fixed_in / introduced_in.
    # Left nil/false when the version could not be detected.
    attr_accessor :detected_version

    # @param [ String ] title
    # @param [ Hash ] references
    # @option references [ Array<String>, String ] :cve
    # @option references [ Array<String>, String ] :secunia
    # @option references [ Array<String>, String ] :osvdb
    # @option references [ Array<String>, String ] :exploitdb
    # @option references [ Array<String> ] :url URL(s) to related advisories etc
    # @option references [ Array<String>, String ] :metasploit The related metasploit module(s)
    # @option references [ Array<String> ] :youtube
    # @param [ String ] type
    # @param [ Array<Hash> ] affected_versions One entry per backported fix range,
    #   each with :fixed_in and :introduced_in keys (either may be nil).
    # @param [ HashSymbol ] cvss
    # @option cvss [ String ] :score
    # @option cvss [ String ] :vector
    # rubocop:disable Metrics/ParameterLists
    def initialize(title, references: {}, type: nil, affected_versions: nil, cvss: nil, poc: nil, uuid: nil)
      # rubocop:enable Metrics/ParameterLists
      @title             = title
      @type              = type
      @affected_versions = normalize_affected_versions(affected_versions)
      @cvss              = { score: cvss[:score], vector: cvss[:vector] } if cvss
      @poc               = poc
      @uuid              = uuid
      @detected_version  = nil

      self.references = references
    end

    # @param [ Hash ] json_data
    # @return [ Vulnerability ]
    def self.load_from_json(json_data)
      references = { wpvulndb: json_data['id'].to_s }

      if json_data['references']
        references_keys.each do |key|
          references[key] = json_data['references'][key.to_s] if json_data['references'].key?(key.to_s)
        end
      end

      new(
        json_data['title'],
        references: references,
        type: json_data['vuln_type'],
        affected_versions: affected_versions_from(json_data),
        cvss: json_data['cvss']&.symbolize_keys,
        poc: json_data['poc'],
        uuid: json_data['id'].to_s # The 'id' field IS the UUID in WPScan API
      )
    end

    # Reads the :affected_versions ranges used by both the API and the DB dumps.
    # Legacy flat fixed_in / introduced_in entries are wrapped into a single range
    # so they still get version-filtered instead of matching every version.
    #
    # @param [ Hash ] json_data
    # @return [ Array<Hash>, nil ]
    def self.affected_versions_from(json_data)
      return json_data['affected_versions'] if json_data['affected_versions']
      return nil unless json_data.key?('fixed_in') || json_data.key?('introduced_in')

      [{ 'fixed_in' => json_data['fixed_in'], 'introduced_in' => json_data['introduced_in'] }]
    end

    # Orders entries oldest-first (created_at, updated_at, id) to match the API's
    # order, so the API and the reverse-ordered DB dump report the same sequence.
    # The trailing index keeps the sort stable when those fields are absent.
    #
    # @param [ Array<Hash> ] json_vulns Raw entries from the API or a DB dump
    # @return [ Array<Hash> ]
    def self.sort_json(json_vulns)
      Array(json_vulns).each_with_index.sort_by do |vuln, index|
        [vuln['created_at'].to_s, vuln['updated_at'].to_s, vuln['id'].to_s, index]
      end.map(&:first)
    end

    # Whether this vulnerability covers a given version. True when no
    # affected_versions are declared (advisory with no fix metadata) or when at
    # least one backported range covers the version.
    #
    # @param [ #< ] version
    #
    # @return [ Boolean ]
    def affects?(version)
      return true if affected_versions.empty?

      affected_versions.any? { |range| self.class.range_covers?(range, version) }
    end

    # Backward-compatible scalar: the fixed_in of the range relevant to the
    # detected version (the branch the scanned site is actually on). nil when
    # there is no fix metadata.
    #
    # @return [ String, nil ]
    def fixed_in
      relevant_range && relevant_range[:fixed_in]
    end

    # Backward-compatible scalar counterpart of {#fixed_in}.
    #
    # @return [ String, nil ]
    def introduced_in
      relevant_range && relevant_range[:introduced_in]
    end

    # The single affected-version range whose fixed_in / introduced_in we
    # surface in the output. Picks the range covering the detected version so a
    # site on an older but still-supported branch is matched against the right
    # fix. Falls back to the highest (most recent) range when the version is
    # unknown or no range covers it, mirroring the pre-v4 single-value output.
    #
    # @return [ Hash{Symbol => String, nil}, nil ]
    def relevant_range
      return nil if affected_versions.empty?

      if detected_version
        covering = affected_versions.find { |range| self.class.range_covers?(range, detected_version) }
        return covering if covering
      end

      highest_range
    end

    # Whether a single range covers the version. A range matches when
    # introduced_in is missing or <= version AND fixed_in is missing or >
    # version.
    #
    # @param [ Hash{Symbol => String, nil} ] range
    # @param [ #< ] version
    #
    # @return [ Boolean ]
    def self.range_covers?(range, version)
      introduced = range[:introduced_in]
      fixed      = range[:fixed_in]

      return false if introduced && !introduced.to_s.empty? && version < introduced
      return true if fixed.nil? || fixed.to_s.empty?

      version < fixed
    end

    # @param [ Vulnerability ] other
    #
    # @return [ Boolean ]
    def ==(other)
      title == other.title &&
        type == other.type &&
        references == other.references &&
        affected_versions == other.affected_versions &&
        cvss == other.cvss &&
        poc == other.poc &&
        uuid == other.uuid
    end

    private

    # The range with the greatest fixed_in. An unpatched range (no fixed_in)
    # outranks every patched one. Used as the fallback for {#relevant_range}.
    #
    # @return [ Hash{Symbol => String, nil} ]
    def highest_range
      affected_versions.max_by { |range| version_key(range[:fixed_in]) }
    end

    # Comparable sort key for a fixed_in value. Returns a [tier, Gem::Version]
    # tuple so a missing fixed_in (unpatched) always sorts highest, and patched
    # ranges sort by their numeric version.
    #
    # @return [ Array ]
    def version_key(fixed_in)
      return [1, Gem::Version.new('0')] if fixed_in.nil? || fixed_in.to_s.empty?

      [0, Gem::Version.new(fixed_in.to_s[/\A[\d.]+/] || '0')]
    rescue ArgumentError
      [0, Gem::Version.new('0')]
    end

    # Coerces the v4 affected_versions payload into an array of symbolised range
    # hashes. Accepts either an array of hashes (with string or symbol keys) or
    # nil; anything else is dropped so a malformed entry can't break parsing.
    #
    # @return [ Array<Hash{Symbol => String, nil}> ]
    def normalize_affected_versions(value)
      Array(value).filter_map do |range|
        next unless range.is_a?(Hash)

        range = range.transform_keys(&:to_sym)
        { fixed_in: range[:fixed_in], introduced_in: range[:introduced_in] }
      end
    end
  end
end
