"""Dependency vulnerability scanning using pip-audit and npm audit.

Scans Python and JavaScript dependencies for known CVEs using
the respective ecosystem's audit tools.
"""

from __future__ import annotations

import json
import logging
import subprocess
from pathlib import Path  # noqa: TC003 — used at runtime for path operations
from typing import Literal

from unfamiliar.analysis.models import VulnerabilityFinding

logger = logging.getLogger(__name__)

# Critical severity keywords for vulnerability detection
_CRITICAL_KEYWORDS = ["remote code execution", "rce", "arbitrary code"]
_HIGH_KEYWORDS = ["sql injection", "xss", "authentication bypass"]


def scan_vulnerabilities(
    repo_path: Path,
) -> tuple[list[VulnerabilityFinding], list[str], list[str]]:
    """Scan dependency manifests for known vulnerabilities.

    Checks for:
    - Python: requirements.txt or pyproject.toml (via pip-audit)
    - JavaScript: package.json with lockfile (via npm audit)

    Args:
        repo_path: Path to the repository root.

    Returns:
        Tuple of (findings, manifests_scanned, errors).
        - findings: List of VulnerabilityFinding objects.
        - manifests_scanned: List of manifest files that were checked.
        - errors: List of error messages from failed scans.
    """
    findings: list[VulnerabilityFinding] = []
    manifests_scanned: list[str] = []
    errors: list[str] = []

    # Check for Python manifests
    requirements_txt = repo_path / "requirements.txt"
    pyproject_toml = repo_path / "pyproject.toml"

    if requirements_txt.exists():
        manifests_scanned.append("requirements.txt")
        result, error = _scan_requirements_txt(requirements_txt)
        if result:
            findings.extend(result)
        if error:
            errors.append(error)

    if pyproject_toml.exists():
        manifests_scanned.append("pyproject.toml")
        result, error = _scan_pyproject_toml(repo_path)
        if result:
            findings.extend(result)
        if error:
            errors.append(error)

    # Check for JavaScript manifests (need both package.json and lockfile)
    package_json = repo_path / "package.json"
    package_lock = repo_path / "package-lock.json"
    yarn_lock = repo_path / "yarn.lock"

    if package_json.exists() and (package_lock.exists() or yarn_lock.exists()):
        manifests_scanned.append("package.json")
        result, error = _scan_js_deps(repo_path)
        if result:
            findings.extend(result)
        if error:
            errors.append(error)

    return findings, manifests_scanned, errors


def _run_audit_subprocess(
    cmd: list[str],
    timeout: int,
    cwd: Path | None,
    tool_label: str,
) -> tuple[str, str | None]:
    """Run an audit subprocess with standard error handling.

    Returns (stout, None) on success, ("", error_message) on failure.
    """
    try:
        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=timeout,
            cwd=cwd,
        )
        return result.stdout, None
    except FileNotFoundError:
        error = f"{tool_label} not found, skipping vulnerability scan"
        logger.debug(error)
        return "", error
    except subprocess.TimeoutExpired:
        error = f"{tool_label} timed out"
        logger.warning(error)
        return "", error
    except OSError as e:
        error = f"{tool_label} execution failed: {e}"
        logger.warning(error)
        return "", error


def _scan_requirements_txt(
    manifest_path: Path,
) -> tuple[list[VulnerabilityFinding], str | None]:
    """Scan requirements.txt for vulnerabilities using pip-audit.

    Args:
        manifest_path: Path to requirements.txt.

    Returns:
        Tuple of (findings, error_message).
    """
    stdout, error = _run_audit_subprocess(
        cmd=[
            "pip-audit",
            "-r",
            str(manifest_path),
            "-f",
            "json",
            "--progress-spinner=off",
        ],
        timeout=120,
        cwd=None,
        tool_label="pip-audit",
    )
    if error:
        return [], error
    return _parse_pip_audit_output(stdout), None


def _scan_pyproject_toml(
    repo_path: Path,
) -> tuple[list[VulnerabilityFinding], str | None]:
    """Scan pyproject.toml for vulnerabilities using pip-audit.

    Uses --locked flag with cwd instead of -r flag.

    Args:
        repo_path: Path to the repository root.

    Returns:
        Tuple of (findings, error_message).
    """
    stdout, error = _run_audit_subprocess(
        [
            "pip-audit",
            "--locked",
            "-f",
            "json",
            "--progress-spinner=off",
        ],
        timeout=120,
        cwd=repo_path,
        tool_label="pip-audit",
    )
    if error:
        return [], error
    return _parse_pip_audit_output(stdout), None


