# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements.  See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership.  The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License.  You may obtain a copy of the License at
#
#   http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied.  See the License for the
# specific language governing permissions and limitations
# under the License.
"""Unit tests for pure helpers in ``generate_cve_json``.

Everything that shells out to ``gh`` or touches the network lives in
``fetch_issue``, ``attach_to_issue``, ``_gh_api_json``, and
``_find_existing_attachment_comment_id``. Those are excluded from this
test suite; they can be exercised against a live issue by running the
CLI end-to-end.
"""

from __future__ import annotations

import json
from collections.abc import Iterator
from pathlib import Path
from typing import Any

import pytest

from generate_cve_json import (
    _build_attachment_body,
    _is_cna_ready_for_review,
    _product_for_package,
    build_affected,
    build_cna_container,
    build_credits,
    build_descriptions,
    build_metrics,
    build_problem_types,
    build_references,
    classify_reference,
    combine_remediation_developers,
    compute_cna_private_state,
    compute_package_url,
    emit_json,
    extract_field,
    format_version_range,
    parse_affected_versions,
    parse_credits_from_field,
    parse_cve_id,
    parse_cwe,
    parse_url_list,
    resolve_title,
    wrap_cve_record,
)
from generate_cve_json.cve_json import is_bot_credit, normalise_severity, to_html

DEFAULT_AFFECTED_ARGS: dict[str, Any] = {
    "vendor": "Apache Software Foundation",
    "product": "Apache Example",
    "package_name": "apache-example",
    "collection_url": "https://pypi.python.org",
    "version_start": None,
}

# ---------------------------------------------------------------------------
# Issue body field extraction
# ---------------------------------------------------------------------------


class TestExtractField:
    def test_returns_value_up_to_next_field(self):
        body = (
            "### The issue description\nSummary text.\n\n### Affected versions\n>=3.0.0\n\n### CWE\nCWE-352\n"
        )
        assert extract_field(body, "Affected versions") == ">=3.0.0"
        assert extract_field(body, "CWE") == "CWE-352"

    def test_missing_field_returns_empty_string(self):
        body = "### Short public summary for publish\n\nHello.\n"
        assert extract_field(body, "CVE tool link") == ""

    def test_no_response_placeholder_is_normalised_to_empty(self):
        body = "### Severity\n\n_No response_\n"
        assert extract_field(body, "Severity") == ""

    def test_trailing_whitespace_is_stripped(self):
        body = "### Short public summary for publish\n\nHello.   \n\n### Affected versions\n\n\n"
        assert extract_field(body, "Short public summary for publish") == "Hello."

    def test_public_advisory_url_field_is_separate_from_security_mailing_list_thread(self):
        body = (
            "### Security mailing list thread\n\n"
            "https://lists.apache.org/thread/fake-security-private-hash\n\n"
            "### Public advisory URL\n\n"
            "https://lists.apache.org/thread/real-users-archive-id?users@airflow.apache.org\n\n"
            "### Reporter credited as\n\nAlice\n"
        )
        # The two fields are addressed independently by name; the private
        # security@ URL stays in the body but is never exported.
        assert (
            extract_field(body, "Security mailing list thread")
            == "https://lists.apache.org/thread/fake-security-private-hash"
        )
        assert (
            extract_field(body, "Public advisory URL")
            == "https://lists.apache.org/thread/real-users-archive-id?users@airflow.apache.org"
        )


# ---------------------------------------------------------------------------
# Credit parsing
# ---------------------------------------------------------------------------


class TestParseCreditsFromField:
    def test_newline_separated_credits_produce_multiple_entries(self):
        value = "Alice Smith\nBob Jones (Acme Corp)"
        assert parse_credits_from_field(value) == ["Alice Smith", "Bob Jones (Acme Corp)"]

    def test_comma_inside_a_credit_stays_in_the_same_credit(self):
        value = "Jane Doe, Acme Security"
        # Comma-splitting is deliberately off: "Name, Affiliation" is ONE credit.
        assert parse_credits_from_field(value) == ["Jane Doe, Acme Security"]

    def test_bullets_are_stripped(self):
        value = "- Alice\n* Bob\n+ Charlie\n1. Diana"
        assert parse_credits_from_field(value) == ["Alice", "Bob", "Charlie", "Diana"]

    def test_duplicates_are_removed_preserving_order(self):
        value = "Alice\nBob\nAlice"
        assert parse_credits_from_field(value) == ["Alice", "Bob"]

    def test_empty_field_returns_empty_list(self):
        assert parse_credits_from_field("") == []


# ---------------------------------------------------------------------------
# Bot / AI credit detection (drives `type: "tool"` in build_credits)
# ---------------------------------------------------------------------------


class TestIsBotCredit:
    @pytest.mark.parametrize(
        "name",
        [
            "dependabot[bot]",
            "github-actions[bot]",
            "renovate[bot]",
            "copilot[bot]",
            "ghsa-probot[bot]",
        ],
    )
    def test_github_bot_suffix_matches(self, name: str):
        assert is_bot_credit(name) is True

    @pytest.mark.parametrize(
        "name",
        [
            "Dependabot",
            "DEPENDABOT",
            "Snyk",
            "Renovate",
            "github-actions",
            "Claude",
            "ChatGPT",
            "Mend",
            "Whitesource",
        ],
    )
    def test_known_name_list_matches_case_insensitively(self, name: str):
        assert is_bot_credit(name) is True

    @pytest.mark.parametrize(
        "name",
        [
            "discovered by Automated Scanner v3",
            "reported via security-scanner",
            "found by Renovate during dependency sweep",
        ],
    )
    def test_known_name_matches_inside_free_form_string(self, name: str):
        assert is_bot_credit(name) is True

    @pytest.mark.parametrize(
        "name",
        [
            "claude-bot",
            "release-bot",
            "securitybot",
            "scan-ai",
            "bugbunny.ai",  # domain-style AI-service handle
            "xbow.ai",
            "triage-agent",
            "secaudit-gpt",
            "securityscanner-7",
            "automated-triage",
            "automaton",
        ],
    )
    def test_pattern_handles_match(self, name: str):
        assert is_bot_credit(name) is True

    @pytest.mark.parametrize(
        "name",
        [
            "Alice Smith",
            "Bob Jones (Acme Corp)",
            "Jane Doe, Acme Security",
            "Joe Bot",  # space breaks word boundary on *bot/-bot patterns
            "Botev Martinov",  # word boundary stops the bot suffix at "Botev"
            "Renovation Engineering Inc",  # 'renovate' substring, not whole word
            "AI Research Lab",  # uppercase free-standing; no *-ai handle shape
        ],
    )
    def test_human_names_do_not_match(self, name: str):
        assert is_bot_credit(name) is False

    def test_empty_string_does_not_match(self):
        assert is_bot_credit("") is False

    def test_whitespace_only_does_not_match(self):
        assert is_bot_credit("   ") is False


