"""
Tests for dependency_scanner.py

Uses tempfile.TemporaryDirectory fixtures — no dependency on the real repo.
All tests are self-contained and deterministic.
"""

import json
import os
import sys
import tempfile
import asyncio
from pathlib import Path
from typing import Dict, Any
from unittest.mock import MagicMock, AsyncMock, patch

import pytest

# ─── Path bootstrap ───────────────────────────────────────────────────────────
sys.path.insert(0, str(Path(__file__).parent.parent / "services" / "router"))

from dependency_scanner import (
    scan_dependencies,
    scan_dependencies_dict,
    _parse_poetry_lock,
    _parse_pipfile_lock,
    _parse_requirements_txt,
    _parse_pyproject_toml,
    _parse_package_lock_json,
    _parse_pnpm_lock,
    _parse_yarn_lock,
    _parse_package_json,
    _compare_versions,
    _normalize_pkg_name,
    _redact,
    ECOSYSTEM_PYPI,
    ECOSYSTEM_NPM,
)


# ─── Helpers ──────────────────────────────────────────────────────────────────

def make_cache(entries: Dict[str, Any]) -> Dict:
    """Build an osv_cache.json-compatible dict."""
    return {"version": 1, "updated_at": "2026-01-01T00:00:00+00:00", "entries": entries}


def write_cache(tmpdir: str, entries: Dict[str, Any]) -> str:
    cache_path = os.path.join(tmpdir, "osv_cache.json")
    with open(cache_path, "w") as f:
        json.dump(make_cache(entries), f)
    return cache_path


def vuln_entry(osv_id: str, pkg: str, ecosystem: str, severity: str, fixed: str) -> Dict:
    """Build a minimal OSV vuln object."""
    return {
        "id": osv_id,
        "aliases": [f"CVE-2024-{osv_id[-4:]}"],
        "database_specific": {"severity": severity},
        "summary": f"Test vuln in {pkg}",
        "affected": [
            {
                "package": {"name": pkg, "ecosystem": ecosystem},
                "ranges": [
                    {"type": "ECOSYSTEM", "events": [{"introduced": "0"}, {"fixed": fixed}]}
                ],
            }
        ],
    }


# ─── Unit: version comparison ─────────────────────────────────────────────────

class TestVersionCompare:
    def test_equal(self):
        assert _compare_versions("1.2.3", "1.2.3") == 0

    def test_newer(self):
        assert _compare_versions("2.0.0", "1.9.9") > 0

    def test_older(self):
        assert _compare_versions("1.0.0", "2.0.0") < 0

    def test_patch(self):
        assert _compare_versions("4.17.21", "4.17.20") > 0

    def test_semver_prerelease_digit_appended(self):
        # Simple parser treats "1.0.0b1" as [1,0,0,1] vs [1,0,0] → 1 > 0
        # This is a known limitation of the simple parser; full semver is not required
        result = _compare_versions("1.0.0b1", "1.0.0")
        assert result >= 0  # beta suffix appended as digit, not less than release

    def test_normalize_name(self):
        assert _normalize_pkg_name("Requests_lib") == "requests-lib"
        assert _normalize_pkg_name("PyYAML") == "pyyaml"


# ─── Unit: secret redaction ───────────────────────────────────────────────────

class TestRedact:
    def test_masks_api_key(self):
        assert "***REDACTED***" in _redact("api_key = 'abc12345678'")

    def test_masks_token(self):
        assert "***REDACTED***" in _redact("token: Bearer eyJhbGciOiJIUzI1NiJ9")

    def test_leaves_clean_text(self):
        text = "requests==2.31.0"
        assert _redact(text) == text


# ─── Unit: Python parsers ─────────────────────────────────────────────────────

