"""Tests for security analysis module.

Tests scan_for_secrets and scan_vulnerabilities from analysis/security/.
All external calls (detect-secrets, subprocess) are mocked with unittest.mock.patch.
"""

from __future__ import annotations

import json
import subprocess
from pathlib import Path  # noqa: TC003
from unittest.mock import MagicMock, patch

import pytest

from unfamiliar.analysis.security.secrets import scan_for_secrets
from unfamiliar.analysis.security.vulnerabilities import scan_vulnerabilities


def make_secrets_mock(
    tmp_path: Path,
    filename: str,
    secret_type: str = "Test Secret",
    line_number: int = 1,
    is_secret: bool | None = True,
) -> tuple[MagicMock, MagicMock]:
    """Create a mock for detect-secrets.SecretsCollection.scan_file."""
    test_file = tmp_path / filename
    test_file.write_text("content\n")

    mock_secrets_collection = MagicMock()
    mock_secrets_collection.scan_file = MagicMock()
    mock_secrets_collection.json.return_value = {
        "results": {
            str(test_file): [
                {
                    "type": secret_type,
                    "line_number": line_number,
                    "is_secret": is_secret,
                }
            ]
        }
    }

    mock_default_settings = MagicMock()
    mock_default_settings.__enter__ = MagicMock(return_value=None)
    mock_default_settings.__exit__ = MagicMock(return_value=None)

    return mock_secrets_collection, mock_default_settings