class TestBuildCreditsBotTypeAssignment:
    def test_plain_human_credit_gets_finder_type(self):
        credits = build_credits("Alice Smith", remediation_developers=[])
        assert credits == [{"lang": "en", "type": "finder", "value": "Alice Smith"}]

    def test_github_bot_suffix_credit_gets_tool_type(self):
        credits = build_credits("dependabot[bot]", remediation_developers=[])
        assert credits == [{"lang": "en", "type": "tool", "value": "dependabot[bot]"}]

    def test_known_bot_name_credit_gets_tool_type(self):
        credits = build_credits("Dependabot", remediation_developers=[])
        assert credits == [{"lang": "en", "type": "tool", "value": "Dependabot"}]

    def test_pattern_handle_credit_gets_tool_type(self):
        credits = build_credits("claude-bot", remediation_developers=[])
        assert credits == [{"lang": "en", "type": "tool", "value": "claude-bot"}]

    def test_mixed_credits_get_per_row_types(self):
        credits = build_credits(
            "Alice Smith\nDependabot\nBob Jones (Acme Corp)",
            remediation_developers=[],
        )
        assert credits == [
            {"lang": "en", "type": "finder", "value": "Alice Smith"},
            {"lang": "en", "type": "tool", "value": "Dependabot"},
            {"lang": "en", "type": "finder", "value": "Bob Jones (Acme Corp)"},
        ]

    def test_remediation_developer_type_unaffected_by_bot_policy(self):
        """Remediation-developer side is intentionally NOT routed through tool."""
        credits = build_credits(
            "Alice Smith",
            remediation_developers=["dependabot[bot]"],
        )
        # Bot lands as remediation developer with its original type — the
        # finder-side tool routing does not extend here. Skipping bots on
        # the remediation side is a separate (upstream-skill) concern.
        assert credits == [
            {"lang": "en", "type": "finder", "value": "Alice Smith"},
            {
                "lang": "en",
                "type": "remediation developer",
                "value": "dependabot[bot]",
            },
        ]


# ---------------------------------------------------------------------------
# URL list parsing
# ---------------------------------------------------------------------------


class TestParseUrlList:
    def test_multiple_urls_extracted(self):
        value = (
            "First:\nhttps://github.com/apache/airflow/pull/64114\n"
            "Second: https://github.com/apache/airflow/pull/65346"
        )
        urls = parse_url_list(value)
        assert urls == [
            "https://github.com/apache/airflow/pull/64114",
            "https://github.com/apache/airflow/pull/65346",
        ]

    def test_bare_text_with_no_urls_returns_empty(self):
        assert parse_url_list("No links here.") == []


# ---------------------------------------------------------------------------
# CVE ID / CWE parsing
# ---------------------------------------------------------------------------


class TestParseCveId:
    def test_extracts_cve_from_asf_tool_link(self):
        assert parse_cve_id("https://cveprocess.apache.org/cve5/CVE-2026-40948") == "CVE-2026-40948"

    def test_extracts_cve_from_plain_id(self):
        assert parse_cve_id("CVE-2026-40948") == "CVE-2026-40948"

    def test_missing_returns_empty(self):
        assert parse_cve_id("") == ""


class TestParseCwe:
    def test_cwe_with_title(self):
        cwe_id, description = parse_cwe("CWE-352: Cross-Site Request Forgery (CSRF)")
        assert cwe_id == "CWE-352"
        assert description == "CWE-352: Cross-Site Request Forgery (CSRF)"

    def test_cwe_without_title_emits_bare_id_as_description(self):
        cwe_id, description = parse_cwe("CWE-614")
        assert cwe_id == "CWE-614"
        assert description == "CWE-614"

    def test_no_cwe_keeps_original_text_as_description(self):
        cwe_id, description = parse_cwe("_No response_")
        assert cwe_id == ""
        assert description == "_No response_"

    def test_cwe_with_parens_around_title_strips_outer_wrapper(self):
        # `CWE-285 (Improper Authorization)` is what most projects'
        # CWE pickers serialise to. Without the strip, the result is
        # `CWE-285: (Improper Authorization)` — colon AND parens —
        # which reviewers flag as cluttered. This regression closes
        # the Arnout-reported issue on CVE-2026-46763 (2026-05-28).
        cwe_id, description = parse_cwe("CWE-285 (Improper Authorization)")
        assert cwe_id == "CWE-285"
        assert description == "CWE-285: Improper Authorization"

    def test_cwe_with_brackets_around_title_also_stripped(self):
        cwe_id, description = parse_cwe("CWE-352 [Cross-Site Request Forgery]")
        assert cwe_id == "CWE-352"
        assert description == "CWE-352: Cross-Site Request Forgery"

    def test_cwe_with_inner_parens_not_stripped(self):
        # The wrapper-strip only fires when the outer characters are
        # the wrapper. Inner parens stay; they are content.
        cwe_id, description = parse_cwe("CWE-285 Title (annotation)")
        assert cwe_id == "CWE-285"
        assert description == "CWE-285: Title (annotation)"

    def test_cwe_with_mismatched_wrappers_not_stripped(self):
        # A leading `(` without a trailing `)` is not a wrapper.
        cwe_id, description = parse_cwe("CWE-285 (Foo) Bar (Baz)")
        assert cwe_id == "CWE-285"
        assert description == "CWE-285: (Foo) Bar (Baz)"


# ---------------------------------------------------------------------------
# Affected-versions parsing
# ---------------------------------------------------------------------------


class TestParseAffectedVersions:
    def test_bare_less_than_creates_range(self):
        versions = parse_affected_versions("<3.2.0", None)
        assert versions == [
            {"lessThan": "3.2.0", "status": "affected", "version": "0", "versionType": "semver"},
        ]

    def test_inclusive_range(self):
        versions = parse_affected_versions(">=3.0.0, <3.2.0", None)
        assert versions == [
            {"lessThan": "3.2.0", "status": "affected", "version": "3.0.0", "versionType": "semver"},
        ]

    def test_less_or_equal_upper_bound(self):
        versions = parse_affected_versions("<=6.5.0", None)
        assert versions == [
            {
                "lessThanOrEqual": "6.5.0",
                "status": "affected",
                "version": "0",
                "versionType": "semver",
            },
        ]

    def test_version_start_override_sets_low_bound(self):
        versions = parse_affected_versions(">=3.0.0, <3.2.0", "2.10.5")
        assert versions[0]["version"] == "2.10.5"

    def test_unknown_string_raises_value_error(self):
        # The parser now refuses to emit garbage: an unparsable
        # `Affected versions` field is a hard error rather than a
        # bare-string fallback. This regression closes the issue on
        # CVE-2026-46763 (2026-05-28) where the field `>= 3.0.0 (...)`
        # serialised the parenthetical clause into the JSON `version`
        # field.
        with pytest.raises(ValueError, match="Could not parse"):
            parse_affected_versions("all versions", None)

    def test_unparsable_input_error_mentions_the_value(self):
        # The error message includes the offending value so the
        # reviewer can find it in the issue body.
        with pytest.raises(ValueError, match="all versions"):
            parse_affected_versions("all versions", None)

    def test_lower_bound_only_emits_open_ended_entry(self):
        # `>=2.0.0` (no upper) — useful for trackers whose fix-shipped
        # version isn't known yet (typically providers). Still emits
        # (does not raise) but issues a warning to stderr — see the
        # warning test below.
        versions = parse_affected_versions(">=2.0.0", None)
        assert versions == [
            {"status": "affected", "version": "2.0.0", "versionType": "semver"},
        ]

    def test_lower_bound_only_with_space(self):
        versions = parse_affected_versions(">= 2.0.0", None)
        assert versions == [
            {"status": "affected", "version": "2.0.0", "versionType": "semver"},
        ]

    def test_lower_bound_only_warns_about_missing_upper_bound(self, capsys):
        # A bare lower bound has the misleading shape where CVE 5.x
        # readers interpret it as "this version alone is affected".
        # Warn so a reviewer notices and fills in `< X.Y.Z` or the
        # `< NEXT VERSION` sentinel.
        parse_affected_versions(">= 2.0.0", None)
        captured = capsys.readouterr()
        assert "bare lower bound" in captured.err
        assert "'2.0.0'" in captured.err or '"2.0.0"' in captured.err
        # No stdout chatter; warnings go to stderr only.
        assert captured.out == ""

    def test_lower_bound_with_next_version_sentinel_does_not_warn(self, capsys):
        # `< NEXT VERSION` is the documented "fix not yet released,
        # upper bound unknown" form — emitting the bare-version entry
        # is intentional, no warning.
        parse_affected_versions(">= 2.0.0, < NEXT VERSION", None)
        captured = capsys.readouterr()
        assert captured.err == ""
        assert captured.out == ""


