# typed: strict
# frozen_string_literal: true

require "vulns/cvss"
require "vulns/semver"

module Homebrew
  module Vulns
    # Wraps a single OSV vulnerability record.
    # See https://ossf.github.io/osv-schema/ for the field definitions.
    class Vulnerability
      include Utils::Output::Mixin

      class Interval < T::Struct
        const :lower, T.nilable(String)
        const :upper, T.nilable(String)
        const :upper_inclusive, T::Boolean
        const :fix, T.nilable(String)
      end

      CVSS_TYPE_PRIORITY = T.let(
        { "CVSS_V4" => 4, "CVSS_V3" => 3, "CVSS_V2" => 2 }.freeze,
        T::Hash[String, Integer],
      )
      private_constant :CVSS_TYPE_PRIORITY

      SEVERITY_LEVEL = T.let(
        { critical: 4, high: 3, medium: 2, low: 1 }.freeze,
        T::Hash[Symbol, Integer],
      )
      private_constant :SEVERITY_LEVEL

      sig { returns(String) }
      attr_reader :id

      sig { returns(T.nilable(String)) }
      attr_reader :summary, :details

      sig { returns(T.nilable(Symbol)) }
      attr_reader :severity

      sig { returns(T::Array[String]) }
      attr_reader :aliases, :upstream, :related

      sig { returns(T::Array[T::Hash[String, T.untyped]]) }
      attr_reader :references, :affected, :severity_entries

      sig { params(data: T::Hash[String, T.untyped]).void }
      def initialize(data)
        @id = T.let(data.fetch("id"), String)
        @summary = T.let(data["summary"], T.nilable(String))
        @details = T.let(data["details"], T.nilable(String))
        @aliases = T.let(Array(data["aliases"]), T::Array[String])
        @upstream = T.let(Array(data["upstream"]), T::Array[String])
        @related = T.let(Array(data["related"]), T::Array[String])
        @references = T.let(Array(data["references"]), T::Array[T::Hash[String, T.untyped]])
        @affected = T.let(Array(data["affected"]), T::Array[T::Hash[String, T.untyped]])
        @severity_entries = T.let(Array(data["severity"]), T::Array[T::Hash[String, T.untyped]])
        @severity = T.let(extract_severity(data), T.nilable(Symbol))
      end

      sig { params(list: T::Array[T::Hash[String, T.untyped]]).returns(T::Array[Vulnerability]) }
      def self.from_osv_list(list)
        list.map { |data| new(data) }
      end

      sig { returns(String) }
      def severity_display
        severity&.to_s&.upcase || "UNKNOWN"
      end

      sig { returns(Integer) }
      def severity_level
        sev = severity
        return 0 if sev.nil?

        SEVERITY_LEVEL.fetch(sev, 0)
      end

      # Only `id` and `aliases` name *this* vulnerability. `upstream` is a
      # directed reference (a distro advisory pointing at one-or-more source
      # CVEs) and `related` links to different vulnerabilities; neither is safe
      # to treat as an identity of this record. {Match} follows `upstream`
      # explicitly and re-attributes the hit to each CVE it names.
      sig { returns(T::Array[String]) }
      def identifiers
        [id, *aliases].uniq
      end

      sig { returns(T::Array[String]) }
      def cve_ids
        identifiers.select { |i| i.start_with?("CVE-") }
      end

      sig { returns(T.nilable(String)) }
      def advisory_url
        references.find { |r| r["type"] == "ADVISORY" }&.dig("url")
      end

      sig { returns(T::Array[String]) }
      def fix_urls
        references.select { |r| r["type"] == "FIX" }.filter_map { |r| r["url"] }
      end

      sig { returns(T::Array[String]) }
      def fixed_versions
        affected.flat_map do |aff|
          Array(aff["ranges"]).flat_map do |range|
            Array(range["events"]).filter_map { |e| e["fixed"] }
          end
        end.uniq
      end

      # `state` is `:affected` (in an interval), `:fixed` (past the closing
      # boundary of at least one interval), or `:not_applicable` (below the
      # `introduced` of every interval; the vulnerability never applied to this
      # version). `fixed_in` is the boundary that closed the containing
      # interval (`:affected`) or the highest boundary at-or-below the version
      # (`:fixed`).
      RangeStatus = Struct.new(:state, :fixed_in, keyword_init: true) do
        sig { returns(T::Boolean) }
        def affected? = state == :affected

        sig { returns(T::Boolean) }
        def fixed? = state == :fixed
      end

      # Evaluates `version` against every `affected[]` entry whose `package`
      # matches `{ecosystem, name}` (an advisory can carry several disjoint
      # entries for the same package, e.g. GHSA-jfh8-c2jp-5v3q's three
      # log4j-core ranges), honouring range `type`:
      #
      # - `SEMVER` ranges compare with {Semver}.
      # - `ECOSYSTEM` ranges compare with {Version} (best-effort; the record's
      #   own ecosystem defines the ordering, but callers only invoke this for
      #   ecosystems whose versions are broadly `Version`-comparable).
      # - `GIT` ranges are commit hashes and are skipped as uncomparable.
      #
      # Returns `nil` when no entry matches the package or no comparable range
      # exists in the matching entries, so callers can distinguish
      # `:not_applicable`/`:fixed` from "could not check".
      sig { params(ecosystem: String, name: String, version: String).returns(T.nilable(RangeStatus)) }
      def range_status(ecosystem, name, version)
        entries = affected_entries_for(ecosystem, name)
        return if entries.empty?

        target = normalize_version(version)
        checked = T.let(false, T::Boolean)
        past_fixes = T.let([], T::Array[String])

        entries.each do |entry|
          Array(entry["ranges"]).each do |range|
            type = range["type"]
            next if type == "GIT"

            cmp = comparator_for(type)
            intervals(Array(range["events"])).each do |interval|
              inside = in_interval?(target, interval.lower, interval.upper, interval.upper_inclusive, cmp)
              checked = true
              if inside
                fixed_in = interval.upper_inclusive ? nil : interval.upper
                return RangeStatus.new(state: :affected, fixed_in:).freeze
              end
              upper = interval.upper
              next unless upper

              rel = cmp.call(target, upper)
              past_fixes << upper if interval.upper_inclusive ? rel.positive? : rel >= 0
            rescue Uncomparable
              next
            end
          end

          versions = Array(entry["versions"])
          next if versions.empty?

          checked = true
          if versions.any? { |v| normalize_version(v.to_s) == target }
            return RangeStatus.new(state: :affected, fixed_in: nil).freeze
          end
        end

        return unless checked

        state = past_fixes.any? ? :fixed : :not_applicable
        RangeStatus.new(state:, fixed_in: past_fixes.max_by { |v| Version.new(v) }).freeze
      end

      sig { params(ecosystem: String, name: String).returns(T::Array[T::Hash[String, T.untyped]]) }
      def affected_entries_for(ecosystem, name)
        affected.select do |aff|
          pkg = aff["package"]
          pkg.is_a?(Hash) && pkg["ecosystem"] == ecosystem && pkg["name"] == name
        end
      end

      sig { params(range_type: T.nilable(String)).returns(T.proc.params(a: String, b: String).returns(Integer)) }
      def comparator_for(range_type)
        if range_type == "SEMVER"
          ->(a, b) { Semver.compare(a, b) || raise(Uncomparable) }
        else
          ->(a, b) { Version.new(a) <=> Version.new(b) || raise(Uncomparable) }
        end
      end

      sig {
        params(target: String, lower: T.nilable(String), upper: T.nilable(String),
               upper_inclusive: T::Boolean,
               cmp: T.proc.params(a: String, b: String).returns(Integer)).returns(T::Boolean)
      }
      def in_interval?(target, lower, upper, upper_inclusive, cmp)
        above = lower.nil? || cmp.call(target, lower) >= 0
        below = if upper.nil?
          true
        elsif upper_inclusive
          cmp.call(target, upper) <= 0
        else
          cmp.call(target, upper).negative?
        end
        above && below
      end

      # OSV has already matched this record against the queried version. This
      # method only overrides that with `false` when every affected entry can
      # be evaluated locally (explicit `versions` list or `SEMVER` range) and
      # none of them contains the target. Any uncheckable range or comparison
      # failure keeps the match.
      sig { params(version: String).returns(T::Boolean) }
      def affects_version?(version)
        return true if affected.empty?

        target = normalize_version(version)
        checkable = T.let(false, T::Boolean)

        affected.each do |aff|
          versions = Array(aff["versions"])
          if versions.any?
            checkable = true
            return true if versions.any? { |v| normalize_version(v.to_s) == target }
          end

          Array(aff["ranges"]).each do |range|
            return true if range["type"] != "SEMVER"

            checkable = true
            return true if in_semver_range?(target, Array(range["events"]))
          end
        end

        !checkable
      end

      sig { params(data: T::Hash[String, T.untyped]).returns(T.nilable(Symbol)) }
      def extract_severity(data)
        Array(data["severity"])
          .select { |s| CVSS_TYPE_PRIORITY.key?(s["type"]) }
          .sort_by { |s| -CVSS_TYPE_PRIORITY.fetch(s["type"]) }
          .each do |s|
            sev = CVSS.severity(s["score"].to_s)
            return sev if sev
          end

        top = normalize_severity(data.dig("database_specific", "severity"))
        return top if top

        affected.each do |aff|
          eco = normalize_severity(aff.dig("ecosystem_specific", "severity"))
          return eco if eco

          db = normalize_severity(aff.dig("database_specific", "severity"))
          return db if db
        end

        nil
      end

      sig { params(value: T.nilable(String)).returns(T.nilable(Symbol)) }
      def normalize_severity(value)
        case value&.downcase
        when "critical" then :critical
        when "high" then :high
        when "medium", "moderate" then :medium
        when "low" then :low
        end
      end

      sig { params(version: String).returns(String) }
      def normalize_version(version)
        version.sub(/\Av/i, "")
      end

      class Uncomparable < StandardError
      end

      sig { params(target: String, events: T::Array[T::Hash[String, T.untyped]]).returns(T::Boolean) }
      def in_semver_range?(target, events)
        return false if events.empty?

        intervals(events).any? { |interval| in_interval_permissive?(target, interval) }
      end

      sig { params(target: String, interval: Interval).returns(T.nilable(T::Boolean)) }
      def in_interval_strict?(target, interval)
        lower = interval.lower
        upper = interval.upper
        above = lower.nil? || semver_compare!(target, lower) >= 0
        below = if upper.nil?
          true
        elsif interval.upper_inclusive
          semver_compare!(target, upper) <= 0
        else
          semver_compare!(target, upper).negative?
        end
        above && below
      rescue Uncomparable
        nil
      end

      sig { params(target: String, interval: Interval).returns(T::Boolean) }
      def in_interval_permissive?(target, interval)
        in_interval_strict?(target, interval) != false
      rescue => e
        odebug "Failed to check #{target} against interval #{interval.inspect} for #{id}: #{e}"
        true
      end

      sig { params(left: String, right: String).returns(Integer) }
      def semver_compare!(left, right)
        Semver.compare(left, right) || raise(Uncomparable, "cannot compare #{left.inspect} with #{right.inspect}")
      end

      sig {
        params(events: T::Array[T::Hash[String, T.untyped]])
          .returns(T::Array[Interval])
      }
      def intervals(events)
        result = []
        lower = T.let(nil, T.nilable(String))

        events.each do |event|
          if (intro = event["introduced"])
            result << Interval.new(lower: lower, upper: nil, upper_inclusive: false, fix: nil) if lower
            intro = normalize_version(intro.to_s)
            lower = (intro == "0") ? nil : intro
          elsif (fixed = event["fixed"])
            fixed_norm = normalize_version(fixed.to_s)
            result << Interval.new(lower: lower, upper: fixed_norm, upper_inclusive: false, fix: fixed_norm)
            lower = nil
          elsif (last = event["last_affected"])
            last_norm = normalize_version(last.to_s)
            result << Interval.new(lower: lower, upper: last_norm, upper_inclusive: true, fix: nil)
            lower = nil
          elsif (limit = event["limit"])
            limit = limit.to_s
            upper = (limit == "*") ? nil : normalize_version(limit)
            result << Interval.new(lower: lower, upper: upper, upper_inclusive: false, fix: nil)
            lower = nil
          end
        end
        result << Interval.new(lower: lower, upper: nil, upper_inclusive: false, fix: nil) if lower || result.empty?
        result
      end

      sig { params(target: String, range: T::Hash[String, T.untyped]).returns(T::Boolean) }
      def non_semver_fix_available?(target, range)
        events = Array(range["events"])
        return false if events.empty?

        range_intervals = intervals(events)
        uncomparable_seen = T.let(false, T::Boolean)
        range_intervals.each do |interval|
          result = in_interval_strict?(target, interval)
          if result.nil?
            uncomparable_seen = true
            next
          end
          return interval.fix.present? if result
        end

        return false unless uncomparable_seen

        last_event = events.last
        return false if last_event.nil?

        last_event.key?("fixed")
      end

      sig { params(aff: T::Hash[String, T.untyped], repo_url: T.nilable(String)).returns(T::Boolean) }
      def affected_entry_relevant?(aff, repo_url)
        return true if repo_url.nil?
        return false if (ecosystem = aff.dig("package", "ecosystem")) && ecosystem != "GIT"

        git_ranges = Array(aff["ranges"]).select { |r| r["type"] == "GIT" }
        return true if git_ranges.empty?

        normalized_repo = repo_url.downcase.chomp("/").delete_suffix(".git").chomp("/")
        git_ranges.any? do |r|
          range_repo = r["repo"].to_s.downcase.chomp("/").delete_suffix(".git").chomp("/")
          range_repo.empty? || range_repo == normalized_repo
        end
      end

      sig { params(version: String, repo_url: T.nilable(String)).returns(T::Boolean) }
      def fix_available?(version, repo_url = nil)
        return false if affected.empty?

        target = normalize_version(version)

        affected.each do |aff|
          next unless affected_entry_relevant?(aff, repo_url)

          version_matched = false
          ranges = Array(aff["ranges"])
          semver_ranges, non_semver_ranges = ranges.partition { |r| r["type"] == "SEMVER" }

          versions = Array(aff["versions"])
          version_matched = true if versions.any? { |v| normalize_version(v.to_s) == target }

          version_matched = true if non_semver_ranges.any?

          matched_semver_range = T.let(false, T::Boolean)
          fix_found = T.let(false, T::Boolean)

          semver_ranges.each do |range|
            intervals(Array(range["events"])).each do |interval|
              result = in_interval_strict?(target, interval)
              next if result.nil?

              if result
                matched_semver_range = true
                fix_found = true if interval.fix.present?
              end
            end
          end

          version_matched ||= matched_semver_range

          next unless version_matched

          return true if fix_found

          non_semver_ranges.each do |range|
            return true if non_semver_fix_available?(target, range)
          end
        end

        false
      end
    end
  end
end