class TestScanForSecrets:
    """Tests for scan_for_secrets function."""

    def test_empty_file_paths_returns_empty_list(self, tmp_path: Path) -> None:
        """Empty file_paths list returns [], no calls to detect-secrets."""
        result = scan_for_secrets([], tmp_path)
        assert result == []

    def test_file_over_1mb_is_skipped(self, tmp_path: Path) -> None:
        """File over 1MB is skipped, not scanned."""
        # Create a large file (over 1MB)
        large_file = tmp_path / "large_file.py"
        large_file.write_bytes(b"x" * (2 * 1024 * 1024))  # 2MB

        result = scan_for_secrets(["large_file.py"], tmp_path)
        # Result should be empty since file is skipped
        assert result == []

    def test_confirmed_false_positive_not_included(self, tmp_path: Path) -> None:
        """Mock returns is_secret=False (confirmed false positive) not in results."""
        mock_secrets, mock_settings = make_secrets_mock(
            tmp_path, "config.py", "Base64 High Entropy String", 1, False
        )

        with (
            patch(
                "unfamiliar.analysis.security.secrets.SecretsCollection",
                return_value=mock_secrets,
            ),
            patch(
                "unfamiliar.analysis.security.secrets.default_settings",
                return_value=mock_settings,
            ),
        ):
            result = scan_for_secrets(["config.py"], tmp_path)

        # False positive should not be included
        assert result == []

    def test_all_secret_finding_fields_asserted(self, tmp_path: Path) -> None:
        """All SecretFinding fields properly populated from detect-secrets output."""
        mock_secrets, mock_settings = make_secrets_mock(
            tmp_path, "secrets.py", "Secret Keyword", 42
        )

        with (
            patch(
                "unfamiliar.analysis.security.secrets.SecretsCollection",
                return_value=mock_secrets,
            ),
            patch(
                "unfamiliar.analysis.security.secrets.default_settings",
                return_value=mock_settings,
            ),
        ):
            result = scan_for_secrets(["secrets.py"], tmp_path)

        assert len(result) == 1
        finding = result[0]

        # Assert ALL fields match expected values
        assert finding.file_path == "secrets.py"
        assert finding.line_number == 42
        assert finding.secret_type == "Secret Keyword"
        assert finding.description == "Potential Secret Keyword detected"

    def test_binary_file_is_skipped(self, tmp_path: Path) -> None:
        """Binary file (containing null bytes) is skipped, not scanned."""
        binary_file = tmp_path / "image.png"
        binary_file.write_bytes(b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR")

        mock_secrets_collection = MagicMock()
        mock_secrets_collection.scan_file = MagicMock()

        mock_default_settings = MagicMock()
        mock_default_settings.__enter__ = MagicMock(return_value=None)
        mock_default_settings.__exit__ = MagicMock(return_value=None)

        with (
            patch(
                "unfamiliar.analysis.security.secrets.SecretsCollection",
                return_value=mock_secrets_collection,
            ),
            patch(
                "unfamiliar.analysis.security.secrets.default_settings",
                return_value=mock_default_settings,
            ),
        ):
            result = scan_for_secrets(["image.png"], tmp_path)

        # scan_file should not be called for binary file
        mock_secrets_collection.scan_file.assert_not_called()
        assert result == []

    def test_nonexistent_file_is_skipped(self, tmp_path: Path) -> None:
        """Nonexistent file is skipped without error."""
        mock_secrets_collection = MagicMock()
        mock_secrets_collection.scan_file = MagicMock()

        mock_default_settings = MagicMock()
        mock_default_settings.__enter__ = MagicMock(return_value=None)
        mock_default_settings.__exit__ = MagicMock(return_value=None)

        with (
            patch(
                "unfamiliar.analysis.security.secrets.SecretsCollection",
                return_value=mock_secrets_collection,
            ),
            patch(
                "unfamiliar.analysis.security.secrets.default_settings",
                return_value=mock_default_settings,
            ),
        ):
            result = scan_for_secrets(["nonexistent.py"], tmp_path)

        # scan_file should not be called for nonexistent file
        mock_secrets_collection.scan_file.assert_not_called()
        assert result == []

    def test_multiple_findings_returned(self, tmp_path: Path) -> None:
        """Multiple findings from detect-secrets are all returned."""
        test_file1 = tmp_path / "file1.py"
        test_file1.write_text('KEY1 = "secret1"\n')
        test_file2 = tmp_path / "file2.py"
        test_file2.write_text('KEY2 = "secret2"\n')

        mock_secrets_collection = MagicMock()
        mock_secrets_collection.scan_file = MagicMock()
        mock_secrets_collection.json.return_value = {
            "results": {
                str(test_file1): [
                    {
                        "type": "AWS Access Key",
                        "line_number": 1,
                        "is_secret": True,
                    }
                ],
                str(test_file2): [
                    {
                        "type": "Private Key",
                        "line_number": 5,
                        "is_secret": True,
                    }
                ],
            }
        }

        mock_default_settings = MagicMock()
        mock_default_settings.__enter__ = MagicMock(return_value=None)
        mock_default_settings.__exit__ = MagicMock(return_value=None)

        with (
            patch(
                "unfamiliar.analysis.security.secrets.SecretsCollection",
                return_value=mock_secrets_collection,
            ),
            patch(
                "unfamiliar.analysis.security.secrets.default_settings",
                return_value=mock_default_settings,
            ),
        ):
            result = scan_for_secrets(["file1.py", "file2.py"], tmp_path)

        assert len(result) == 2

        # Check both findings are present (order may vary)
        file_paths = {f.file_path for f in result}
        assert file_paths == {"file1.py", "file2.py"}

    def test_scan_file_exception_continues_to_next_file(self, tmp_path: Path) -> None:
        """When scan_file raises on one file, scanning should continue"""

        bad_file = tmp_path / "corrupted.py"
        bad_file.write_text("some content")

        good_file = tmp_path / "clean.py"
        good_file.write_text("API_KEY = 'secret123'")

        mock_secrets_collection = MagicMock()
        mock_secrets_collection.scan_file = MagicMock(
            side_effect=[OSError("encoding error"), None]
        )

        mock_secrets_collection.json.return_value = {
            "results": {
                str(good_file): [
                    {
                        "type": "Secret Keyword",
                        "line_number": 1,
                        "is_secret": True,
                    }
                ]
            }
        }

        mock_default_settings = MagicMock()
        mock_default_settings.__enter__ = MagicMock(return_value=None)
        mock_default_settings.__exit__ = MagicMock(return_value=None)

        with (
            patch(
                "unfamiliar.analysis.security.secrets.SecretsCollection",
                return_value=mock_secrets_collection,
            ),
            patch(
                "unfamiliar.analysis.security.secrets.default_settings",
                return_value=mock_default_settings,
            ),
        ):
            result = scan_for_secrets(["corrupted.py", "clean.py"], tmp_path)

        assert len(result) == 1
        assert result[0].file_path == "clean.py"


class TestScanVulnerabilities:
    """Tests for scan_vulnerabilities function."""

    def test_no_manifest_files_returns_empty_tuple(self, tmp_path: Path) -> None:
        """No manifest files returns ([], [], [])."""
        findings, manifests_scanned, errors = scan_vulnerabilities(tmp_path)
        assert findings == []
        assert manifests_scanned == []
        assert errors == []

    def test_requirements_txt_present_calls_pip_audit(self, tmp_path: Path) -> None:
        """requirements.txt present calls pip-audit -r ... -f json."""
        requirements_txt = tmp_path / "requirements.txt"
        requirements_txt.write_text("requests==2.28.0\n")

        mock_result = MagicMock()
        mock_result.stdout = "[]"
        mock_result.returncode = 0

        with patch("subprocess.run", return_value=mock_result) as mock_run:
            findings, manifests_scanned, errors = scan_vulnerabilities(tmp_path)

        # Verify pip-audit was called with correct arguments
        mock_run.assert_called()
        call_args = mock_run.call_args
        assert "pip-audit" in call_args[0][0]
        assert "-r" in call_args[0][0]
        assert "-f" in call_args[0][0]
        assert "json" in call_args[0][0]
        # requirements.txt should be in manifests_scanned
        assert "requirements.txt" in manifests_scanned
        assert findings == []
        assert errors == []

    def test_pyproject_toml_present_calls_pip_audit_locked(
        self, tmp_path: Path
    ) -> None:
        """pyproject.toml present calls pip-audit --locked -f json."""
        pyproject_toml = tmp_path / "pyproject.toml"
        pyproject_toml.write_text("[project]\nname = 'test'\n")

        mock_result = MagicMock()
        mock_result.stdout = "[]"
        mock_result.returncode = 0

        with patch("subprocess.run", return_value=mock_result) as mock_run:
            findings, manifests_scanned, errors = scan_vulnerabilities(tmp_path)
        # Verify pip-audit was called with --locked flag
        mock_run.assert_called()
        call_args = mock_run.call_args
        assert "pip-audit" in call_args[0][0]
        assert "--locked" in call_args[0][0]
        assert "-f" in call_args[0][0]
        assert "json" in call_args[0][0]
        # pyproject.toml should be in manifests_scanned
        assert "pyproject.toml" in manifests_scanned
        assert findings == []
        assert errors == []

    def test_package_json_with_lockfile_calls_npm_audit(self, tmp_path: Path) -> None:
        """package.json + package-lock.json present calls npm audit --json."""
        package_json = tmp_path / "package.json"
        package_json.write_text('{"name": "test", "version": "1.0.0"}\n')
        package_lock = tmp_path / "package-lock.json"
        package_lock.write_text('{"lockfileVersion": 2}\n')

        mock_result = MagicMock()
        mock_result.stdout = '{"vulnerabilities": {}}'
        mock_result.returncode = 0

        with patch("subprocess.run", return_value=mock_result) as mock_run:
            findings, manifests_scanned, errors = scan_vulnerabilities(tmp_path)
        # Verify npm audit was called
        mock_run.assert_called()
        call_args = mock_run.call_args
        assert "npm" in call_args[0][0]
        assert "audit" in call_args[0][0]
        assert "--json" in call_args[0][0]
        # package.json should be in manifests_scanned
        assert "package.json" in manifests_scanned
        assert findings == []
        assert errors == []

    def test_package_json_without_lockfile_no_npm_audit(self, tmp_path: Path) -> None:
        """package.json without lockfile does not call npm audit."""
        package_json = tmp_path / "package.json"
        package_json.write_text('{"name": "test", "version": "1.0.0"}\n')
        # No lockfile created

        with patch("subprocess.run") as mock_run:
            findings, manifests_scanned, errors = scan_vulnerabilities(tmp_path)
            # npm audit should not be called without lockfile
            for call in mock_run.call_args_list:
                if "npm" in call[0][0]:
                    pytest.fail("npm audit should not be called without lockfile")
        # package.json should NOT be in manifests_scanned
        assert "package.json" not in manifests_scanned

    def test_pip_audit_not_found_error_in_scan_errors(self, tmp_path: Path) -> None:
        """pip-audit not found (FileNotFoundError) adds error to scan_errors."""
        requirements_txt = tmp_path / "requirements.txt"
        requirements_txt.write_text("requests==2.28.0\n")

        with patch(
            "subprocess.run",
            side_effect=FileNotFoundError("pip-audit not found"),
        ):
            findings, manifests_scanned, errors = scan_vulnerabilities(tmp_path)
        # Should not raise exception
        assert "requirements.txt" in manifests_scanned
        assert len(errors) == 1
        assert errors[0] == "pip-audit not found, skipping vulnerability scan"
        assert findings == []

    def test_mock_pip_audit_returns_one_vulnerability(self, tmp_path: Path) -> None:
        """Mock pip-audit JSON output with 1 CVE returns 1 VulnerabilityFinding."""
        requirements_txt = tmp_path / "requirements.txt"
        requirements_txt.write_text("vulnerable-pkg==1.0.0\n")

        pip_audit_output = [
            {
                "name": "vulnerable-pkg",
                "version": "1.0.0",
                "vulns": [
                    {
                        "id": "PYSEC-2023-123",
                        "description": "A critical vulnerability",
                        "fix_versions": ["1.2.0"],
                        "aliases": ["CVE-2023-12345"],
                    }
                ],
            }
        ]

        mock_result = MagicMock()
        mock_result.stdout = json.dumps(pip_audit_output)
        mock_result.returncode = 1  # pip-audit exits 1 when vulns found

        with patch("subprocess.run", return_value=mock_result):
            findings, manifests_scanned, errors = scan_vulnerabilities(tmp_path)
        assert len(findings) == 1
        assert errors == []
        # Assert ALL fields on VulnerabilityFinding
        finding = findings[0]
        assert finding.package_name == "vulnerable-pkg"
        assert finding.installed_version == "1.0.0"
        assert finding.vulnerability_id == "CVE-2023-12345"  # Uses CVE alias
        assert finding.severity in ("low", "moderate", "high", "critical")
        assert finding.description == "A critical vulnerability"
        assert finding.fix_version == "1.2.0"
        assert finding.ecosystem == "python"

    def test_mock_npm_audit_returns_one_vulnerability(self, tmp_path: Path) -> None:
        """Mock npm audit JSON with 1 vuln returns VulnerabilityFinding with
        ecosystem=javascript.
        """
        package_json = tmp_path / "package.json"
        package_json.write_text('{"name": "test", "version": "1.0.0"}\n')
        package_lock = tmp_path / "package-lock.json"
        package_lock.write_text('{"lockfileVersion": 2}\n')

        npm_audit_output = {
            "vulnerabilities": {
                "vulnerable-dep": {
                    "severity": "high",
                    "via": [
                        {
                            "name": "Prototype Pollution",
                            "url": "https://npmjs.com/advisories/12345",
                            "title": "Prototype Pollution in vulnerable-dep",
                            "overview": "Detailed overview here",
                        }
                    ],
                    "fixAvailable": {"version": "2.0.0"},
                }
            }
        }

        mock_result = MagicMock()
        mock_result.stdout = json.dumps(npm_audit_output)
        mock_result.returncode = 1  # npm audit exits 1 when vulns found

        with patch("subprocess.run", return_value=mock_result):
            findings, manifests_scanned, errors = scan_vulnerabilities(tmp_path)
        assert len(findings) == 1
        assert "package.json" in manifests_scanned
        assert errors == []
        # Assert ALL fields on VulnerabilityFinding
        finding = findings[0]
        assert finding.package_name == "vulnerable-dep"
        assert finding.installed_version == "unknown"
        assert finding.ecosystem == "javascript"
        assert finding.severity == "high"
        assert finding.vulnerability_id == "12345"  # Extracted from URL
        assert finding.description == "Prototype Pollution in vulnerable-dep"
        assert finding.fix_version == "2.0.0"

    def test_npm_audit_via_string_reference_skipped(self, tmp_path: Path) -> None:
        """npm audit via entries that are strings (not objects) are skipped."""
        package_json = tmp_path / "package.json"
        package_json.write_text('{"name": "test", "version": "1.0.0"}\n')
        package_lock = tmp_path / "package-lock.json"
        package_lock.write_text('{"lockfileVersion": 2}\n')

        # npm audit can return via as a list of strings (references to other advisories)
        npm_audit_output = {
            "vulnerabilities": {
                "some-dep": {
                    "severity": "moderate",
                    "via": ["GHSA-1234-5678-9abc"],  # String reference, not object
                    "fixAvailable": False,
                }
            }
        }

        mock_result = MagicMock()
        mock_result.stdout = json.dumps(npm_audit_output)
        mock_result.returncode = 1

        with patch("subprocess.run", return_value=mock_result):
            findings, manifests_scanned, errors = scan_vulnerabilities(tmp_path)
        # String via entries should be skipped - no findings
        assert len(findings) == 0
        # But manifest should still be scanned
        assert "package.json" in manifests_scanned

    def test_multiple_manifests_all_scanned(self, tmp_path: Path) -> None:
        """Multiple manifests (Python + JS) are all scanned."""
        # Create Python manifests
        requirements_txt = tmp_path / "requirements.txt"
        requirements_txt.write_text("requests==2.28.0\n")
        pyproject_toml = tmp_path / "pyproject.toml"
        pyproject_toml.write_text("[project]\nname = 'test'\n")

        # Create JS manifests
        package_json = tmp_path / "package.json"
        package_json.write_text('{"name": "test"}\n')
        package_lock = tmp_path / "package-lock.json"
        package_lock.write_text('{"lockfileVersion": 2}\n')

        def mock_subprocess_run(*args, **kwargs):
            cmd = args[0]
            mock_result = MagicMock()
            mock_result.returncode = 0

            if "npm" in cmd:
                mock_result.stdout = '{"vulnerabilities": {}}'
            else:
                mock_result.stdout = "[]"

            return mock_result

        with patch("subprocess.run", side_effect=mock_subprocess_run):
            findings, manifests_scanned, errors = scan_vulnerabilities(tmp_path)
        # All three manifests should be scanned
        assert "requirements.txt" in manifests_scanned
        assert "pyproject.toml" in manifests_scanned
        assert "package.json" in manifests_scanned

    def test_yarn_lock_enables_npm_audit(self, tmp_path: Path) -> None:
        """package.json + yarn.lock enables npm audit (not just package-lock.json)."""
        package_json = tmp_path / "package.json"
        package_json.write_text('{"name": "test", "version": "1.0.0"}\n')
        yarn_lock = tmp_path / "yarn.lock"
        yarn_lock.write_text("# yarn lockfile\n")

        mock_result = MagicMock()
        mock_result.stdout = '{"vulnerabilities": {}}'
        mock_result.returncode = 0

        with patch("subprocess.run", return_value=mock_result) as mock_run:
            findings, manifests_scanned, errors = scan_vulnerabilities(tmp_path)
        # npm audit should be called with yarn.lock present
        npm_called = any(
            "npm" in call[0][0] and "audit" in call[0][0]
            for call in mock_run.call_args_list
        )
        assert npm_called
        assert "package.json" in manifests_scanned

    def test_pip_audit_timeout_adds_error(self, tmp_path: Path) -> None:
        """pip-audit timeout adds error to scan_errors, no exception raised."""
        requirements_txt = tmp_path / "requirements.txt"
        requirements_txt.write_text("requests==2.28.0\n")

        with patch(
            "subprocess.run",
            side_effect=subprocess.TimeoutExpired(cmd="pip-audit", timeout=120),
        ):
            findings, manifests_scanned, errors = scan_vulnerabilities(tmp_path)
        assert "requirements.txt" in manifests_scanned
        assert len(errors) == 1
        assert "pip-audit timed out" in errors[0]
        assert findings == []

    def test_npm_not_found_adds_error(self, tmp_path: Path) -> None:
        """npm not found adds error to scan_errors, no exception raised."""
        package_json = tmp_path / "package.json"
        package_json.write_text('{"name": "test"}\n')
        package_lock = tmp_path / "package-lock.json"
        package_lock.write_text('{"lockfileVersion": 2}\n')

        # Mock requirements.txt to succeed (no error), npm to fail
        mock_pip_result = MagicMock()
        mock_pip_result.stdout = "[]"
        mock_pip_result.returncode = 0

        def mock_subprocess_run(*args, **kwargs):
            cmd = args[0]
            if "npm" in cmd:
                raise FileNotFoundError("npm not found")
            return mock_pip_result

        with patch("subprocess.run", side_effect=mock_subprocess_run):
            findings, manifests_scanned, errors = scan_vulnerabilities(tmp_path)
        # Should have npm error
        npm_errors = [e for e in errors if "npm" in e.lower()]
        assert len(npm_errors) == 1
        assert npm_errors[0] == "npm audit not found, skipping vulnerability scan"

    @pytest.mark.parametrize(
        "description,expected_severity",
        [
            ("Allows remote code execution via crafted input", "critical"),
            ("SQL injection vulnerability in query parameter", "high"),
            ("Some random issue with no keywords", "moderate"),
        ],
    )
    def test_severity_from_description_keywords(
        self, tmp_path: Path, description: str, expected_severity: str
    ) -> None:
        """Severity is inferred from vulnerability description keywords"""
        requirements_txt = tmp_path / "requirements.txt"
        requirements_txt.write_text("vuln-pkg==1.0.0\n")

        pip_audit_output = [
            {
                "name": "vuln-pkg",
                "version": "1.0.0",
                "vulns": [
                    {
                        "id": "PYSEC-2023-001",
                        "description": description,
                        "fix_versions": [],
                        "aliases": [],
                    }
                ],
            }
        ]

        mock_result = MagicMock()
        mock_result.stdout = json.dumps(pip_audit_output)
        mock_result.returncode = 1

        with patch("subprocess.run", return_value=mock_result):
            findings, manifests_scanned, errors = scan_vulnerabilities(tmp_path)

        assert len(findings) == 1
        assert findings[0].severity == expected_severity

    def test_empty_pip_audit_output_returns_empty_list(self, tmp_path: Path) -> None:
        """Empty pip-audit output returns empty findings list."""
        requirements_txt = tmp_path / "requirements.txt"
        requirements_txt.write_text("safe-pkg==1.0.0\n")

        mock_result = MagicMock()
        mock_result.stdout = ""
        mock_result.returncode = 0

        with patch("subprocess.run", return_value=mock_result):
            findings, manifests_scanned, errors = scan_vulnerabilities(tmp_path)
        assert findings == []
        assert errors == []

    def test_invalid_json_output_returns_empty_list(self, tmp_path: Path) -> None:
        """Invalid JSON from pip-audit returns empty findings, no exception."""
        requirements_txt = tmp_path / "requirements.txt"
        requirements_txt.write_text("pkg==1.0.0\n")

        mock_result = MagicMock()
        mock_result.stdout = "not valid json {{{"
        mock_result.returncode = 1

        with patch("subprocess.run", return_value=mock_result):
            findings, manifests_scanned, errors = scan_vulnerabilities(tmp_path)
        # Should not raise, just return empty
        assert findings == []

    def test_vulnerability_finding_all_fields_types(self, tmp_path: Path) -> None:
        """VulnerabilityFinding has correct types for all fields."""
        requirements_txt = tmp_path / "requirements.txt"
        requirements_txt.write_text("pkg==1.0.0\n")

        pip_audit_output = [
            {
                "name": "test-pkg",
                "version": "1.0.0",
                "vulns": [
                    {
                        "id": "PYSEC-2023-1",
                        "description": "Test vulnerability",
                        "fix_versions": ["2.0.0"],
                        "aliases": [],
                    }
                ],
            }
        ]

        mock_result = MagicMock()
        mock_result.stdout = json.dumps(pip_audit_output)
        mock_result.returncode = 1

        with patch("subprocess.run", return_value=mock_result):
            findings, manifests_scanned, errors = scan_vulnerabilities(tmp_path)
        assert len(findings) == 1
        f = findings[0]

        # Verify types
        assert isinstance(f.package_name, str)
        assert isinstance(f.installed_version, str)
        assert isinstance(f.vulnerability_id, str)
        assert isinstance(f.severity, str)
        assert isinstance(f.description, str)
        assert isinstance(f.fix_version, str)
        assert isinstance(f.ecosystem, str)
        # Verify valid severity value
        assert f.severity in ("low", "moderate", "high", "critical")
        # Verify valid ecosystem value
        assert f.ecosystem in ("python", "javascript")

    def test_requirements_txt_path_passed_to_pip_audit(self, tmp_path: Path) -> None:
        """pip-audit is called with the correct path to requirements.txt."""
        requirements_txt = tmp_path / "requirements.txt"
        requirements_txt.write_text("requests==2.28.0\n")

        mock_result = MagicMock()
        mock_result.stdout = "[]"
        mock_result.returncode = 0

        with patch("subprocess.run", return_value=mock_result) as mock_run:
            scan_vulnerabilities(tmp_path)

            call_args = mock_run.call_args[0][0]
            assert str(requirements_txt) in call_args

    def test_pyproject_toml_repo_path_passed_to_pip_audit(self, tmp_path: Path) -> None:
        """pip-audit is called with the correct path to pyproject.toml."""
        pyproject_toml = tmp_path / "pyproject.toml"
        pyproject_toml.write_text("[project]\nname = 'test'\n")

        mock_result = MagicMock()
        mock_result.stdout = "[]"
        mock_result.returncode = 0

        with patch("subprocess.run", return_value=mock_result) as mock_run:
            scan_vulnerabilities(tmp_path)

            call_kwargs = mock_run.call_args[1]
            assert call_kwargs.get("cwd") == tmp_path

    def test_js_deps_repo_path_passed_to_npm_audit(self, tmp_path: Path) -> None:
        """npm audit is called with the correct path to package.json."""
        package_json = tmp_path / "package.json"
        package_json.write_text('{"name": "test"}\n')
        package_lock = tmp_path / "package-lock.json"
        package_lock.write_text('{"lockfileVersion": 2}\n')

        mock_result = MagicMock()
        mock_result.stdout = '{"vulnerabilities": {}}'
        mock_result.returncode = 0

        with patch("subprocess.run", return_value=mock_result) as mock_run:
            scan_vulnerabilities(tmp_path)

        npm_call = None
        for call in mock_run.call_args_list:
            if "npm" in call[0][0]:
                npm_call = call
                break

        assert npm_call is not None, "npm audit was not called"
        assert npm_call[1].get("cwd") == tmp_path

    def test_pip_audit_oserror_adds_error(self, tmp_path: Path) -> None:
        """OSError during pip-audit execution adds error message."""
        requirements_txt = tmp_path / "requirements.txt"
        requirements_txt.write_text("pkg==1.0.0\n")

        with patch("subprocess.run", side_effect=OSError("Permission denied")):
            findings, manifests_scanned, errors = scan_vulnerabilities(tmp_path)

        assert "requirements.txt" in manifests_scanned
        assert len(errors) == 1
        assert "pip-audit execution failed" in errors[0]
        assert "Permission denied" in errors[0]
        assert findings == []

    def test_pip_audit_no_cve_alias_uses_vuln_id(self, tmp_path: Path) -> None:
        """When no CVE alias present, vulnerability_id uses original vuln id."""
        requirements_txt = tmp_path / "requirements.txt"
        requirements_txt.write_text("pkg==1.0.0\n")

        pip_audit_output = [
            {
                "name": "pkg",
                "version": "1.0.0",
                "vulns": [
                    {
                        "id": "PYSEC-2023-999",
                        "description": "Some issue",
                        "fix_versions": [],
                        "aliases": [],
                    }
                ],
            }
        ]

        mock_result = MagicMock()
        mock_result.stdout = json.dumps(pip_audit_output)
        mock_result.returncode = 1

        with patch("subprocess.run", return_value=mock_result):
            findings, manifests_scanned, errors = scan_vulnerabilities(tmp_path)

        assert len(findings) == 1
        assert findings[0].vulnerability_id == "PYSEC-2023-999"

    def test_npm_audit_no_title_uses_vuln_id_as_description(
        self, tmp_path: Path
    ) -> None:
        """When advisory has no title or overview, description falls back to vuln_id."""
        package_json = tmp_path / "package.json"
        package_json.write_text('{"name": "test"}\n')
        package_lock = tmp_path / "package-lock.json"
        package_lock.write_text('{"lockfileVersion": 2}\n')

        npm_audit_output = {
            "vulnerabilities": {
                "dep": {
                    "severity": "moderate",
                    "via": [
                        {
                            "url": "https://npmjs.com/advisories/12345",
                        }
                    ],
                    "fixAvailable": False,
                }
            }
        }

        mock_result = MagicMock()
        mock_result.stdout = json.dumps(npm_audit_output)
        mock_result.returncode = 1

        with patch("subprocess.run", return_value=mock_result):
            findings, manifests_scanned, errors = scan_vulnerabilities(tmp_path)

        assert len(findings) == 1
        assert findings[0].description == "12345"

    def test_npm_audit_fix_available_dict_no_version(self, tmp_path: Path) -> None:
        """fixAvailable as dict without version key results in empty fix_version."""
        package_json = tmp_path / "package.json"
        package_json.write_text('{"name": "test"}\n')
        package_lock = tmp_path / "package-lock.json"
        package_lock.write_text('{"lockfileVersion": 2}\n')

        npm_output = {
            "vulnerabilities": {
                "dep": {
                    "severity": "high",
                    "via": [
                        {
                            "url": "https://npmjs.com/advisories/1234",
                            "title": "Some vuln",
                        }
                    ],
                    "fixAvailable": {"name": "dep"},  # Dict but no "version" key
                }
            }
        }

        mock_result = MagicMock()
        mock_result.stdout = json.dumps(npm_output)
        mock_result.returncode = 1

        with patch("subprocess.run", return_value=mock_result):
            findings, _, _ = scan_vulnerabilities(tmp_path)

        assert len(findings) == 1
        assert findings[0].fix_version == ""