class TestParseAffectedVersionsNextVersionPlaceholder:
    """The `< NEXT VERSION` sentinel: fix not yet released, upper bound unknown.

    Stripped before further parsing; resulting entry has no `lessThan`.
    Used predominantly for projects trackers where the wave milestone
    is date-based and the package version that ships the fix is decided
    by the release manager during the wave.
    """

    def test_just_next_version_placeholder(self):
        versions = parse_affected_versions("< NEXT VERSION", None)
        assert versions == [
            {"status": "affected", "version": "0", "versionType": "semver"},
        ]

    def test_lowercase_token_also_accepted(self):
        versions = parse_affected_versions("< next version", None)
        assert versions == [
            {"status": "affected", "version": "0", "versionType": "semver"},
        ]

    def test_lower_bound_with_next_version_upper(self):
        versions = parse_affected_versions(">= 2.0.0 < NEXT VERSION", None)
        assert versions == [
            {"status": "affected", "version": "2.0.0", "versionType": "semver"},
        ]

    def test_lower_bound_with_comma_and_next_version_upper(self):
        versions = parse_affected_versions(">= 2.0.0, < NEXT VERSION", None)
        assert versions == [
            {"status": "affected", "version": "2.0.0", "versionType": "semver"},
        ]

    def test_real_version_replacing_placeholder_round_trips(self):
        # Once the release manager knows the fix-shipped version, the
        # sync skill replaces `< NEXT VERSION` with `< X.Y.Z`. The
        # parser must produce the standard fully-bounded shape.
        versions = parse_affected_versions(">= 2.0.0, < 5.6.0", None)
        assert versions == [
            {"status": "affected", "version": "2.0.0", "lessThan": "5.6.0", "versionType": "semver"},
        ]


# ---------------------------------------------------------------------------
# Product-name resolution for Airflow packages
# ---------------------------------------------------------------------------


class TestProductForPackage:
    def test_core_package_resolves_to_apache_airflow(self):
        assert _product_for_package("apache-example") == "Apache Example"

    def test_known_provider_uses_display_map_casing(self):
        assert _product_for_package("apache-example-project-foo") == "Apache Example Project Foo"
        assert (
            _product_for_package("apache-example-project-kerfluffle") == "Apache Example Project Kerfluffle"
        )
        assert _product_for_package("apache-example-project-acme-xyz") == "Apache Example Project Acme XYZ"
        # Confirms the user-cited mapping example from the Vulnogram form:
        #   apache-example-project-bar → "Apache Example Project Bar".
        assert _product_for_package("apache-example-project-bar") == "Apache Example Project Bar"

    def test_unknown_provider_falls_back_to_title_case(self):
        assert (
            _product_for_package("apache-example-project-madeup-widget")
            == "Apache Example Project Madeup Widget"
        )

    def test_overrides_win_over_display_map(self):
        assert (
            _product_for_package(
                "apache-example-project-foo",
                product_overrides={
                    "apache-example-project-foo": "Custom ES Display",
                },
            )
            == "Custom ES Display"
        )

    def test_overrides_win_over_title_case_fallback(self):
        assert (
            _product_for_package(
                "apache-example-project-madeup-widget",
                product_overrides={
                    "apache-example-project-madeup-widget": "Apache Example Project WIDGET",
                },
            )
            == "Apache Example Project WIDGET"
        )


# ---------------------------------------------------------------------------
# Product-name resolution for projects whose subpackages use a
# convention other than `-project-` (regression coverage for the bug
# where the lookup was anchored on a hardcoded `-project-` prefix
# instead of reading the `project` group from the configured
# `package_pattern`).
# ---------------------------------------------------------------------------


class TestProductForPackageProvidersStyle:
    """Subpackage prefix is `-providers-`, not `-project-`.

    Mirrors the shape Apache Airflow ships on PyPI
    (`apache-airflow-providers-<dir>`). The lookup must read the
    `project` named group from whatever the configured
    `package_pattern` declares — the prefix is a project-level
    convention, not a generator constant.
    """

    @pytest.fixture(autouse=True)
    def _providers_config(self) -> Iterator[None]:
        from pathlib import Path

        from generate_cve_json.cve_json import _set_config_path

        fixture = Path(__file__).resolve().parent / "fixtures" / "cve-json-config-providers.toml"
        default = Path(__file__).resolve().parent / "fixtures" / "cve-json-config.toml"
        _set_config_path(fixture)
        try:
            yield
        finally:
            _set_config_path(default)

    def test_known_provider_uses_display_map_casing(self):
        # `cncf-kubernetes` is captured by the `(?P<project>...)`
        # group of the configured pattern and looked up in the
        # display map — the resolver must not fall through to
        # returning the raw package name.
        assert (
            _product_for_package("apache-example-providers-cncf-kubernetes")
            == "Apache Example CNCF Kubernetes provider"
        )

    def test_unknown_provider_falls_back_to_title_case(self):
        assert (
            _product_for_package("apache-example-providers-madeup-widget")
            == "Apache Example Madeup Widget provider"
        )

    def test_top_level_still_resolves(self):
        assert _product_for_package("apache-example") == "Apache Example"

    def test_overrides_still_win(self):
        assert (
            _product_for_package(
                "apache-example-providers-cncf-kubernetes",
                product_overrides={
                    "apache-example-providers-cncf-kubernetes": "CUSTOM",
                },
            )
            == "CUSTOM"
        )


# ---------------------------------------------------------------------------
# Multi-product `build_affected`
# ---------------------------------------------------------------------------


class TestBuildAffectedSingleProduct:
    def test_empty_field_emits_one_placeholder_entry(self):
        entries = build_affected("", **DEFAULT_AFFECTED_ARGS)
        assert len(entries) == 1
        assert entries[0]["packageName"] == "apache-example"
        assert entries[0]["product"] == "Apache Example"

    def test_bare_version_range_uses_defaults(self):
        entries = build_affected("<3.2.2", **DEFAULT_AFFECTED_ARGS)
        assert len(entries) == 1
        assert entries[0]["packageName"] == "apache-example"
        assert entries[0]["product"] == "Apache Example"
        assert entries[0]["versions"][0]["lessThan"] == "3.2.2"

    def test_single_line_with_package_prefix_detects_provider(self):
        entries = build_affected(
            "apache-example-project-foo <=6.5.0",
            **DEFAULT_AFFECTED_ARGS,
        )
        assert len(entries) == 1
        assert entries[0]["packageName"] == "apache-example-project-foo"
        assert entries[0]["product"] == "Apache Example Project Foo"
        assert entries[0]["versions"][0]["lessThanOrEqual"] == "6.5.0"

    def test_single_line_with_core_package_prefix_detects_core(self):
        entries = build_affected(
            "apache-example <3.2.2",
            vendor="Apache Software Foundation",
            product="IGNORED",
            package_name="ignored",
            collection_url="https://pypi.python.org",
            version_start=None,
        )
        assert len(entries) == 1
        # Package prefix wins over the default argument because the body
        # has said the package name explicitly.
        assert entries[0]["packageName"] == "apache-example"
        assert entries[0]["product"] == "Apache Example"


