# typed: true
# frozen_string_literal: true

require "vulns/match"

RSpec.describe Homebrew::Vulns::Match do
  let(:repology) do
    Homebrew::Vulns::Repology.new({ "meta" => {}, "formulae" => {
      "requests" => { "Debian" => ["requests"], "Alpine" => ["py3-requests"] },
    } })
  end
  let(:cpan_sec) do
    Homebrew::Vulns::CPANSec.new({ "meta" => {}, "dists" => {
      "Image-ExifTool" => { "advisories" => [
        { "id" => "CPANSA-Image-ExifTool-2021-22204", "cves" => ["CVE-2021-22204"],
          "affected_versions" => ["<12.24"], "fixed_versions" => [">=12.24"] },
      ] },
    } })
  end
  let(:matcher) { described_class.new(repology:, cpan_sec:) }

  def stub_repology_lookup(result = {})
    allow(Homebrew::Vulns::Repology).to receive(:lookup).and_return(result)
  end

  def vuln(data)
    Homebrew::Vulns::Vulnerability.new(data)
  end

  def ev(strategy, ecosystem: nil, name: nil, subject_version: nil, key: "k", resource: nil, advisory: nil)
    Homebrew::Vulns::Match::Evidence.new(strategy:, ecosystem:, name:, subject_version:, key:,
                                         resource:, advisory:)
  end

  def make_hit(vulnerability, *evidence)
    Homebrew::Vulns::Match::Hit.new(vulnerability:, evidence:)
  end

  describe "#identify" do
    it "derives git repo/tag, primary registry package, resources and distro packages" do
      f = formula("requests") do
        T.bind(self, T.class_of(Formula))
        homepage "https://requests.readthedocs.io"
        url "https://files.pythonhosted.org/packages/aa/bb/cc/requests-2.31.0.tar.gz"
        head "https://github.com/psf/requests.git"
        resource "certifi" do
          url "https://files.pythonhosted.org/packages/11/22/33/certifi-2024.2.2.tar.gz"
        end
        resource "vendored-c" do
          url "https://example.com/blob-1.0.tar.gz"
        end
      end

      identity = matcher.identify(f)

      expect(identity.git_repo).to eq "https://github.com/psf/requests"
      expect(identity.git_tag).to eq "2.31.0"
      expect(identity.primary_package.ecosystem).to eq "PyPI"
      expect(identity.primary_package.name).to eq "requests"
      expect(identity.resource_packages.keys).to eq ["certifi"]
      expect(identity.resource_packages["certifi"].purl).to eq "pkg:pypi/certifi@2024.2.2"
      expect(identity.distro_packages)
        .to eq("Debian" => ["requests"], "Alpine" => ["py3-requests"])
      expect(identity.identifiable?).to be true
    end

    it "falls back to Repology.lookup when the index has no entry (single-formula mode)" do
      f = formula("newthing") do
        T.bind(self, T.class_of(Formula))
        url "https://example.com/newthing-1.0.tar.gz"
      end
      stub_repology_lookup({ "Debian" => ["newthing"] })

      expect(matcher.identify(f).distro_packages).to eq("Debian" => ["newthing"])
    end

    it "does not fall back to Repology.lookup in bulk mode" do
      f = formula("newthing") do
        T.bind(self, T.class_of(Formula))
        url "https://example.com/newthing-1.0.tar.gz"
      end
      expect(Homebrew::Vulns::Repology).not_to receive(:lookup)

      bulk = described_class.new(repology:, cpan_sec:, bulk: true)
      expect(bulk.identify(f).distro_packages).to eq({})
    end

    it "swallows a Repology lookup error to an empty distro map" do
      f = formula("newthing") do
        T.bind(self, T.class_of(Formula))
        url "https://example.com/newthing-1.0.tar.gz"
      end
      allow(Homebrew::Vulns::Repology).to receive(:lookup)
        .and_raise(Homebrew::Vulns::CachedFeed::Error, "boom")

      expect(matcher.identify(f).distro_packages).to eq({})
    end

    it "reports identifiable? false when nothing is derivable" do
      f = formula("mystery") do
        T.bind(self, T.class_of(Formula))
        url "https://example.com/mystery-1.0.tar.gz"
      end
      stub_repology_lookup

      expect(matcher.identify(f).identifiable?).to be false
    end
  end

  describe "#build_osv_queries" do
    def pkg(ecosystem:, name:, version:, purl:)
      Homebrew::Vulns::Identify::RegistryPackage.new(ecosystem:, name:, version:, purl:)
    end

    it "emits versionless GIT/registry/distro queries with subject_version carried on the evidence" do
      identity = Homebrew::Vulns::Match::Identity.new(
        git_repo:          "https://github.com/psf/requests",
        git_tag:           "v2.31.0",
        primary_package:   pkg(ecosystem: "PyPI", name: "requests", version: "2.31.0",
                               purl: "pkg:pypi/requests@2.31.0"),
        resource_packages: { "certifi" => pkg(ecosystem: "PyPI", name: "certifi", version: "2024.2.2",
                                              purl: "pkg:pypi/certifi@2024.2.2") },
        distro_packages:   { "Debian" => ["requests"] },
      )

      queries = matcher.build_osv_queries(identity, "2.31.0")

      expect(queries.map(&:first)).to eq [
        { ecosystem: "GIT", name: "https://github.com/psf/requests", version: nil },
        { ecosystem: "PyPI", name: "requests", version: nil },
        { ecosystem: "PyPI", name: "certifi", version: nil },
        { ecosystem: "Debian", name: "requests", version: nil },
      ]
      expect(queries.map { |_, e| [e.strategy, e.ecosystem, e.name, e.subject_version, e.resource] }).to eq [
        [:git, "GIT", "https://github.com/psf/requests", "v2.31.0", nil],
        [:registry, "PyPI", "requests", "2.31.0", nil],
        [:registry, "PyPI", "certifi", "2024.2.2", "certifi"],
        [:distro, "Debian", "requests", nil, nil],
      ]
    end

    it "excludes CPAN packages from OSV queries and omits GIT when no repo derived" do
      identity = Homebrew::Vulns::Match::Identity.new(
        git_repo:          nil,
        git_tag:           "13.55",
        primary_package:   pkg(ecosystem: "CPAN", name: "Image-ExifTool", version: "13.55",
                               purl: "pkg:cpan/EXIFTOOL/Image-ExifTool@13.55"),
        resource_packages: {}, distro_packages: {}
      )

      expect(matcher.build_osv_queries(identity, "13.55")).to eq []
    end
  end

  describe "#range_status" do
    it "returns the registry-entry status when GIT ranges are uncomparable" do
      v = vuln("id" => "CVE-1", "affected" => [
        { "package" => { "ecosystem" => "GIT", "name" => "https://github.com/jqlang/jq" },
          "ranges"  => [{ "type" => "GIT", "events" => [{ "fixed" => "e47e56d" }] }] },
        { "package" => { "ecosystem" => "PyPI", "name" => "requests" },
          "ranges"  => [{ "type"   => "ECOSYSTEM",
                          "events" => [{ "introduced" => "0" }, { "fixed" => "2.28.1" }] }] },
      ])
      hit = make_hit(v,
                     ev(:git, ecosystem: "GIT", name: "https://github.com/jqlang/jq",
                              subject_version: "1.8.1"),
                     ev(:registry, ecosystem: "PyPI", name: "requests", subject_version: "2.31.0"))

      status, evidence = matcher.range_status(hit)
      expect(status).to have_attributes(state: :fixed, fixed_in: "2.28.1")
      expect(evidence.strategy).to eq :registry
    end

    it "returns nil when the only matching entry has GIT-type ranges" do
      v = vuln("id" => "CVE-2026-32316", "affected" => [
        { "package" => { "ecosystem" => "GIT", "name" => "https://github.com/jqlang/jq" },
          "ranges"  => [{ "type" => "GIT", "events" => [{ "fixed" => "e47e56d" }] }] },
      ])
      hit = make_hit(v, ev(:git, ecosystem: "GIT", name: "https://github.com/jqlang/jq",
                                 subject_version: "1.8.1"))

      expect(matcher.range_status(hit)).to be_nil
    end

    it "evaluates CPANSA constraint strings for :cpansa evidence" do
      adv = Homebrew::Vulns::CPANSec::Advisory.new(id: "CPANSA-X", cves: ["CVE-1"],
                                                   affected_versions: ["<12.24"],
                                                   fixed_versions: [">=12.24"])
      hit = make_hit(vuln("id" => "CVE-1"),
                     ev(:cpansa, ecosystem: "CPAN", name: "Image-ExifTool",
                                 subject_version: "13.55", advisory: adv))

      expect(matcher.range_status(hit)&.first).to have_attributes(state: :fixed, fixed_in: "12.24")
    end

    it "checks a distro-resolved upstream CVE against attached own-identity evidence" do
      v = vuln("id" => "CVE-2015-8863", "affected" => [
        { "package" => { "ecosystem" => "GIT", "name" => "https://github.com/jqlang/jq" },
          "ranges"  => [{ "type"   => "SEMVER",
                          "events" => [{ "introduced" => "0" }, { "fixed" => "1.6" }] }] },
      ])
      hit = make_hit(v,
                     ev(:distro, ecosystem: "Debian", name: "jq"),
                     ev(:distro, ecosystem: "GIT", name: "https://github.com/jqlang/jq",
                                 subject_version: "1.8.1", key: "upstream:..."))

      expect(matcher.range_status(hit)&.first).to have_attributes(state: :fixed, fixed_in: "1.6")
    end

    it "skips evidence with no subject_version" do
      hit = make_hit(vuln("id" => "CVE-1"), ev(:distro, ecosystem: "Debian", name: "jq"))
      expect(matcher.range_status(hit)).to be_nil
    end

    it "checks each evidence against its own source record after dedup merges hits" do
      # CVE record from GIT query: no PyPI affected entry.
      cve = vuln("id" => "CVE-2024-47081", "affected" => [
        { "package" => { "ecosystem" => "GIT", "name" => "https://github.com/psf/requests" },
          "ranges"  => [{ "type" => "GIT", "events" => [{ "fixed" => "abc123" }] }] },
      ])
      # GHSA record from PyPI query: carries the PyPI range.
      ghsa = vuln("id" => "GHSA-9hjg-9r4m-mvj7", "aliases" => ["CVE-2024-47081"], "affected" => [
        { "package" => { "ecosystem" => "PyPI", "name" => "requests" },
          "ranges"  => [{ "type"   => "ECOSYSTEM",
                          "events" => [{ "introduced" => "0" }, { "fixed" => "2.32.4" }] }] },
      ])
      merged = matcher.dedup_by_cve([
        make_hit(cve, ev(:git, ecosystem: "GIT", name: "https://github.com/psf/requests",
                               subject_version: "2.31.0")),
        make_hit(ghsa, ev(:registry, ecosystem: "PyPI", name: "requests", subject_version: "2.31.0")),
      ])

      expect(merged.length).to eq 1
      status, evidence = matcher.range_status(merged.first)
      expect(status).to have_attributes(state: :affected, fixed_in: "2.32.4")
      expect(evidence.source_record.id).to eq "GHSA-9hjg-9r4m-mvj7"
    end

    it "reports :affected when a resource subject is affected even if the primary is :not_applicable" do
      v = vuln("id" => "CVE-1", "affected" => [
        { "package" => { "ecosystem" => "PyPI", "name" => "requests" },
          "ranges"  => [{ "type"   => "ECOSYSTEM",
                          "events" => [{ "introduced" => "3.0.0" }, { "fixed" => "3.0.4" }] }] },
        { "package" => { "ecosystem" => "PyPI", "name" => "certifi" },
          "ranges"  => [{ "type"   => "ECOSYSTEM",
                          "events" => [{ "introduced" => "0" }, { "fixed" => "2025.1.1" }] }] },
      ])
      hit = make_hit(v,
                     ev(:registry, ecosystem: "PyPI", name: "requests", subject_version: "2.31.0"),
                     ev(:registry, ecosystem: "PyPI", name: "certifi", subject_version: "2024.2.2",
                                   resource: "certifi"))

      status, evidence = matcher.range_status(hit)
      expect(status).to have_attributes(state: :affected, fixed_in: "2025.1.1")
      expect(evidence.resource).to eq "certifi"
    end

    it "reports :affected when a resource is affected even if the primary is :fixed" do
      v = vuln("id" => "CVE-1", "affected" => [
        { "package" => { "ecosystem" => "PyPI", "name" => "requests" },
          "ranges"  => [{ "type"   => "ECOSYSTEM",
                          "events" => [{ "introduced" => "0" }, { "fixed" => "2.28.1" }] }] },
        { "package" => { "ecosystem" => "PyPI", "name" => "certifi" },
          "ranges"  => [{ "type"   => "ECOSYSTEM",
                          "events" => [{ "introduced" => "0" }, { "fixed" => "2025.1.1" }] }] },
      ])
      hit = make_hit(v,
                     ev(:registry, ecosystem: "PyPI", name: "requests", subject_version: "2.31.0"),
                     ev(:registry, ecosystem: "PyPI", name: "certifi", subject_version: "2024.2.2",
                                   resource: "certifi"))

      expect(matcher.range_status(hit)&.first).to have_attributes(state: :affected)
    end

    it "reports :not_applicable only when every comparable subject is not_applicable" do
      v = vuln("id" => "CVE-1", "affected" => [
        { "package" => { "ecosystem" => "PyPI", "name" => "requests" },
          "ranges"  => [{ "type"   => "ECOSYSTEM",
                          "events" => [{ "introduced" => "3.0.0" }, { "fixed" => "3.0.4" }] }] },
      ])
      hit = make_hit(v,
                     ev(:registry, ecosystem: "PyPI", name: "requests", subject_version: "2.31.0"),
                     ev(:distro, ecosystem: "Debian", name: "requests"))

      expect(matcher.range_status(hit)&.first&.state).to eq :not_applicable
    end
  end

  describe "#hits_from" do
    let(:cpan_sec) do
      Homebrew::Vulns::CPANSec.new({ "meta" => {}, "dists" => {
        "No-CVE-Dist" => { "advisories" => [
          { "id"                => "CPANSA-No-CVE-Dist-2020-01", "cves" => [],
            "affected_versions" => ["<1.0"], "fixed_versions" => [">=1.0"],
            "description" => "d", "references" => ["https://x"] },
        ] },
      } })
    end

    it "scopes a synthesised fallback to the CVE being handled when OSV lacks it" do
      cpan_sec = Homebrew::Vulns::CPANSec.new({ "meta" => {}, "dists" => {
        "Multi" => { "advisories" => [
          { "id" => "CPANSA-Multi-1", "cves" => ["CVE-2022-4988", "CVE-2022-4989"],
            "affected_versions" => ["<1.0"], "fixed_versions" => [">=1.0"] },
        ] },
      } })
      m = described_class.new(repology:, cpan_sec:)
      identity = Homebrew::Vulns::Match::Identity.new(
        git_repo: nil, git_tag: nil,
        primary_package: Homebrew::Vulns::Identify::RegistryPackage.new(
          ecosystem: "CPAN", name: "Multi", version: "0.9", purl: "pkg:cpan/X/Multi@0.9",
        ),
        resource_packages: {}, distro_packages: {}
      )
      allow(Homebrew::Vulns::OSV).to receive(:vulnerability).with("CVE-2022-4988")
                                                            .and_raise(Homebrew::Vulns::OSV::ApiError, "404")
      allow(Homebrew::Vulns::OSV).to receive(:vulnerability).with("CVE-2022-4989")
                                                            .and_return({ "id" => "CVE-2022-4989" })

      hits = m.hits_from({}, identity)

      expect(hits.map(&:canonical_id).sort).to eq ["CVE-2022-4988", "CVE-2022-4989"]
    end

    it "builds a hit directly from a CPANSA advisory that has no CVE alias" do
      identity = Homebrew::Vulns::Match::Identity.new(
        git_repo: nil, git_tag: nil,
        primary_package: Homebrew::Vulns::Identify::RegistryPackage.new(
          ecosystem: "CPAN", name: "No-CVE-Dist", version: "0.9", purl: "pkg:cpan/X/No-CVE-Dist@0.9",
        ),
        resource_packages: {}, distro_packages: {}
      )
      expect(Homebrew::Vulns::OSV).not_to receive(:vulnerability)

      hits = matcher.hits_from({}, identity)

      expect(hits.length).to eq 1
      expect(hits.first.vulnerability.id).to eq "CPANSA-No-CVE-Dist-2020-01"
      expect(hits.first.vulnerability.references).to eq [{ "type" => "WEB", "url" => "https://x" }]
      expect(matcher.range_status(hits.first)&.first)
        .to have_attributes(state: :affected, fixed_in: "1.0")
    end
  end

  describe "#resolve_upstream" do
    let(:identity) do
      Homebrew::Vulns::Match::Identity.new(
        git_repo: "https://github.com/jqlang/jq", git_tag: "1.8.1",
        primary_package: nil, resource_packages: {}, distro_packages: {}
      )
    end

    it "splits a multi-CVE distro advisory into one hit per upstream CVE with own-identity evidence" do
      allow(matcher).to receive(:fetch_vulnerability).with("RHSA-2026:1").and_return(
        vuln("id" => "RHSA-2026:1", "upstream" => ["CVE-2026-0001", "CVE-2026-0002"]),
      )
      allow(matcher).to receive(:fetch_vulnerability).with("CVE-2026-0001")
                                                     .and_return(vuln("id" => "CVE-2026-0001"))
      allow(matcher).to receive(:fetch_vulnerability).with("CVE-2026-0002")
                                                     .and_return(vuln("id" => "CVE-2026-0002"))

      hits = matcher.resolve_upstream(
        { "RHSA-2026:1" => [ev(:distro, ecosystem: "Red Hat", name: "jq")] }, identity
      )

      expect(hits.map { |h| h.vulnerability.id }.sort).to eq ["CVE-2026-0001", "CVE-2026-0002"]
      expect(hits.first.evidence.map(&:ecosystem)).to include("Red Hat", "GIT")
    end

    it "follows upstream transitively (USN -> UBUNTU-CVE-* -> CVE-*) with cycle protection" do
      allow(matcher).to receive(:fetch_vulnerability).with("USN-8202-1").and_return(
        vuln("id" => "USN-8202-1", "upstream" => ["UBUNTU-CVE-2024-0001"]),
      )
      allow(matcher).to receive(:fetch_vulnerability).with("UBUNTU-CVE-2024-0001").and_return(
        vuln("id" => "UBUNTU-CVE-2024-0001", "upstream" => ["CVE-2024-0001", "USN-8202-1"]),
      )
      allow(matcher).to receive(:fetch_vulnerability).with("CVE-2024-0001")
                                                     .and_return(vuln("id" => "CVE-2024-0001"))

      hits = matcher.resolve_upstream({ "USN-8202-1" => [ev(:distro)] }, identity)
      expect(hits.map { |h| h.vulnerability.id }).to eq ["CVE-2024-0001"]
    end

    it "consults related for bare CVE ids only for ALSA-* records with no upstream" do
      allow(matcher).to receive(:fetch_vulnerability).with("ALSA-1").and_return(
        vuln("id" => "ALSA-1", "related" => ["CVE-2024-0001", "RHSA-2024:1"]),
      )
      allow(matcher).to receive(:fetch_vulnerability).with("CVE-2024-0001")
                                                     .and_return(vuln("id" => "CVE-2024-0001"))

      hits = matcher.resolve_upstream({ "ALSA-1" => [ev(:distro)] }, identity)
      expect(hits.map { |h| h.vulnerability.id }).to eq ["CVE-2024-0001"]
    end

    it "does not consult related for a non-ALSA record with no upstream" do
      allow(matcher).to receive(:fetch_vulnerability).with("MGASA-1").and_return(
        vuln("id" => "MGASA-1", "related" => ["CVE-2024-9999"]),
      )
      hits = matcher.resolve_upstream({ "MGASA-1" => [ev(:distro)] }, identity)
      expect(hits.map { |h| h.vulnerability.id }).to eq ["MGASA-1"]
    end

    it "ignores related when upstream is present" do
      allow(matcher).to receive(:fetch_vulnerability).with("DSA-1").and_return(
        vuln("id" => "DSA-1", "upstream" => ["CVE-2024-0001"], "related" => ["CVE-9999-9999"]),
      )
      allow(matcher).to receive(:fetch_vulnerability).with("CVE-2024-0001")
                                                     .and_return(vuln("id" => "CVE-2024-0001"))

      hits = matcher.resolve_upstream({ "DSA-1" => [ev(:distro)] }, identity)
      expect(hits.map { |h| h.vulnerability.id }).to eq ["CVE-2024-0001"]
    end

    it "keeps a record whose id/aliases already include a CVE as-is" do
      allow(matcher).to receive(:fetch_vulnerability).with("CVE-2024-0001").and_return(
        vuln("id" => "CVE-2024-0001", "upstream" => ["CVE-2024-0099"]),
      )
      hits = matcher.resolve_upstream({ "CVE-2024-0001" => [ev(:git)] }, identity)
      expect(hits.map { |h| h.vulnerability.id }).to eq ["CVE-2024-0001"]
    end

    it "keeps a record with no CVE anywhere as a low-confidence hit rather than dropping it" do
      allow(matcher).to receive(:fetch_vulnerability).with("ALBA-2022:1788").and_return(
        vuln("id" => "ALBA-2022:1788", "upstream" => [], "related" => ["RHBA-2022:1788"]),
      )
      hits = matcher.resolve_upstream({ "ALBA-2022:1788" => [ev(:distro)] }, identity)
      expect(hits.map { |h| h.vulnerability.id }).to eq ["ALBA-2022:1788"]
    end

    it "keeps a record as-is when its upstream CVE cannot be fetched" do
      allow(matcher).to receive(:fetch_vulnerability).with("DSA-1").and_return(
        vuln("id" => "DSA-1", "upstream" => ["CVE-2024-0404"]),
      )
      allow(matcher).to receive(:fetch_vulnerability).with("CVE-2024-0404").and_return(nil)
      hits = matcher.resolve_upstream({ "DSA-1" => [ev(:distro)] }, identity)
      expect(hits.map { |h| h.vulnerability.id }).to eq ["DSA-1"]
    end
  end

  describe "#each_advisory_batch" do
    it "sends every formula's queries through one OSV.query_batch and yields per-formula hits" do
      a = formula("aa") do
        T.bind(self, T.class_of(Formula))
        url "https://github.com/owner/aa/archive/refs/tags/v1.0.tar.gz"
      end
      b = formula("bb") do
        T.bind(self, T.class_of(Formula))
        url "https://github.com/owner/bb/archive/refs/tags/v2.0.tar.gz"
      end
      bulk = described_class.new(repology:, cpan_sec:, bulk: true)

      expect(Homebrew::Vulns::OSV).to receive(:query_batch).once.with(
        [
          { ecosystem: "GIT", name: "https://github.com/owner/aa", version: nil },
          { ecosystem: "GIT", name: "https://github.com/owner/bb", version: nil },
        ],
      ).and_return([[{ "id" => "CVE-2024-0001" }], []])
      allow(Homebrew::Vulns::OSV).to receive(:vulnerability).with("CVE-2024-0001")
                                                            .and_return({ "id" => "CVE-2024-0001" })

      yielded = T.let([], T::Array[[String, T::Array[String]]])
      bulk.each_advisory_batch([a, b]) { |f, hits| yielded << [f.name, hits.map(&:canonical_id)] }

      expect(yielded).to eq [["aa", ["CVE-2024-0001"]], ["bb", []]]
    end
  end

  describe "#advisories_for" do
    let(:exiftool) do
      formula("exiftool") do
        T.bind(self, T.class_of(Formula))
        url "https://cpan.metacpan.org/authors/id/E/EX/EXIFTOOL/Image-ExifTool-13.55.tar.gz"
        head "https://github.com/exiftool/exiftool.git"
      end
    end

    before { stub_repology_lookup({ "Debian" => ["libimage-exiftool-perl"] }) }

    it "queries versionlessly, resolves distro upstream to CVEs, and dedups by CVE alias" do
      expect(Homebrew::Vulns::OSV).to receive(:query_batch).with(
        [
          { ecosystem: "GIT", name: "https://github.com/exiftool/exiftool", version: nil },
          { ecosystem: "Debian", name: "libimage-exiftool-perl", version: nil },
        ],
      ).and_return(
        [
          [{ "id" => "CVE-2021-22204" }],
          [{ "id" => "DEBIAN-CVE-2021-22204" }, { "id" => "DSA-4910-1" }],
        ],
      )
      allow(Homebrew::Vulns::OSV).to receive(:vulnerability).with("CVE-2021-22204").and_return(
        { "id" => "CVE-2021-22204", "aliases" => ["GHSA-xxxx"] },
      )
      allow(Homebrew::Vulns::OSV).to receive(:vulnerability).with("DEBIAN-CVE-2021-22204").and_return(
        { "id" => "DEBIAN-CVE-2021-22204", "upstream" => ["CVE-2021-22204"] },
      )
      allow(Homebrew::Vulns::OSV).to receive(:vulnerability).with("DSA-4910-1").and_return(
        { "id" => "DSA-4910-1", "upstream" => ["CVE-2021-22204", "CVE-2021-99999"] },
      )
      allow(Homebrew::Vulns::OSV).to receive(:vulnerability).with("CVE-2021-99999").and_return(
        { "id" => "CVE-2021-99999" },
      )

      hits = matcher.advisories_for(exiftool)

      expect(hits.map(&:canonical_id).sort).to eq ["CVE-2021-22204", "CVE-2021-99999"]
      merged = hits.find { |h| h.canonical_id == "CVE-2021-22204" }
      expect(T.must(merged).strategy).to eq :git
      expect(T.must(merged).evidence.map(&:strategy).uniq.sort).to eq [:cpansa, :distro, :git]
      expect(T.must(merged).evidence.find { |e| e.strategy == :cpansa }&.advisory).not_to be_nil
    end

    it "returns [] without hitting OSV when nothing is identifiable" do
      f = formula("mystery") do
        T.bind(self, T.class_of(Formula))
        url "https://example.com/mystery-1.0.tar.gz"
      end
      stub_repology_lookup
      expect(Homebrew::Vulns::OSV).not_to receive(:query_batch)

      expect(matcher.advisories_for(f)).to eq []
    end

    it "caches OSV.vulnerability lookups across calls" do
      allow(Homebrew::Vulns::OSV).to receive(:query_batch)
        .and_return([[{ "id" => "CVE-2021-22204" }], []])
      expect(Homebrew::Vulns::OSV).to receive(:vulnerability).once
                                                             .and_return({ "id" => "CVE-2021-22204" })

      matcher.advisories_for(exiftool)
      matcher.advisories_for(exiftool)
    end
  end

  describe "#to_brew_record" do
    let(:requests) do
      formula("requests") do
        T.bind(self, T.class_of(Formula))
        url "https://files.pythonhosted.org/packages/aa/bb/cc/requests-2.31.0.tar.gz"
        resource "certifi" do
          url "https://files.pythonhosted.org/packages/11/22/33/certifi-2024.2.2.tar.gz"
        end
      end
    end
    let(:now) { Time.utc(2026, 7, 27, 12, 0, 0) }

    def registry_hit(affected_events:, subject_version: "2.31.0", resource: nil, name: "requests")
      make_hit(
        vuln("id" => "CVE-2024-1234", "aliases" => ["GHSA-abcd"], "summary" => "s",
             "severity" => [{ "type" => "CVSS_V3", "score" => "..." }],
             "references" => [{ "type" => "ADVISORY", "url" => "https://x" }],
             "affected" => [{ "package" => { "ecosystem" => "PyPI", "name" => name },
                              "ranges"  => [{ "type" => "ECOSYSTEM", "events" => affected_events }] }]),
        ev(:registry, ecosystem: "PyPI", name:, subject_version:,
                      key: "pkg:pypi/#{name}@#{subject_version}", resource:),
      )
    end

    it "emits fixed=pkg_version and fix: bump when the range says the shipped version is not affected" do
      hit = registry_hit(affected_events: [{ "introduced" => "0" }, { "fixed" => "2.28.1" }])

      record = matcher.to_brew_record(requests, hit, now:)

      expect(record[:id]).to eq "BREW-requests-CVE-2024-1234"
      expect(record[:upstream]).to eq ["CVE-2024-1234", "GHSA-abcd"]
      expect(record[:severity]).to eq [{ "type" => "CVSS_V3", "score" => "..." }]
      expect(record[:references]).to eq [{ "type" => "ADVISORY", "url" => "https://x" }]
      aff = record[:affected].first
      expect(aff[:package]).to eq(ecosystem: "Homebrew", name: "requests", purl: "pkg:brew/requests")
      expect(aff[:ranges]).to eq [{ type:   "ECOSYSTEM",
                                    events: [{ introduced: "0" }, { fixed: requests.pkg_version.to_s }] }]
      expect(aff[:ecosystem_specific]).to eq(fix: "bump", range_state: "fixed", upstream_fixed_in: "2.28.1")
      expect(record.dig(:database_specific, :source)).to eq "matched"
      expect(record.dig(:database_specific, :strategy)).to eq "registry"
      expect(record.dig(:database_specific, :confidence)).to eq "high"
    end

    it "emits no fixed event and fix: nil when the range says the shipped version is still affected" do
      hit = registry_hit(affected_events: [{ "introduced" => "0" }, { "fixed" => "2.32.0" }])

      record = matcher.to_brew_record(requests, hit, now:)

      aff = record[:affected].first
      expect(aff[:ranges]).to eq [{ type: "ECOSYSTEM", events: [{ introduced: "0" }] }]
      expect(aff[:ecosystem_specific]).to eq(fix: nil, range_state: "affected", upstream_fixed_in: "2.32.0")
    end

    it "emits fix: nil and demotes confidence when no comparable range exists (GIT-only)" do
      hit = make_hit(
        vuln("id" => "CVE-2026-32316", "affected" => [
          { "package" => { "ecosystem" => "GIT", "name" => "https://github.com/jqlang/jq" },
            "ranges"  => [{ "type" => "GIT", "events" => [{ "fixed" => "e47e56d" }] }] },
        ]),
        ev(:git, ecosystem: "GIT", name: "https://github.com/jqlang/jq", subject_version: "1.8.1"),
      )

      record = matcher.to_brew_record(requests, hit, now:)

      expect(record.dig(:affected, 0, :ranges, 0, :events)).to eq [{ introduced: "0" }]
      expect(record.dig(:affected, 0, :ecosystem_specific)).to eq(fix: nil)
      expect(record.dig(:database_specific, :confidence)).to eq "medium"
    end

    it "records not_applicable and does not emit fixed for a version below every introduced" do
      hit = registry_hit(affected_events: [{ "introduced" => "3.0.0" }, { "fixed" => "3.0.4" }])

      record = matcher.to_brew_record(requests, hit, now:)

      expect(record.dig(:affected, 0, :ranges, 0, :events)).to eq [{ introduced: "0" }]
      expect(record.dig(:affected, 0, :ecosystem_specific)).to eq(fix: nil, range_state: "not_applicable")
    end

    it "prefers an explicit first_fixed over the derived value" do
      hit = registry_hit(affected_events: [{ "introduced" => "0" }, { "fixed" => "2.28.1" }])

      record = matcher.to_brew_record(requests, hit, first_fixed: "2.28.1_1", now:)

      expect(record.dig(:affected, 0, :ranges, 0, :events)).to eq [{ introduced: "0" }, { fixed: "2.28.1_1" }]
    end

    it "records resource name and purl and evaluates against the resource's pinned version" do
      hit = registry_hit(affected_events: [{ "introduced" => "0" }, { "fixed" => "2024.2.2" }],
                         subject_version: "2024.2.2", resource: "certifi", name: "certifi")

      record = matcher.to_brew_record(requests, hit, now:)

      expect(record.dig(:affected, 0, :ecosystem_specific))
        .to eq(fix: "bump", range_state: "fixed", upstream_fixed_in: "2024.2.2",
               resource: "certifi", resource_purl: "pkg:pypi/certifi@2024.2.2")
      expect(record.dig(:affected, 0, :ranges, 0, :events).last).to eq(fixed: requests.pkg_version.to_s)
    end
  end

  describe "#first_fixed_version" do
    let(:requests) do
      formula("requests") do
        T.bind(self, T.class_of(Formula))
        url "https://files.pythonhosted.org/packages/aa/bb/cc/requests-2.31.0.tar.gz"
      end
    end

    def stub_history(versions_newest_first)
      fv = instance_double(FormulaVersions)
      revs = versions_newest_first.each_with_index.map { |_, i| ["r#{i}", "Formula/r/requests.rb"] }
      allow(fv).to receive(:rev_list) { |_, &b| revs.each { |rev, entry| b.call(rev, entry) } }
      versions_newest_first.each_with_index do |entry, i|
        primary, res = Array(entry)
        old = if primary
          formula("requests") do
            T.bind(self, T.class_of(Formula))
            url "https://files.pythonhosted.org/packages/aa/bb/cc/requests-#{primary}.tar.gz"
            if res
              resource "certifi" do
                url "https://files.pythonhosted.org/packages/11/22/33/certifi-#{res}.tar.gz"
              end
            end
          end
        end
        allow(fv).to receive(:formula_at_revision).with("r#{i}", anything) do |&b|
          old && b.call(old)
        end
      end
      allow(FormulaVersions).to receive(:new).and_return(fv)
    end

    def hit_with_range(*events)
      make_hit(
        vuln("id" => "CVE-1", "affected" => [
          { "package" => { "ecosystem" => "PyPI", "name" => "requests" },
            "ranges"  => [{ "type" => "ECOSYSTEM", "events" => events }] },
        ]),
        ev(:registry, ecosystem: "PyPI", name: "requests", subject_version: "2.31.0"),
      )
    end

    def hit_fixed_at(fixed)
      hit_with_range({ "introduced" => "0" }, { "fixed" => fixed })
    end

    it "returns the pkg_version at the oldest revision still at or past upstream fixed_in" do
      stub_history(["2.31.0", "2.30.0", "2.28.1", "2.28.0", "2.27.0"])
      expect(matcher.first_fixed_version(requests, hit_fixed_at("2.28.1"))).to eq "2.28.1"
    end

    it "honours last_affected inclusivity by re-running the range per revision" do
      stub_history(["2.31.0", "2.1", "2.0", "1.9"])
      hit = hit_with_range({ "introduced" => "0" }, { "last_affected" => "2.0" })
      # 2.0 is the last *affected* version so 2.1 is the first fixed pkg_version.
      expect(matcher.first_fixed_version(requests, hit)).to eq "2.1"
    end

    it "returns :never_affected when Homebrew jumped from below introduced straight past fixed" do
      # Advisory {introduced: 2.0, fixed: 3.0}; Homebrew went 1.0 -> 4.0 and
      # never shipped a 2.x, so no BREW record should be emitted.
      stub_history(["4.0", "1.0"])
      current = formula("requests") do
        T.bind(self, T.class_of(Formula))
        url "https://files.pythonhosted.org/packages/aa/bb/cc/requests-4.0.tar.gz"
      end
      hit = make_hit(
        vuln("id" => "CVE-1", "affected" => [
          { "package" => { "ecosystem" => "PyPI", "name" => "requests" },
            "ranges"  => [{ "type"   => "ECOSYSTEM",
                            "events" => [{ "introduced" => "2.0" }, { "fixed" => "3.0" }] }] },
        ]),
        ev(:registry, ecosystem: "PyPI", name: "requests", subject_version: "4.0"),
      )

      expect(matcher.first_fixed_version(current, hit)).to eq :never_affected
    end

    it "returns :never_affected when the formula was already past fixed at its first revision" do
      stub_history(["2.31.0"])
      expect(matcher.first_fixed_version(requests, hit_fixed_at("2.28.1"))).to eq :never_affected
    end

    it "keeps versionless (distro) evidence uncheckable at historical revisions too" do
      stub_history(["2.31.0", "2.30.0", "2.28.1", "2.28.0"])
      # A distro record whose Debian range would spuriously match our formula
      # version if it were compared: ensure it stays skipped in the walk.
      distro_record = vuln("id" => "DEBIAN-CVE-1", "affected" => [
        { "package" => { "ecosystem" => "Debian", "name" => "requests" },
          "ranges"  => [{ "type"   => "ECOSYSTEM",
                          "events" => [{ "introduced" => "0" }, { "fixed" => "999+deb12u1" }] }] },
      ])
      registry_record = vuln("id" => "GHSA-x", "aliases" => ["CVE-1"], "affected" => [
        { "package" => { "ecosystem" => "PyPI", "name" => "requests" },
          "ranges"  => [{ "type"   => "ECOSYSTEM",
                          "events" => [{ "introduced" => "0" }, { "fixed" => "2.28.1" }] }] },
      ])
      hit = matcher.dedup_by_cve([
        make_hit(registry_record,
                 ev(:registry, ecosystem: "PyPI", name: "requests", subject_version: "2.31.0")),
        make_hit(distro_record,
                 ev(:distro, ecosystem: "Debian", name: "requests", subject_version: nil)),
      ]).first

      expect(matcher.first_fixed_version(requests, hit)).to eq "2.28.1"
    end

    it "aggregates every subject per revision so a fixed primary does not mask a later-fixed resource" do
      # Primary requests fixed upstream in 2.0; resource certifi fixed upstream in 100.0.
      # History (formula pkg_version => [primary, certifi]): the resource crossed its
      # threshold at formula 3.0; the primary crossed at 2.0. Aggregate is only :fixed
      # from 3.0 onward.
      stub_history([["4.0", "101.0"], ["3.0", "100.0"], ["2.5", "99.0"], ["2.0", "98.0"], ["1.0", "97.0"]])
      v = vuln("id" => "CVE-1", "affected" => [
        { "package" => { "ecosystem" => "PyPI", "name" => "requests" },
          "ranges"  => [{ "type" => "ECOSYSTEM", "events" => [{ "introduced" => "0" }, { "fixed" => "2.0" }] }] },
        { "package" => { "ecosystem" => "PyPI", "name" => "certifi" },
          "ranges"  => [{ "type" => "ECOSYSTEM", "events" => [{ "introduced" => "0" }, { "fixed" => "100.0" }] }] },
      ])
      current = formula("requests") do
        T.bind(self, T.class_of(Formula))
        url "https://files.pythonhosted.org/packages/aa/bb/cc/requests-4.0.tar.gz"
        resource("certifi") { url "https://files.pythonhosted.org/packages/11/22/33/certifi-101.0.tar.gz" }
      end
      hit = make_hit(v,
                     ev(:registry, ecosystem: "PyPI", name: "requests", subject_version: "4.0"),
                     ev(:registry, ecosystem: "PyPI", name: "certifi", subject_version: "101.0",
                                   resource: "certifi"))

      expect(matcher.first_fixed_version(current, hit)).to eq "3.0"
    end

    it "stops at an unloadable revision and returns the last known fixed pkg_version" do
      stub_history(["2.31.0", "2.30.0", nil, "2.28.0"])
      expect(matcher.first_fixed_version(requests, hit_fixed_at("2.28.1"))).to eq "2.30.0"
    end

    it "returns nil when the current version is still affected" do
      expect(FormulaVersions).not_to receive(:new)
      expect(matcher.first_fixed_version(requests, hit_fixed_at("2.32.0"))).to be_nil
    end

    it "returns nil when there is no comparable range" do
      hit = make_hit(vuln("id" => "CVE-1"), ev(:distro, ecosystem: "Debian", name: "requests"))
      expect(matcher.first_fixed_version(requests, hit)).to be_nil
    end
  end

  describe Homebrew::Vulns::Match::Hit do
    it "sorts evidence by descending strategy precision and reports the highest as #strategy" do
      hit = make_hit(vuln("id" => "CVE-1"), ev(:distro), ev(:git), ev(:registry))
      expect(hit.evidence.map(&:strategy)).to eq [:git, :registry, :distro]
      expect(hit.strategy).to eq :git
    end

    it "uses the lowest CVE alias as canonical_id, or the record id when there is none" do
      expect(make_hit(vuln("id" => "GHSA-x", "aliases" => ["CVE-2024-2", "CVE-2024-1"]),
                      ev(:git)).canonical_id).to eq "CVE-2024-1"
      expect(make_hit(vuln("id" => "GHSA-y"), ev(:git)).canonical_id).to eq "GHSA-y"
    end

    it "rejects empty evidence" do
      expect { described_class.new(vulnerability: vuln("id" => "CVE-1"), evidence: []) }
        .to raise_error(ArgumentError)
    end
  end
end