class TestPoetryLockParser:
    def test_basic_parse(self):
        content = '''
[[package]]
name = "requests"
version = "2.31.0"
description = "HTTP library"
optional = false

[[package]]
name = "pyyaml"
version = "6.0.1"
description = "YAML library"
optional = false
'''
        pkgs = _parse_poetry_lock(content, "poetry.lock")
        assert len(pkgs) == 2
        names = {p.name for p in pkgs}
        assert "requests" in names
        assert "pyyaml" in names
        assert all(p.ecosystem == ECOSYSTEM_PYPI for p in pkgs)
        assert all(p.pinned for p in pkgs)

    def test_empty_content(self):
        assert _parse_poetry_lock("", "poetry.lock") == []

    def test_version_extracted(self):
        content = '[[package]]\nname = "fastapi"\nversion = "0.104.1"\n'
        pkgs = _parse_poetry_lock(content, "poetry.lock")
        assert pkgs[0].version == "0.104.1"


class TestPipfileLockParser:
    def test_basic_parse(self):
        data = {
            "default": {
                "requests": {"version": "==2.31.0"},
                "flask": {"version": "==2.3.0"},
            },
            "develop": {
                "pytest": {"version": "==7.4.0"},
            }
        }
        pkgs = _parse_pipfile_lock(json.dumps(data), "Pipfile.lock")
        names = {p.name: p.version for p in pkgs}
        assert names["requests"] == "2.31.0"
        assert names["flask"] == "2.3.0"
        assert names["pytest"] == "7.4.0"

    def test_invalid_json_returns_empty(self):
        assert _parse_pipfile_lock("not json", "Pipfile.lock") == []


class TestRequirementsTxtParser:
    def test_pinned_extracted(self):
        content = "requests==2.31.0\nflask==2.3.0\n# comment\n"
        pkgs = _parse_requirements_txt(content, "requirements.txt")
        pinned = {p.name: p.version for p in pkgs if p.pinned}
        assert pinned["requests"] == "2.31.0"
        assert pinned["flask"] == "2.3.0"

    def test_unpinned_recorded_but_no_version(self):
        content = "requests>=2.28.0\n"
        pkgs = _parse_requirements_txt(content, "requirements.txt")
        assert len(pkgs) == 1
        assert pkgs[0].version == ""
        assert not pkgs[0].pinned

    def test_extras_stripped(self):
        content = "uvicorn[standard]==0.24.0\n"
        pkgs = _parse_requirements_txt(content, "requirements.txt")
        pinned = [p for p in pkgs if p.pinned]
        assert len(pinned) == 1
        assert pinned[0].name == "uvicorn"
        assert pinned[0].version == "0.24.0"

    def test_git_and_comment_skipped(self):
        content = "# comment\n-r other.txt\ngit+https://github.com/foo/bar.git\n"
        pkgs = _parse_requirements_txt(content, "requirements.txt")
        assert pkgs == []

    def test_deduplication(self):
        content = "requests==2.31.0\nrequests==2.31.0\n"
        pkgs = _parse_requirements_txt(content, "requirements.txt")
        assert len([p for p in pkgs if p.name == "requests"]) == 1


class TestPyprojectParser:
    def test_poetry_deps(self):
        content = """
[tool.poetry.dependencies]
python = "^3.11"
fastapi = "^0.104"
pydantic = "^2.5"
"""
        pkgs = _parse_pyproject_toml(content, "pyproject.toml")
        names = {p.name for p in pkgs}
        assert "fastapi" in names
        assert "pydantic" in names
        assert "python" not in names

    def test_no_deps_section_returns_empty(self):
        assert _parse_pyproject_toml("[build-system]\n", "pyproject.toml") == []


# ─── Unit: Node parsers ───────────────────────────────────────────────────────