class TestBuildAffectedMultiProduct:
    def test_two_providers_produce_two_entries(self):
        entries = build_affected(
            "apache-example-project-foo <=6.5.0\napache-example-project-kerfluffle <=1.9.0",
            **DEFAULT_AFFECTED_ARGS,
        )
        assert len(entries) == 2
        es, os_ = entries
        assert es["packageName"] == "apache-example-project-foo"
        assert es["product"] == "Apache Example Project Foo"
        assert es["versions"][0]["lessThanOrEqual"] == "6.5.0"
        assert os_["packageName"] == "apache-example-project-kerfluffle"
        assert os_["product"] == "Apache Example Project Kerfluffle"
        assert os_["versions"][0]["lessThanOrEqual"] == "1.9.0"

    def test_bullet_prefixes_are_stripped(self):
        entries = build_affected(
            "- apache-example-project-foo <=6.5.0\n* apache-example-project-kerfluffle <=1.9.0",
            **DEFAULT_AFFECTED_ARGS,
        )
        assert [e["packageName"] for e in entries] == [
            "apache-example-project-foo",
            "apache-example-project-kerfluffle",
        ]

    def test_blank_lines_between_entries_are_ignored(self):
        entries = build_affected(
            "apache-example-project-foo <=6.5.0\n\napache-example-project-kerfluffle <=1.9.0\n",
            **DEFAULT_AFFECTED_ARGS,
        )
        assert len(entries) == 2

    def test_mixed_known_and_unknown_provider(self):
        entries = build_affected(
            "apache-example-project-foo <=6.5.0\napache-example-project-brand-new <=0.1.0",
            **DEFAULT_AFFECTED_ARGS,
        )
        assert entries[0]["product"] == "Apache Example Project Foo"
        assert entries[1]["product"] == "Apache Example Project Brand New"

    def test_product_overrides_applied_per_entry(self):
        entries = build_affected(
            "apache-example-project-brand-new <=0.1.0",
            product_overrides={
                "apache-example-project-brand-new": "Apache Example Project BRAND",
            },
            **DEFAULT_AFFECTED_ARGS,
        )
        assert entries[0]["product"] == "Apache Example Project BRAND"

    def test_line_without_prefix_falls_back_to_defaults(self):
        # A single line that does not carry a recognisable package
        # prefix stays in the legacy single-entry path and takes the
        # explicit product / packageName args. (The version-range
        # shape itself is incidental to what this test exercises;
        # use a parseable shape since the parser no longer accepts
        # arbitrary free-form text.)
        entries = build_affected(
            "<= 1.0.0",
            vendor="Apache Software Foundation",
            product="Apache Example Helm Chart",
            package_name="apache-example-helm-chart",
            collection_url="https://airflow.apache.org/",
            version_start=None,
        )
        assert len(entries) == 1
        assert entries[0]["packageName"] == "apache-example-helm-chart"
        assert entries[0]["product"] == "Apache Example Helm Chart"

    def test_multi_line_is_deterministic(self):
        # Re-generating must produce byte-identical output, which is
        # what keeps the `--attach` idempotence guarantee intact.
        first = build_affected(
            "apache-example-project-foo <=6.5.0\napache-example-project-kerfluffle <=1.9.0",
            **DEFAULT_AFFECTED_ARGS,
        )
        second = build_affected(
            "apache-example-project-foo <=6.5.0\napache-example-project-kerfluffle <=1.9.0",
            **DEFAULT_AFFECTED_ARGS,
        )
        assert first == second

    def test_per_line_next_version_placeholder(self):
        # The providers-tracker pattern: one line per affected package,
        # all with `< NEXT VERSION` until the wave ships.
        entries = build_affected(
            "apache-example-project-foo < NEXT VERSION\napache-example-project-kerfluffle < NEXT VERSION",
            **DEFAULT_AFFECTED_ARGS,
        )
        assert len(entries) == 2
        es, os_ = entries
        assert es["packageName"] == "apache-example-project-foo"
        assert es["versions"] == [
            {"status": "affected", "version": "0", "versionType": "semver"},
        ]
        assert os_["packageName"] == "apache-example-project-kerfluffle"
        assert os_["versions"] == [
            {"status": "affected", "version": "0", "versionType": "semver"},
        ]

    def test_per_line_lower_bound_only(self):
        # Lower-bound-only line (e.g. `apache-example-project-xyz >=2.0.0`)
        # — useful when affected versions are known to start at X but the
        # fix-shipped version isn't known yet.
        entries = build_affected(
            "apache-example-project-xyz >=2.0.0",
            **DEFAULT_AFFECTED_ARGS,
        )
        assert len(entries) == 1
        assert entries[0]["packageName"] == "apache-example-project-xyz"
        assert entries[0]["versions"] == [
            {"status": "affected", "version": "2.0.0", "versionType": "semver"},
        ]

    def test_per_line_lower_bound_with_next_version_upper(self):
        entries = build_affected(
            "apache-example-project-xyz >= 2.0.0, < NEXT VERSION",
            **DEFAULT_AFFECTED_ARGS,
        )
        assert len(entries) == 1
        assert entries[0]["versions"] == [
            {"status": "affected", "version": "2.0.0", "versionType": "semver"},
        ]


# ---------------------------------------------------------------------------
# Reference tagging
# ---------------------------------------------------------------------------


class TestClassifyReference:
    def test_github_pr_tagged_as_patch(self):
        assert classify_reference("https://github.com/apache/airflow/pull/64114") == ["patch"]

    def test_github_commit_tagged_as_patch(self):
        assert classify_reference("https://github.com/apache/airflow/commit/abc123") == ["patch"]

    def test_lists_apache_tagged_as_vendor_advisory(self):
        assert classify_reference("https://lists.apache.org/thread/abc") == ["vendor-advisory"]

    def test_plain_doc_url_has_no_tags(self):
        assert classify_reference("https://airflow.apache.org/docs/") == []

    def test_security_apache_tagged_as_vendor_advisory(self):
        assert classify_reference("https://security.apache.org/foo") == ["vendor-advisory"]

    def test_evil_substring_in_path_is_not_tagged(self):
        # Regression: substring match would have flagged this; CodeQL
        # `py/incomplete-url-substring-sanitization`. Hostname match
        # rejects it correctly.
        assert classify_reference("https://evil.example/?q=lists.apache.org") == []
        assert classify_reference("https://evil.example/security.apache.org") == []

    def test_subdomain_is_not_treated_as_apache(self):
        assert classify_reference("https://lists.apache.org.evil.example/x") == []

    def test_malformed_url_returns_no_tags(self):
        assert classify_reference("not a url") == []

    def test_cve_org_record_tagged_as_related(self):
        # ASF Security's preferred URL form for sibling/incomplete-fix CVE
        # cross-references (Arnout Engelen, 2026-05-29 review on
        # CVE-2026-49298).
        assert classify_reference("https://www.cve.org/CVERecord?id=CVE-2026-27173") == ["related"]
        assert classify_reference("https://cve.org/CVERecord?id=CVE-2025-68438") == ["related"]

    def test_nvd_record_tagged_as_related(self):
        # NVD is the same CVE database under a different URL form; treat
        # as related too.
        assert classify_reference("https://nvd.nist.gov/vuln/detail/CVE-2026-27173") == ["related"]