def _parse_pip_audit_output(output: str) -> list[VulnerabilityFinding]:
    """Parse pip-audit JSON output into VulnerabilityFinding objects.

    Args:
        output: JSON output from pip-audit.

    Returns:
        List of VulnerabilityFinding objects.
    """
    findings: list[VulnerabilityFinding] = []

    if not output.strip():
        return findings

    try:
        data = json.loads(output)
    except json.JSONDecodeError as e:
        logger.warning("Failed to parse pip-audit output: %s", e)
        return findings

    # pip-audit outputs a list of packages with vulns
    for package in data:
        package_name = package.get("name", "")
        installed_version = package.get("version", "")
        vulns = package.get("vulns", [])

        for vuln in vulns:
            vuln_id = vuln.get("id", "")
            description = vuln.get("description", "")
            fix_versions = vuln.get("fix_versions", [])
            aliases = vuln.get("aliases", [])

            # Use first fix version if available
            fix_version = fix_versions[0] if fix_versions else ""

            # Find CVE identifier from aliases
            cve_id = vuln_id
            for alias in aliases:
                if alias.startswith("CVE-"):
                    cve_id = alias
                    break

            # Determine severity from description or default to moderate
            severity = _determine_pip_audit_severity(vuln)

            findings.append(
                VulnerabilityFinding(
                    package_name=package_name,
                    installed_version=installed_version,
                    vulnerability_id=cve_id,
                    severity=severity,
                    description=description,
                    fix_version=fix_version,
                    ecosystem="python",
                )
            )

    return findings


def _determine_pip_audit_severity(
    vuln: dict[str, str | list[str]],
) -> Literal["low", "moderate", "high", "critical"]:
    """Determine severity from pip-audit vulnerability data.

    pip-audit doesn't provide severity directly, so we infer from
    description keywords or default to moderate.

    Args:
        vuln: Vulnerability dict from pip-audit.

    Returns:
        Severity string: "low", "moderate", "high", or "critical".
    """
    description_val = vuln.get("description", "")
    description = description_val.lower() if isinstance(description_val, str) else ""

    # Check for critical keywords
    if any(kw in description for kw in _CRITICAL_KEYWORDS):
        return "critical"
    if any(kw in description for kw in _HIGH_KEYWORDS):
        return "high"

    # Default to moderate for known vulnerabilities
    return "moderate"


def _scan_js_deps(
    project_dir: Path,
) -> tuple[list[VulnerabilityFinding], str | None]:
    """Scan JavaScript dependencies using npm audit.

    Args:
        project_dir: Path to the project directory (containing package.json).

    Returns:
        Tuple of (findings, error_message).
    """
    stdout, error = _run_audit_subprocess(
        ["npm", "audit", "--json"],
        timeout=120,
        cwd=project_dir,
        tool_label="npm audit",
    )
    if error:
        return [], error
    # Parse stdout regardless of exit code
    return _parse_npm_audit_output(stdout), None


def _parse_npm_audit_output(output: str) -> list[VulnerabilityFinding]:
    """Parse npm audit JSON output into VulnerabilityFinding objects.

    Args:
        output: JSON output from npm audit.

    Returns:
        List of VulnerabilityFinding objects.
    """
    findings: list[VulnerabilityFinding] = []

    if not output.strip():
        return findings

    try:
        data = json.loads(output)
    except json.JSONDecodeError as e:
        logger.warning("Failed to parse npm audit output: %s", e)
        return findings

    # npm audit v7+ has "vulnerabilities" dict keyed by package name
    vulns = data.get("vulnerabilities", {})

    for package_name, vuln_data in vulns.items():
        severity = vuln_data.get("severity", "moderate")
        via = vuln_data.get("via", [])

        # via can be a list of strings or objects
        for advisory in via:
            if isinstance(advisory, str):
                # String reference - skip as we need details
                continue

            if not isinstance(advisory, dict):
                continue

            vuln_id = advisory.get("url", "").split("/")[-1]
            if not vuln_id:
                vuln_id = advisory.get("name", "Unknown")

            # Get CVE or GHSA from advisory
            cve_id = vuln_id

            title = advisory.get("title", "")
            overview = advisory.get("overview", "")
            description = title if title else overview[:200] if overview else vuln_id

            # Get fix info
            fix_available = vuln_data.get("fixAvailable", False)
            fix_version = ""
            if isinstance(fix_available, dict):
                fix_version = fix_available.get("version", "")

            # Get installed version
            installed_version = "unknown"

            findings.append(
                VulnerabilityFinding(
                    package_name=package_name,
                    installed_version=installed_version,
                    vulnerability_id=cve_id,
                    severity=severity,
                    description=description,
                    fix_version=fix_version,
                    ecosystem="javascript",
                )
            )

    return findings