class TestPackageLockParser:
    def test_v2_format(self):
        data = {
            "lockfileVersion": 2,
            "packages": {
                "": {"name": "my-app"},
                "node_modules/lodash": {"version": "4.17.20", "resolved": "https://..."},
                "node_modules/axios": {"version": "1.7.2", "resolved": "https://..."},
            }
        }
        pkgs = _parse_package_lock_json(json.dumps(data), "package-lock.json")
        names = {p.name: p.version for p in pkgs}
        assert names["lodash"] == "4.17.20"
        assert names["axios"] == "1.7.2"
        # Root package skipped
        assert "" not in names

    def test_v1_fallback(self):
        data = {
            "lockfileVersion": 1,
            "dependencies": {
                "lodash": {"version": "4.17.21"},
            }
        }
        pkgs = _parse_package_lock_json(json.dumps(data), "package-lock.json")
        assert pkgs[0].version == "4.17.21"

    def test_scoped_package(self):
        data = {
            "lockfileVersion": 2,
            "packages": {
                "node_modules/@babel/core": {"version": "7.23.0"},
            }
        }
        pkgs = _parse_package_lock_json(json.dumps(data), "package-lock.json")
        assert pkgs[0].name == "@babel/core"


class TestPnpmLockParser:
    def test_basic_parse(self):
        content = "/lodash@4.17.21:\n  resolution: {integrity: sha512-xxx}\n  dev: false\n"
        pkgs = _parse_pnpm_lock(content, "pnpm-lock.yaml")
        assert pkgs[0].name == "lodash"
        assert pkgs[0].version == "4.17.21"


class TestYarnLockParser:
    def test_basic_parse(self):
        content = '''lodash@^4.17.11:
  version "4.17.21"
  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz"
  integrity sha512-xxx

axios@^1.7.2:
  version "1.7.2"
  resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.2.tgz"
  integrity sha512-yyy
'''
        pkgs = _parse_yarn_lock(content, "yarn.lock")
        names = {p.name: p.version for p in pkgs}
        assert names["lodash"] == "4.17.21"
        assert names["axios"] == "1.7.2"

    def test_deduplication(self):
        content = '''lodash@^4.17.11, lodash@^4.17.4:
  version "4.17.21"
  resolved "https://..."
  integrity sha512-xxx
'''
        pkgs = _parse_yarn_lock(content, "yarn.lock")
        lodash_entries = [p for p in pkgs if p.name == "lodash"]
        assert len(lodash_entries) == 1


class TestPackageJsonParser:
    def test_deps_and_dev_deps(self):
        data = {
            "dependencies": {"axios": "^1.7.2", "nats": "^2.28.2"},
            "devDependencies": {"jest": "^29.0.0"}
        }
        pkgs = _parse_package_json(json.dumps(data), "package.json")
        names = {p.name for p in pkgs}
        assert {"axios", "nats", "jest"}.issubset(names)
        assert all(p.version == "" for p in pkgs)


# ─── Integration: scan with offline cache ─────────────────────────────────────