class TestExtractRelatedCveIds:
    def test_extracts_single_prior_cve(self):
        from generate_cve_json.cve_json import extract_related_cve_ids

        summary = (
            "This is a variant of CWE-200 previously addressed in CVE-2025-68438; "
            "that fix did not cover the nested sensitive-keyword allowlist."
        )
        assert extract_related_cve_ids(summary) == ["CVE-2025-68438"]

    def test_extracts_multiple_distinct_in_order(self):
        from generate_cve_json.cve_json import extract_related_cve_ids

        summary = "Fix-bypass of CVE-2026-33858. Also related to CVE-2025-50213 and CVE-2025-27018."
        assert extract_related_cve_ids(summary) == [
            "CVE-2026-33858",
            "CVE-2025-50213",
            "CVE-2025-27018",
        ]

    def test_excludes_current_cve_id(self):
        from generate_cve_json.cve_json import extract_related_cve_ids

        summary = "CVE-2026-42359 fixes a PATCH-path bypass of CVE-2026-33858."
        assert extract_related_cve_ids(summary, current_cve_id="CVE-2026-42359") == [
            "CVE-2026-33858",
        ]

    def test_current_cve_id_match_is_case_insensitive(self):
        from generate_cve_json.cve_json import extract_related_cve_ids

        summary = "cve-2026-42359 fixes a PATCH-path bypass of CVE-2026-33858."
        assert extract_related_cve_ids(summary, current_cve_id="CVE-2026-42359") == [
            "CVE-2026-33858",
        ]

    def test_deduplicates_repeated_mentions(self):
        from generate_cve_json.cve_json import extract_related_cve_ids

        summary = "CVE-2025-68438 was incomplete; this CVE follows CVE-2025-68438."
        assert extract_related_cve_ids(summary) == ["CVE-2025-68438"]

    def test_substring_in_larger_token_does_not_match(self):
        from generate_cve_json.cve_json import extract_related_cve_ids

        # Word-boundary regex must not match identifiers embedded in
        # larger tokens (defensive against accidental hits).
        assert extract_related_cve_ids("seeCVE-2026-33858trailing") == []
        assert extract_related_cve_ids("CVE-2026-33858x") == []

    def test_short_form_required_at_least_four_digits(self):
        from generate_cve_json.cve_json import extract_related_cve_ids

        # CVE-YYYY-NNNN minimum (matches MITRE's 4-7 digit constraint).
        assert extract_related_cve_ids("CVE-2026-123") == []
        assert extract_related_cve_ids("CVE-2026-1234") == ["CVE-2026-1234"]

    def test_empty_string_returns_empty_list(self):
        from generate_cve_json.cve_json import extract_related_cve_ids

        assert extract_related_cve_ids("") == []

    def test_no_cve_id_in_text_returns_empty_list(self):
        from generate_cve_json.cve_json import extract_related_cve_ids

        assert extract_related_cve_ids("no CVE here, just narrative.") == []


class TestRelatedCveUrl:
    def test_url_format_matches_cve_org(self):
        from generate_cve_json.cve_json import related_cve_url

        assert related_cve_url("CVE-2026-27173") == "https://www.cve.org/CVERecord?id=CVE-2026-27173"


class TestBuildReferences:
    def test_mailing_list_field_urls_are_not_auto_included(self):
        refs = build_references(
            mailing_list_field="https://lists.apache.org/thread/fake-security-thread",
            pr_field="https://github.com/apache/airflow/pull/64114",
        )
        urls = [r["url"] for r in refs]
        assert urls == ["https://github.com/apache/airflow/pull/64114"]

    def test_explicit_advisory_url_is_included(self):
        refs = build_references(
            mailing_list_field="",
            pr_field="https://github.com/apache/airflow/pull/64114",
            extra_urls=["https://lists.apache.org/thread/real-users-archive-url"],
        )
        urls = {r["url"] for r in refs}
        assert "https://lists.apache.org/thread/real-users-archive-url" in urls
        # And it gets tagged correctly.
        by_url = {r["url"]: r for r in refs}
        advisory = by_url["https://lists.apache.org/thread/real-users-archive-url"]
        assert advisory.get("tags") == ["vendor-advisory"]

    def test_airflow_s_and_cveprocess_urls_are_filtered_out(self):
        refs = build_references(
            mailing_list_field="",
            pr_field="",
            extra_urls=[
                "https://github.com/apache-example-s/apache-example-s/issues/256",
                "https://cveprocess.apache.org/cve5/CVE-2026-40948",
                "https://github.com/apache/airflow/pull/64114",
            ],
        )
        urls = [r["url"] for r in refs]
        assert urls == ["https://github.com/apache/airflow/pull/64114"]


# ---------------------------------------------------------------------------
# Title handling
# ---------------------------------------------------------------------------


class TestResolveTitle:
    def test_strips_apache_airflow_prefix_from_issue_title(self):
        assert resolve_title("Apache Example: DAG auth bypass", "", None) == "DAG auth bypass"

    def test_override_wins(self):
        assert resolve_title("from-issue", "summary", "my override") == "my override"

    def test_falls_back_to_summary_when_empty(self):
        assert resolve_title("", "A summary. With more text.", None) == "A summary"


# ---------------------------------------------------------------------------
# Readiness helper + envelope state
# ---------------------------------------------------------------------------


def _ready_cna() -> dict:
    """Build a minimal CNA container that satisfies every readiness rule."""
    return {
        "title": "Example vulnerability",
        "descriptions": [{"lang": "en", "value": "A description."}],
        "affected": build_affected(
            ">=3.0.0, <3.2.0",
            vendor="Apache Software Foundation",
            product="Apache Example",
            package_name="apache-example",
            collection_url="https://pypi.python.org",
            version_start=None,
        ),
        "problemTypes": build_problem_types("CWE-352: CSRF"),
        "metrics": build_metrics("Low"),
        "credits": build_credits("Alice Smith", remediation_developers=["Bob"]),
        "references": build_references(
            mailing_list_field="",
            pr_field="https://github.com/apache/airflow/pull/123",
        ),
    }


class TestIsCnaReadyForReview:
    def test_fully_populated_cna_is_ready(self):
        assert _is_cna_ready_for_review(_ready_cna(), "CVE-2026-00001") is True

    def test_missing_cve_id_blocks_review(self):
        assert _is_cna_ready_for_review(_ready_cna(), "") is False

    def test_missing_title_blocks_review(self):
        cna = _ready_cna()
        cna["title"] = ""
        assert _is_cna_ready_for_review(cna, "CVE-2026-00001") is False

    def test_missing_credit_blocks_review(self):
        cna = _ready_cna()
        cna["credits"] = []
        assert _is_cna_ready_for_review(cna, "CVE-2026-00001") is False

    def test_unknown_severity_blocks_review(self):
        cna = _ready_cna()
        cna["metrics"] = build_metrics("Unknown")
        assert _is_cna_ready_for_review(cna, "CVE-2026-00001") is False

    def test_missing_cwe_blocks_review(self):
        cna = _ready_cna()
        cna["problemTypes"] = []
        assert _is_cna_ready_for_review(cna, "CVE-2026-00001") is False

    def test_missing_reference_blocks_review(self):
        cna = _ready_cna()
        cna["references"] = []
        assert _is_cna_ready_for_review(cna, "CVE-2026-00001") is False


class TestWrapCveRecord:
    def test_cve_metadata_state_is_always_published(self):
        # `cveMetadata.state` is the CVE 5.x schema field — only valid
        # values are PUBLISHED / REJECTED for a submitted record.
        # The generator hard-codes PUBLISHED regardless of workflow.
        record = wrap_cve_record(_ready_cna(), cve_id="CVE-2026-00001", org_id="org")
        assert record["cveMetadata"]["state"] == "PUBLISHED"
        assert record["cveMetadata"]["cveId"] == "CVE-2026-00001"

    def test_cve_metadata_state_published_even_when_draft(self):
        cna = _ready_cna()
        cna["credits"] = []  # make the CNA incomplete
        record = wrap_cve_record(cna, cve_id="CVE-2026-00001", org_id="org")
        # Workflow state went to DRAFT, but the CVE 5.x schema state
        # stays PUBLISHED — these are different fields.
        assert record["cveMetadata"]["state"] == "PUBLISHED"

    def test_ready_record_emits_review_workflow_state(self):
        # Legacy / non-gated default: a ready CNA advances to REVIEW
        # without any signal. This is what non-ASF adopters get when
        # they don't set [workflow].release_vote_gating in their config.
        cna = _ready_cna()
        record = wrap_cve_record(cna, cve_id="CVE-2026-00001", org_id="org")
        assert record["CNA_private"]["state"] == "REVIEW"

    def test_ready_record_gated_without_vote_stays_draft(self):
        # Gated path (ASF adopters with release_vote_gating = true): when
        # the caller signals that no vote is happening, a ready CNA
        # stays at DRAFT. REVIEW is reserved for the actual vote window.
        cna = _ready_cna()
        record = wrap_cve_record(
            cna,
            cve_id="CVE-2026-00001",
            org_id="org",
            release_vote_in_progress=False,
        )
        assert record["CNA_private"]["state"] == "DRAFT"

    def test_ready_record_gated_with_vote_emits_review(self):
        cna = _ready_cna()
        record = wrap_cve_record(
            cna,
            cve_id="CVE-2026-00001",
            org_id="org",
            release_vote_in_progress=True,
        )
        assert record["CNA_private"]["state"] == "REVIEW"

    def test_incomplete_record_emits_draft_workflow_state(self):
        cna = _ready_cna()
        cna["credits"] = []
        record = wrap_cve_record(cna, cve_id="CVE-2026-00001", org_id="org")
        assert record["CNA_private"]["state"] == "DRAFT"

    def test_vendor_advisory_reference_emits_public_workflow_state(self):
        cna = _ready_cna()
        cna["references"] = build_references(
            mailing_list_field="",
            pr_field="https://github.com/apache/airflow/pull/123",
            extra_urls=[
                "https://lists.apache.org/thread/abc123xyz789",
            ],
        )
        record = wrap_cve_record(cna, cve_id="CVE-2026-00001", org_id="org")
        assert record["CNA_private"]["state"] == "PUBLIC"

    def test_vendor_advisory_with_incomplete_fields_still_draft(self):
        # Even when the advisory URL is captured, an incomplete CNA
        # stays at DRAFT — PUBLIC requires the full review-ready set.
        cna = _ready_cna()
        cna["credits"] = []
        cna["references"] = build_references(
            mailing_list_field="",
            pr_field="https://github.com/apache/airflow/pull/123",
            extra_urls=[
                "https://lists.apache.org/thread/abc123xyz789",
            ],
        )
        record = wrap_cve_record(cna, cve_id="CVE-2026-00001", org_id="org")
        assert record["CNA_private"]["state"] == "DRAFT"

    def test_envelope_carries_cna_private_block(self):
        record = wrap_cve_record(_ready_cna(), cve_id="CVE-2026-00001", org_id="org")
        assert record["CNA_private"]["owner"] == "example"
        assert record["CNA_private"]["userslist"] == "users@example.apache.org"

    def test_emailed_is_none_in_draft_state(self):
        cna = _ready_cna()
        cna["credits"] = []
        record = wrap_cve_record(cna, cve_id="CVE-2026-00001", org_id="org")
        assert record["CNA_private"]["state"] == "DRAFT"
        assert record["CNA_private"]["emailed"] is None

    def test_emailed_is_none_in_review_state(self):
        record = wrap_cve_record(_ready_cna(), cve_id="CVE-2026-00001", org_id="org")
        assert record["CNA_private"]["state"] == "REVIEW"
        assert record["CNA_private"]["emailed"] is None

    def test_emailed_is_yes_in_public_state(self):
        cna = _ready_cna()
        cna["references"] = build_references(
            mailing_list_field="",
            pr_field="https://github.com/apache/airflow/pull/123",
            extra_urls=[
                "https://lists.apache.org/thread/abc123xyz789",
            ],
        )
        record = wrap_cve_record(cna, cve_id="CVE-2026-00001", org_id="org")
        assert record["CNA_private"]["state"] == "PUBLIC"
        assert record["CNA_private"]["emailed"] == "yes"


# ---------------------------------------------------------------------------
# compute_cna_private_state
# ---------------------------------------------------------------------------


class TestComputeCnaPrivateState:
    def test_ready_without_advisory_is_review(self):
        assert compute_cna_private_state(_ready_cna(), "CVE-2026-00001") == "REVIEW"

    def test_ready_with_advisory_is_public(self):
        cna = _ready_cna()
        cna["references"] = build_references(
            mailing_list_field="",
            pr_field="https://github.com/apache/airflow/pull/123",
            extra_urls=["https://lists.apache.org/thread/abc123xyz789"],
        )
        assert compute_cna_private_state(cna, "CVE-2026-00001") == "PUBLIC"

    def test_incomplete_is_draft(self):
        cna = _ready_cna()
        cna["credits"] = []
        assert compute_cna_private_state(cna, "CVE-2026-00001") == "DRAFT"

    def test_gated_without_vote_is_draft(self):
        # Tri-state: explicit False ⇒ DRAFT even when ready.
        assert (
            compute_cna_private_state(
                _ready_cna(),
                "CVE-2026-00001",
                release_vote_in_progress=False,
            )
            == "DRAFT"
        )

    def test_gated_with_vote_is_review(self):
        assert (
            compute_cna_private_state(
                _ready_cna(),
                "CVE-2026-00001",
                release_vote_in_progress=True,
            )
            == "REVIEW"
        )

    def test_gated_with_advisory_overrides_vote_flag(self):
        # Even when release_vote_in_progress=False, a vendor-advisory
        # reference promotes the state to PUBLIC. The PUBLIC transition
        # is not gated by the vote signal — once the advisory shipped,
        # the record IS public.
        cna = _ready_cna()
        cna["references"] = build_references(
            mailing_list_field="",
            pr_field="https://github.com/apache/airflow/pull/123",
            extra_urls=["https://lists.apache.org/thread/abc123xyz789"],
        )
        assert compute_cna_private_state(cna, "CVE-2026-00001", release_vote_in_progress=False) == "PUBLIC"


# ---------------------------------------------------------------------------
# compute_package_url
# ---------------------------------------------------------------------------


class TestComputePackageUrl:
    def test_pypi_python_org_returns_canonical_pypi_project_url(self):
        assert (
            compute_package_url("https://pypi.python.org", "apache-example")
            == "https://pypi.org/project/apache-example/"
        )

    def test_pypi_org_alias_also_supported(self):
        assert (
            compute_package_url("https://pypi.org", "apache-example")
            == "https://pypi.org/project/apache-example/"
        )

    def test_trailing_slash_is_tolerated(self):
        assert (
            compute_package_url("https://pypi.python.org/", "apache-example-project-foo")
            == "https://pypi.org/project/apache-example-project-foo/"
        )

    def test_unknown_collection_url_returns_none(self):
        assert compute_package_url("https://airflow.apache.org/", "apache-example-helm-chart") is None

    def test_empty_inputs_return_none(self):
        assert compute_package_url("", "apache-example") is None
        assert compute_package_url("https://pypi.python.org", "") is None


# ---------------------------------------------------------------------------
# format_version_range
# ---------------------------------------------------------------------------


class TestFormatVersionRange:
    def test_range_with_low_and_high(self):
        versions = [{"version": "2.0.0", "lessThan": "3.2.2", "status": "affected", "versionType": "semver"}]
        assert format_version_range(versions) == ">= 2.0.0, < 3.2.2"

    def test_open_lower_bound_uses_less_than_only(self):
        versions = [{"version": "0", "lessThan": "3.2.2", "status": "affected", "versionType": "semver"}]
        assert format_version_range(versions) == "< 3.2.2"

    def test_less_than_or_equal(self):
        versions = [
            {"version": "0", "lessThanOrEqual": "3.2.1", "status": "affected", "versionType": "semver"}
        ]
        assert format_version_range(versions) == "<= 3.2.1"

    def test_bare_single_version(self):
        versions = [{"version": "3.1.5", "status": "affected", "versionType": "semver"}]
        assert format_version_range(versions) == "3.1.5"

    def test_round_trip_through_parser(self):
        # parse_affected_versions(...) → format_version_range(...) should
        # reconstruct the original human-readable shape.
        for raw, expected in [
            (">= 2.0.0, < 3.2.2", ">= 2.0.0, < 3.2.2"),
            ("< 3.2.2", "< 3.2.2"),
            ("<= 3.2.1", "<= 3.2.1"),
            ("3.1.5", "3.1.5"),
        ]:
            parsed = parse_affected_versions(raw, version_start_override=None)
            assert format_version_range(parsed) == expected, raw

    def test_empty_input_returns_empty_string(self):
        assert format_version_range([]) == ""