class TestScanWithOfflineCache:
    def test_python_pinned_high_vuln_fails(self):
        """requirements.txt with pinned requests; cache has HIGH vuln → pass=False."""
        with tempfile.TemporaryDirectory() as tmpdir:
            reqs = os.path.join(tmpdir, "requirements.txt")
            Path(reqs).write_text("requests==2.28.0\n")

            cache_path = write_cache(tmpdir, {
                "PyPI:requests:2.28.0": {
                    "vulns": [vuln_entry("GHSA-001", "requests", "PyPI", "HIGH", "2.31.0")],
                    "cached_at": "2026-01-01T00:00:00+00:00",
                }
            })

            result = scan_dependencies(
                repo_root=tmpdir,
                targets=["python"],
                vuln_sources={"osv": {"enabled": True, "mode": "offline_cache",
                                      "cache_path": cache_path}},
                severity_thresholds={"fail_on": ["CRITICAL", "HIGH"], "warn_on": ["MEDIUM"]},
            )
            assert result.pass_ is False
            assert result.stats["vulns_total"] == 1
            assert result.stats["by_severity"]["HIGH"] == 1
            assert len(result.vulnerabilities) == 1
            assert result.vulnerabilities[0]["package"] == "requests"
            assert "2.31.0" in result.vulnerabilities[0]["recommendation"]

    def test_python_critical_vuln_fails(self):
        """CRITICAL vulnerability must fail the gate."""
        with tempfile.TemporaryDirectory() as tmpdir:
            Path(os.path.join(tmpdir, "requirements.txt")).write_text(
                "cryptography==38.0.0\n"
            )
            cache_path = write_cache(tmpdir, {
                "PyPI:cryptography:38.0.0": {
                    "vulns": [vuln_entry("GHSA-CRIT-001", "cryptography", "PyPI", "CRITICAL", "41.0.6")],
                    "cached_at": "2026-01-01T00:00:00+00:00",
                }
            })
            result = scan_dependencies(
                repo_root=tmpdir,
                targets=["python"],
                vuln_sources={"osv": {"enabled": True, "mode": "offline_cache",
                                      "cache_path": cache_path}},
            )
            assert result.pass_ is False
            assert result.stats["by_severity"]["CRITICAL"] == 1

    def test_python_medium_only_passes(self):
        """MEDIUM vuln should not block release (not in fail_on)."""
        with tempfile.TemporaryDirectory() as tmpdir:
            Path(os.path.join(tmpdir, "requirements.txt")).write_text(
                "pyyaml==5.3.0\n"
            )
            cache_path = write_cache(tmpdir, {
                "PyPI:pyyaml:5.3.0": {
                    "vulns": [vuln_entry("GHSA-MED-001", "pyyaml", "PyPI", "MEDIUM", "6.0")],
                    "cached_at": "2026-01-01T00:00:00+00:00",
                }
            })
            result = scan_dependencies(
                repo_root=tmpdir,
                targets=["python"],
                vuln_sources={"osv": {"enabled": True, "mode": "offline_cache",
                                      "cache_path": cache_path}},
                severity_thresholds={"fail_on": ["CRITICAL", "HIGH"], "warn_on": ["MEDIUM"]},
            )
            assert result.pass_ is True
            assert result.stats["by_severity"]["MEDIUM"] == 1
            # MEDIUM should appear in recommendations
            recs_text = " ".join(result.recommendations).lower()
            assert "medium" in recs_text

    def test_node_package_lock_high_fails(self):
        """package-lock.json with lodash@4.17.20; cache has HIGH → fail."""
        with tempfile.TemporaryDirectory() as tmpdir:
            lock_data = {
                "lockfileVersion": 2,
                "packages": {
                    "node_modules/lodash": {"version": "4.17.20"}
                }
            }
            Path(os.path.join(tmpdir, "package-lock.json")).write_text(
                json.dumps(lock_data)
            )
            cache_path = write_cache(tmpdir, {
                "npm:lodash:4.17.20": {
                    "vulns": [vuln_entry("GHSA-NPM-001", "lodash", "npm", "HIGH", "4.17.21")],
                    "cached_at": "2026-01-01T00:00:00+00:00",
                }
            })
            result = scan_dependencies(
                repo_root=tmpdir,
                targets=["node"],
                vuln_sources={"osv": {"enabled": True, "mode": "offline_cache",
                                      "cache_path": cache_path}},
            )
            assert result.pass_ is False
            assert result.stats["vulns_total"] == 1

    def test_cache_miss_unknown_severity_passes_by_default(self):
        """Dep exists but has no cache entry → severity UNKNOWN → pass=True (not in fail_on)."""
        with tempfile.TemporaryDirectory() as tmpdir:
            Path(os.path.join(tmpdir, "requirements.txt")).write_text(
                "newlib==1.0.0\n"
            )
            cache_path = write_cache(tmpdir, {})  # empty cache

            result = scan_dependencies(
                repo_root=tmpdir,
                targets=["python"],
                vuln_sources={"osv": {"enabled": True, "mode": "offline_cache",
                                      "cache_path": cache_path}},
                severity_thresholds={"fail_on": ["CRITICAL", "HIGH"], "warn_on": ["MEDIUM"]},
            )
            assert result.pass_ is True
            assert result.stats["deps_unresolved"] == 1
            # Should recommend populating cache
            recs = " ".join(result.recommendations)
            assert "cache" in recs.lower() or "Update cache" in recs

    def test_no_pinned_deps_passes_with_recommendation(self):
        """Unpinned deps cannot be checked → pass=True + recommendation to pin."""
        with tempfile.TemporaryDirectory() as tmpdir:
            Path(os.path.join(tmpdir, "requirements.txt")).write_text(
                "requests>=2.28.0\nflask>=2.0\n"
            )
            cache_path = write_cache(tmpdir, {})

            result = scan_dependencies(
                repo_root=tmpdir,
                targets=["python"],
                vuln_sources={"osv": {"enabled": True, "mode": "offline_cache",
                                      "cache_path": cache_path}},
            )
            assert result.pass_ is True
            recs = " ".join(result.recommendations).lower()
            assert "unpinned" in recs or "pin" in recs

    def test_no_vulns_passes_cleanly(self):
        """All deps in cache with empty vuln list → clean pass."""
        with tempfile.TemporaryDirectory() as tmpdir:
            Path(os.path.join(tmpdir, "requirements.txt")).write_text(
                "requests==2.31.0\npyyaml==6.0.1\n"
            )
            cache_path = write_cache(tmpdir, {
                "PyPI:requests:2.31.0": {"vulns": [], "cached_at": "2026-01-01T00:00:00+00:00"},
                "PyPI:pyyaml:6.0.1": {"vulns": [], "cached_at": "2026-01-01T00:00:00+00:00"},
            })
            result = scan_dependencies(
                repo_root=tmpdir,
                targets=["python"],
                vuln_sources={"osv": {"enabled": True, "mode": "offline_cache",
                                      "cache_path": cache_path}},
            )
            assert result.pass_ is True
            assert result.stats["vulns_total"] == 0
            assert result.recommendations == []