# ---------------------------------------------------------------------------
# _build_attachment_body — the issue-body table above the embedded JSON
# ---------------------------------------------------------------------------


def _ready_cna_for_attachment() -> dict:
    """Same shape as _ready_cna() but with explicit packageName /
    product / collectionURL on every affected[] entry — what
    build_affected() actually emits in practice."""
    return _ready_cna()


class TestBuildAttachmentBody:
    def test_metric_table_includes_title_state_and_size(self):
        cna = _ready_cna_for_attachment()
        body = _build_attachment_body(
            cve_id="CVE-2026-00001",
            json_text='{"x": 1}',
            cna=cna,
            cna_private_state="REVIEW",
        )
        assert "| CVE ID | `CVE-2026-00001` |" in body
        assert "| Title | Example vulnerability |" in body
        assert "| Vulnogram state | `REVIEW` |" in body
        assert "| Affected packages | `apache-example` |" in body
        assert "| Size | 8 bytes |" in body

    def test_no_envelope_renders_state_placeholder(self):
        body = _build_attachment_body(
            cve_id="CVE-2026-00001",
            json_text='{"x": 1}',
            cna=_ready_cna_for_attachment(),
            cna_private_state=None,
        )
        assert "| Vulnogram state | — (`--no-envelope`) |" in body

    def test_per_package_table_includes_pypi_url_and_versions(self):
        body = _build_attachment_body(
            cve_id="CVE-2026-00001",
            json_text="{}",
            cna=_ready_cna_for_attachment(),
            cna_private_state="REVIEW",
        )
        assert "**Packages this JSON covers:**" in body
        assert "| # | Package | Product | Versions | PyPI URL |" in body
        assert (
            "| 1 | `apache-example` | Apache Example | `>= 3.0.0, < 3.2.0` | <https://pypi.org/project/apache-example/> |"
            in body
        )

    def test_per_package_table_renders_one_row_per_affected_entry(self):
        cna = _ready_cna_for_attachment()
        cna["affected"] = build_affected(
            "apache-example-project-foo <=6.5.0\napache-example-project-kerfluffle <=1.9.0",
            vendor="Apache Software Foundation",
            product="Apache Example",
            package_name="apache-example",
            collection_url="https://pypi.python.org",
            version_start=None,
        )
        body = _build_attachment_body(
            cve_id="CVE-2026-00001",
            json_text="{}",
            cna=cna,
            cna_private_state="REVIEW",
        )
        assert (
            "| 1 | `apache-example-project-foo` | Apache Example Project Foo "
            "| `<= 6.5.0` | <https://pypi.org/project/apache-example-project-foo/> |"
        ) in body
        assert (
            "| 2 | `apache-example-project-kerfluffle` | Apache Example Project Kerfluffle "
            "| `<= 1.9.0` | <https://pypi.org/project/apache-example-project-kerfluffle/> |"
        ) in body
        assert (
            "| Affected packages | `apache-example-project-foo`, `apache-example-project-kerfluffle` |"
        ) in body

    def test_unknown_collection_url_renders_dash_in_url_column(self):
        cna = _ready_cna_for_attachment()
        cna["affected"] = [
            {
                "vendor": "Apache Software Foundation",
                "product": "Apache Example Helm Chart",
                "packageName": "apache-example-helm-chart",
                "collectionURL": "https://airflow.apache.org/",
                "versions": [
                    {"version": "0", "lessThan": "1.18.0", "status": "affected", "versionType": "semver"},
                ],
                "defaultStatus": "unaffected",
            }
        ]
        body = _build_attachment_body(
            cve_id="CVE-2026-00001",
            json_text="{}",
            cna=cna,
            cna_private_state="REVIEW",
        )
        assert "| 1 | `apache-example-helm-chart` | Apache Example Helm Chart | `< 1.18.0` | — |" in body

    def test_pipe_in_title_is_escaped(self):
        cna = _ready_cna_for_attachment()
        cna["title"] = "Pipe | in | title"
        body = _build_attachment_body(
            cve_id="CVE-2026-00001",
            json_text="{}",
            cna=cna,
            cna_private_state="REVIEW",
        )
        assert "| Title | Pipe \\| in \\| title |" in body

    def test_output_is_deterministic(self):
        first = _build_attachment_body(
            cve_id="CVE-2026-00001",
            json_text='{"x": 1}',
            cna=_ready_cna_for_attachment(),
            cna_private_state="REVIEW",
        )
        second = _build_attachment_body(
            cve_id="CVE-2026-00001",
            json_text='{"x": 1}',
            cna=_ready_cna_for_attachment(),
            cna_private_state="REVIEW",
        )
        assert first == second


# ---------------------------------------------------------------------------
# combine_remediation_developers
# ---------------------------------------------------------------------------


class TestCombineRemediationDevelopers:
    def test_body_field_only(self):
        assert combine_remediation_developers("Alice Smith\nBob Jones", []) == [
            "Alice Smith",
            "Bob Jones",
        ]

    def test_cli_only(self):
        assert combine_remediation_developers("", ["Alice Smith"]) == ["Alice Smith"]

    def test_body_first_then_cli(self):
        assert combine_remediation_developers("Alice", ["Bob"]) == ["Alice", "Bob"]

    def test_duplicates_dropped_silently(self):
        # Body says Alice, CLI also says Alice — single credit, body order wins.
        assert combine_remediation_developers("Alice Smith\nBob Jones", ["Alice Smith", "Carol"]) == [
            "Alice Smith",
            "Bob Jones",
            "Carol",
        ]

    def test_no_response_field_treated_as_empty(self):
        # extract_field already collapses _No response_ to "" upstream; this
        # asserts the helper doesn't choke if a caller passes "" directly.
        assert combine_remediation_developers("", []) == []

    def test_full_name_affiliation_pattern_preserved(self):
        # Same parsing rule as Reporter credited as: "Name, Affiliation" is one credit.
        assert combine_remediation_developers("Jed Cunningham, Astronomer", []) == [
            "Jed Cunningham, Astronomer",
        ]


# ---------------------------------------------------------------------------
# normalise_severity
# ---------------------------------------------------------------------------


class TestNormaliseSeverity:
    def test_known_values_are_lowercased(self):
        for raw in ("None", "Low", "Moderate", "Medium", "High", "Important", "Critical"):
            assert normalise_severity(raw) == raw.lower()

    def test_already_lowercase_known_value_passes_through(self):
        assert normalise_severity("high") == "high"

    def test_unknown_value_is_stripped_but_not_changed(self):
        assert normalise_severity("  Informational  ") == "Informational"

    def test_mixed_case_known_value_normalised(self):
        assert normalise_severity("HIGH") == "high"
        assert normalise_severity("CRITICAL") == "critical"
        assert normalise_severity("MODERATE") == "moderate"
        assert normalise_severity("Important") == "important"


# ---------------------------------------------------------------------------
# to_html
# ---------------------------------------------------------------------------


class TestToHtml:
    def test_plain_text_is_returned_unchanged(self):
        assert to_html("Hello world") == "Hello world"

    def test_html_angle_brackets_are_escaped(self):
        assert to_html("<script>alert(1)</script>") == "&lt;script&gt;alert(1)&lt;/script&gt;"

    def test_ampersand_is_escaped(self):
        assert to_html("A & B") == "A &amp; B"

    def test_double_newlines_become_br_br(self):
        assert to_html("Para one.\n\nPara two.") == "Para one.<br><br>Para two."

    def test_single_newlines_become_br(self):
        assert to_html("Line one.\nLine two.") == "Line one.<br>Line two."

    def test_windows_line_endings_normalised_before_conversion(self):
        assert to_html("Line one.\r\nLine two.") == "Line one.<br>Line two."

    def test_mixed_newlines_in_multiline_text(self):
        result = to_html("Intro.\n\nBullet one.\nBullet two.")
        assert result == "Intro.<br><br>Bullet one.<br>Bullet two."


# ---------------------------------------------------------------------------
# build_descriptions
# ---------------------------------------------------------------------------


class TestBuildDescriptions:
    def test_empty_text_returns_empty_list(self):
        assert build_descriptions("") == []

    def test_non_empty_returns_single_entry(self):
        result = build_descriptions("A vulnerability.")
        assert len(result) == 1

    def test_entry_lang_is_en(self):
        result = build_descriptions("A vulnerability.")
        assert result[0]["lang"] == "en"

    def test_entry_value_is_plain_text(self):
        result = build_descriptions("A vulnerability.")
        assert result[0]["value"] == "A vulnerability."

    def test_supporting_media_is_html(self):
        result = build_descriptions("A vulnerability.")
        media = result[0]["supportingMedia"]
        assert len(media) == 1
        assert media[0]["type"] == "text/html"
        assert media[0]["base64"] is False

    def test_special_chars_escaped_in_html_media(self):
        result = build_descriptions("Use <b> wisely & carefully.")
        html_value = result[0]["supportingMedia"][0]["value"]
        assert "&lt;b&gt;" in html_value
        assert "&amp;" in html_value

    def test_plain_value_not_html_escaped(self):
        result = build_descriptions("Use <b> wisely.")
        assert result[0]["value"] == "Use <b> wisely."


# ---------------------------------------------------------------------------
# build_cna_container
# ---------------------------------------------------------------------------

_CNA_DEFAULTS: dict[str, Any] = {
    "title": "DAG auth bypass",
    "description": "An attacker can read arbitrary DAGs.",
    "affected_versions_value": ">=3.0.0, <3.2.0",
    "cwe_value": "CWE-352: CSRF",
    "severity_value": "high",
    "credits_value": "Alice Smith",
    "mailing_list_value": "https://lists.example.org/thread/abc",
    "pr_value": "https://github.com/apache/airflow/pull/123",
    "vendor": "Apache Software Foundation",
    "product": "Apache Example",
    "package_name": "apache-example",
    "collection_url": "https://pypi.python.org",
    "org_id": "org-123",
    "version_start": None,
    "discovery": "UNKNOWN",
    "remediation_developers": [],
}


class TestBuildCnaContainer:
    def test_required_top_level_keys_present(self):
        cna = build_cna_container(**_CNA_DEFAULTS)
        for key in (
            "affected",
            "credits",
            "descriptions",
            "metrics",
            "problemTypes",
            "providerMetadata",
            "references",
            "source",
            "title",
            "x_generator",
        ):
            assert key in cna, f"missing key: {key}"

    def test_title_is_set(self):
        cna = build_cna_container(**_CNA_DEFAULTS)
        assert cna["title"] == "DAG auth bypass"

    def test_provider_metadata_carries_org_id(self):
        cna = build_cna_container(**_CNA_DEFAULTS)
        assert cna["providerMetadata"]["orgId"] == "org-123"

    def test_source_discovery_is_set(self):
        cna = build_cna_container(**_CNA_DEFAULTS)
        assert cna["source"]["discovery"] == "UNKNOWN"

    def test_affected_entry_uses_correct_package(self):
        cna = build_cna_container(**_CNA_DEFAULTS)
        assert any(a["packageName"] == "apache-example" for a in cna["affected"])

    def test_description_value_appears_in_descriptions(self):
        cna = build_cna_container(**_CNA_DEFAULTS)
        assert any("An attacker" in d["value"] for d in cna["descriptions"])

    def test_cwe_appears_in_problem_types(self):
        cna = build_cna_container(**_CNA_DEFAULTS)
        cwe_ids = [desc.get("cweId") for pt in cna["problemTypes"] for desc in pt.get("descriptions", [])]
        assert "CWE-352" in cwe_ids

    def test_severity_appears_in_metrics(self):
        cna = build_cna_container(**_CNA_DEFAULTS)
        texts = [m["other"]["content"]["text"] for m in cna["metrics"] if "other" in m]
        assert "high" in texts

    def test_reporter_credit_in_credits(self):
        cna = build_cna_container(**_CNA_DEFAULTS)
        credit_values = [c["value"] for c in cna["credits"]]
        assert "Alice Smith" in credit_values

    def test_remediation_developer_added_to_credits(self):
        kwargs = {**_CNA_DEFAULTS, "remediation_developers": ["Bob Builder"]}
        cna = build_cna_container(**kwargs)
        dev_credits = [(c["value"], c["type"]) for c in cna["credits"]]
        assert ("Bob Builder", "remediation developer") in dev_credits

    def test_pr_url_appears_in_references(self):
        cna = build_cna_container(**_CNA_DEFAULTS)
        urls = [r["url"] for r in cna["references"]]
        assert "https://github.com/apache/airflow/pull/123" in urls

    def test_advisory_urls_forwarded_to_references(self):
        kwargs = {
            **_CNA_DEFAULTS,
            "advisory_urls": ["https://lists.apache.org/thread/real-advisory"],
        }
        cna = build_cna_container(**kwargs)
        urls = [r["url"] for r in cna["references"]]
        assert "https://lists.apache.org/thread/real-advisory" in urls

    def test_product_overrides_applied_to_affected(self):
        kwargs = {
            **_CNA_DEFAULTS,
            "affected_versions_value": "apache-example-project-foo <=6.5.0",
            "product_overrides": {"apache-example-project-foo": "Custom Foo Display"},
        }
        cna = build_cna_container(**kwargs)
        assert any(a["product"] == "Custom Foo Display" for a in cna["affected"])

    def test_empty_severity_produces_empty_metrics(self):
        kwargs = {**_CNA_DEFAULTS, "severity_value": ""}
        cna = build_cna_container(**kwargs)
        assert cna["metrics"] == []

    def test_empty_cwe_produces_empty_problem_types(self):
        kwargs = {**_CNA_DEFAULTS, "cwe_value": ""}
        cna = build_cna_container(**kwargs)
        assert cna["problemTypes"] == []

    def test_mailing_list_url_excluded_from_references(self):
        # The mailing_list_value field is intentionally ignored in references.
        cna = build_cna_container(**_CNA_DEFAULTS)
        urls = [r["url"] for r in cna["references"]]
        assert "https://lists.example.org/thread/abc" not in urls


# ---------------------------------------------------------------------------
# emit_json
# ---------------------------------------------------------------------------


class TestEmitJson:
    def test_returns_valid_json_string(self):
        obj = {"b": 2, "a": 1}
        text = emit_json(obj, None)
        assert json.loads(text) == obj

    def test_keys_are_sorted(self):
        obj = {"z": 1, "a": 2, "m": 3}
        text = emit_json(obj, None)
        keys = list(json.loads(text).keys())
        assert keys == sorted(keys)

    def test_indented_with_four_spaces(self):
        text = emit_json({"a": 1}, None)
        assert '    "a"' in text

    def test_writes_to_file_when_output_path_given(self, tmp_path: Path):
        out = tmp_path / "out.json"
        emit_json({"x": 1}, out)
        assert out.exists()
        assert json.loads(out.read_text()) == {"x": 1}

    def test_creates_missing_parent_directories(self, tmp_path: Path):
        out = tmp_path / "nested" / "dirs" / "out.json"
        emit_json({"x": 1}, out)
        assert out.exists()

    def test_file_ends_with_newline(self, tmp_path: Path):
        out = tmp_path / "out.json"
        emit_json({"x": 1}, out)
        assert out.read_text().endswith("\n")

    def test_return_value_matches_file_content_without_trailing_newline(self, tmp_path: Path):
        out = tmp_path / "out.json"
        returned = emit_json({"x": 1}, out)
        assert out.read_text() == returned + "\n"

    def test_non_ascii_preserved(self):
        text = emit_json({"name": "José"}, None)
        assert "José" in text