class TestScanStructure:
    def test_stats_structure(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            Path(os.path.join(tmpdir, "requirements.txt")).write_text("flask==2.3.0\n")
            cache_path = write_cache(tmpdir, {
                "PyPI:flask:2.3.0": {"vulns": [], "cached_at": "2026-01-01T00:00:00+00:00"},
            })
            result = scan_dependencies(
                repo_root=tmpdir, targets=["python"],
                vuln_sources={"osv": {"enabled": True, "mode": "offline_cache",
                                      "cache_path": cache_path}},
            )
            stats = result.stats
            assert "ecosystems" in stats
            assert "files_scanned" in stats
            assert "deps_total" in stats
            assert "deps_pinned" in stats
            assert "vulns_total" in stats
            assert "by_severity" in stats
            assert set(stats["by_severity"].keys()) >= {"CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN"}

    def test_vuln_object_structure(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            Path(os.path.join(tmpdir, "requirements.txt")).write_text("vuln-pkg==1.0.0\n")
            cache_path = write_cache(tmpdir, {
                "PyPI:vuln-pkg:1.0.0": {
                    "vulns": [vuln_entry("GHSA-TEST-001", "vuln-pkg", "PyPI", "HIGH", "2.0.0")],
                    "cached_at": "2026-01-01T00:00:00+00:00",
                }
            })
            result = scan_dependencies(
                repo_root=tmpdir, targets=["python"],
                vuln_sources={"osv": {"enabled": True, "mode": "offline_cache",
                                      "cache_path": cache_path}},
            )
            assert result.vulnerabilities
            v = result.vulnerabilities[0]
            required_keys = {"id", "ecosystem", "package", "version", "severity",
                             "fixed_versions", "aliases", "evidence", "recommendation"}
            assert required_keys.issubset(v.keys())

    def test_scan_dependencies_dict_wrapper(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            Path(os.path.join(tmpdir, "requirements.txt")).write_text("requests==2.31.0\n")
            cache_path = write_cache(tmpdir, {
                "PyPI:requests:2.31.0": {"vulns": [], "cached_at": "2026-01-01T00:00:00+00:00"},
            })
            d = scan_dependencies_dict(
                repo_root=tmpdir, targets=["python"],
                vuln_sources={"osv": {"enabled": True, "mode": "offline_cache",
                                      "cache_path": cache_path}},
            )
            assert isinstance(d, dict)
            assert "pass" in d
            assert "summary" in d
            assert "stats" in d
            assert "vulnerabilities" in d
            assert "recommendations" in d


class TestPoetryLockIntegration:
    def test_poetry_lock_full_scan(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            content = """[[package]]
name = "requests"
version = "2.31.0"
description = "Python HTTP"
optional = false

[[package]]
name = "cryptography"
version = "41.0.0"
description = "Crypto"
optional = false
"""
            Path(os.path.join(tmpdir, "poetry.lock")).write_text(content)
            cache_path = write_cache(tmpdir, {
                "PyPI:requests:2.31.0": {"vulns": [], "cached_at": "2026-01-01T00:00:00+00:00"},
                "PyPI:cryptography:41.0.0": {
                    "vulns": [vuln_entry("GHSA-CRYPTO", "cryptography", "PyPI", "HIGH", "42.0.0")],
                    "cached_at": "2026-01-01T00:00:00+00:00",
                }
            })
            result = scan_dependencies(
                repo_root=tmpdir, targets=["python"],
                vuln_sources={"osv": {"enabled": True, "mode": "offline_cache",
                                      "cache_path": cache_path}},
            )
            assert result.pass_ is False
            assert result.stats["deps_pinned"] == 2
            assert result.stats["vulns_total"] == 1


class TestOutdatedAnalysis:
    def test_outdated_detected_from_fixed_version(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            Path(os.path.join(tmpdir, "requirements.txt")).write_text("pyyaml==5.4.1\n")
            cache_path = write_cache(tmpdir, {
                "PyPI:pyyaml:5.4.1": {
                    "vulns": [vuln_entry("GHSA-YAML", "pyyaml", "PyPI", "MEDIUM", "6.0")],
                    "cached_at": "2026-01-01T00:00:00+00:00",
                }
            })
            result = scan_dependencies(
                repo_root=tmpdir, targets=["python"],
                vuln_sources={"osv": {"enabled": True, "mode": "offline_cache",
                                      "cache_path": cache_path}},
                outdated_cfg={"enabled": True, "mode": "lockfile_only"},
            )
            assert result.stats["outdated_total"] == 1
            assert result.outdated[0]["package"] == "pyyaml"
            assert result.outdated[0]["current"] == "5.4.1"
            assert result.outdated[0]["latest"] == "6.0"

    def test_outdated_disabled(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            Path(os.path.join(tmpdir, "requirements.txt")).write_text("pyyaml==5.4.1\n")
            cache_path = write_cache(tmpdir, {
                "PyPI:pyyaml:5.4.1": {
                    "vulns": [vuln_entry("GHSA-YAML", "pyyaml", "PyPI", "MEDIUM", "6.0")],
                    "cached_at": "2026-01-01T00:00:00+00:00",
                }
            })
            result = scan_dependencies(
                repo_root=tmpdir, targets=["python"],
                vuln_sources={"osv": {"enabled": True, "mode": "offline_cache",
                                      "cache_path": cache_path}},
                outdated_cfg={"enabled": False},
            )
            assert result.stats["outdated_total"] == 0
            assert result.outdated == []


class TestExcludedPaths:
    def test_node_modules_excluded(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            # Real requirements.txt
            Path(os.path.join(tmpdir, "requirements.txt")).write_text("requests==2.31.0\n")
            # Fake vuln in node_modules (should be skipped)
            nm = os.path.join(tmpdir, "node_modules", "some-pkg")
            os.makedirs(nm)
            Path(os.path.join(nm, "requirements.txt")).write_text("evil-pkg==0.0.1\n")

            cache_path = write_cache(tmpdir, {
                "PyPI:requests:2.31.0": {"vulns": [], "cached_at": "2026-01-01T00:00:00+00:00"},
            })
            result = scan_dependencies(
                repo_root=tmpdir, targets=["python"],
                vuln_sources={"osv": {"enabled": True, "mode": "offline_cache",
                                      "cache_path": cache_path}},
            )
            pkg_names = [v["package"] for v in result.vulnerabilities]
            assert "evil-pkg" not in pkg_names


class TestLimitsEnforced:
    def test_max_deps_truncation(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            # 10 pinned deps, limit to 3
            lines = "\n".join(f"pkg{i}=={i}.0.0" for i in range(10))
            Path(os.path.join(tmpdir, "requirements.txt")).write_text(lines + "\n")
            cache_path = write_cache(tmpdir, {})

            result = scan_dependencies(
                repo_root=tmpdir, targets=["python"],
                vuln_sources={"osv": {"enabled": True, "mode": "offline_cache",
                                      "cache_path": cache_path}},
                limits={"max_files": 80, "max_deps": 3, "max_vulns": 500},
            )
            assert result.stats["deps_total"] <= 3


# ─── Integration: release_check with dependency_scan gate ─────────────────────

def _run(coro):
    return asyncio.run(coro)


class TestReleaseCheckWithDeps:
    """Ensure the dependency_scan gate correctly influences release_check."""

    def _make_tool_manager(self, scan_result: Dict) -> MagicMock:
        """Build a minimal tool_manager mock for release_check tests."""
        tm = MagicMock()

        async def execute_tool(tool_name, args, **kwargs):
            result = MagicMock()
            if tool_name == "dependency_scanner_tool":
                result.success = True
                result.result = scan_result
                result.error = None
            else:
                # Other tools: pass by default
                result.success = True
                result.result = {
                    "status": "pass",
                    "findings": [],
                    "summary": "ok",
                    "verdict": "PASS",
                    "pass": True,
                }
                result.error = None
            return result

        tm.execute_tool = execute_tool
        return tm

    def test_dep_scan_fail_blocks_release(self):
        """HIGH vuln in deps → release_check pass=False."""
        from release_check_runner import run_release_check

        scan_fail = {
            "pass": False,
            "summary": "❌ HIGH vuln found",
            "stats": {
                "vulns_total": 1,
                "by_severity": {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 0, "LOW": 0, "UNKNOWN": 0},
                "deps_total": 10,
            },
            "vulnerabilities": [{
                "id": "GHSA-TEST",
                "package": "requests",
                "version": "2.28.0",
                "severity": "HIGH",
                "fixed_versions": ["2.31.0"],
                "recommendation": "Upgrade requests to 2.31.0",
                "aliases": [],
                "evidence": {},
            }],
            "outdated": [],
            "licenses": [],
            "recommendations": ["Upgrade requests to 2.31.0"],
        }
        tm = self._make_tool_manager(scan_fail)
        report = _run(run_release_check(tm, {
            "service_name": "router",
            "diff": "--- a/req.txt\n+++ b/req.txt\n",
            "run_deps": True,
            "deps_vuln_mode": "offline_cache",
        }, agent_id="sofiia"))

        assert report["pass"] is False
        gate_names = [g["name"] for g in report["gates"]]
        assert "dependency_scan" in gate_names
        dep_gate = next(g for g in report["gates"] if g["name"] == "dependency_scan")
        assert dep_gate["status"] == "fail"

    def test_dep_scan_pass_allows_release(self):
        """No vulns → gate passes, release_check can continue."""
        from release_check_runner import run_release_check

        scan_pass = {
            "pass": True,
            "summary": "✅ No vulns",
            "stats": {
                "vulns_total": 0,
                "by_severity": {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0, "UNKNOWN": 0},
                "deps_total": 50,
            },
            "vulnerabilities": [],
            "outdated": [],
            "licenses": [],
            "recommendations": [],
        }
        tm = self._make_tool_manager(scan_pass)
        report = _run(run_release_check(tm, {
            "service_name": "router",
            "diff": "",
            "run_deps": True,
        }, agent_id="sofiia"))

        dep_gate = next(
            (g for g in report["gates"] if g["name"] == "dependency_scan"), None
        )
        assert dep_gate is not None
        assert dep_gate["status"] == "pass"

    def test_dep_scan_disabled(self):
        """run_deps=False → dependency_scan gate not in report."""
        from release_check_runner import run_release_check

        tm = self._make_tool_manager({"pass": True, "stats": {}, "vulnerabilities": [],
                                       "outdated": [], "licenses": [], "recommendations": [],
                                       "summary": ""})
        report = _run(run_release_check(tm, {
            "service_name": "router",
            "diff": "",
            "run_deps": False,
        }, agent_id="sofiia"))

        gate_names = [g["name"] for g in report["gates"]]
        assert "dependency_scan" not in gate_names

    def test_dep_scan_fail_fast(self):
        """fail_fast=True → report is returned immediately after dep scan failure."""
        from release_check_runner import run_release_check

        scan_fail = {
            "pass": False,
            "summary": "CRITICAL vuln",
            "stats": {
                "vulns_total": 1,
                "by_severity": {"CRITICAL": 1, "HIGH": 0, "MEDIUM": 0, "LOW": 0, "UNKNOWN": 0},
                "deps_total": 5,
            },
            "vulnerabilities": [{
                "id": "GHSA-CRIT",
                "package": "oldlib",
                "version": "0.1.0",
                "severity": "CRITICAL",
                "fixed_versions": ["1.0.0"],
                "recommendation": "Upgrade oldlib",
                "aliases": [],
                "evidence": {},
            }],
            "outdated": [],
            "licenses": [],
            "recommendations": ["Upgrade oldlib"],
        }
        call_count = {"n": 0}
        tm = MagicMock()

        async def execute_tool(tool_name, args, **kwargs):
            call_count["n"] += 1
            result = MagicMock()
            if tool_name == "dependency_scanner_tool":
                result.success = True
                result.result = scan_fail
            else:
                result.success = True
                result.result = {"pass": True, "findings": [], "summary": "ok"}
            result.error = None
            return result

        tm.execute_tool = execute_tool

        report = _run(run_release_check(tm, {
            "service_name": "router",
            "diff": "",
            "run_deps": True,
            "fail_fast": True,
        }, agent_id="sofiia"))

        assert report["pass"] is False
        # pr_review + config_lint + dependency_scan = 3 calls; no further tools called
        assert call_count["n"] <= 3


# ─── RBAC Tests ───────────────────────────────────────────────────────────────

class TestDepsRBAC:
    """Verify RBAC guards for dependency_scanner_tool."""

    def test_agent_without_deps_read_is_denied(self):
        from tool_governance import check_rbac
        ok, reason = check_rbac("agent_media", "dependency_scanner_tool", "scan")
        assert not ok
        assert "denied" in reason.lower() or "entitlement" in reason.lower()

    def test_agent_cto_has_deps_read(self):
        from tool_governance import check_rbac
        ok, _ = check_rbac("sofiia", "dependency_scanner_tool", "scan")
        assert ok

    def test_agent_oncall_has_deps_read(self):
        from tool_governance import check_rbac
        ok, _ = check_rbac("helion", "dependency_scanner_tool", "scan")
        assert ok

    def test_agent_media_no_deps_gate(self):
        from tool_governance import check_rbac
        ok, _ = check_rbac("agent_media", "dependency_scanner_tool", "gate")
        assert not ok
