# Copyright (c) 2026 MergeGuide, Inc. All rights reserved.
# SPDX-License-Identifier: Proprietary
"""Manifest parsers for dependency extraction.

Parses dependency manifests from various package ecosystems to extract
package names and versions for vulnerability scanning.
"""

from __future__ import annotations

import json
import re
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import TYPE_CHECKING

try:
    from defusedxml.ElementTree import fromstring as safe_fromstring
except ImportError:
    # Fallback: disable external entities via XMLParser
    def safe_fromstring(text: str) -> ET.Element:
        parser = ET.XMLParser()
        parser.feed(text)
        return parser.close()


if TYPE_CHECKING:
    pass


class Ecosystem(Enum):
    """Supported package ecosystems."""

    NPM = "npm"
    PYPI = "PyPI"
    GO = "Go"
    CARGO = "crates.io"
    MAVEN = "Maven"
    RUBYGEMS = "RubyGems"
    PACKAGIST = "Packagist"
    NUGET = "NuGet"


@dataclass
class Dependency:
    """A parsed dependency with name and version."""

    name: str
    version: str
    ecosystem: Ecosystem
    source_file: str = ""
    is_dev: bool = False

    def to_osv_query(self) -> dict:
        """Convert to OSV API query format."""
        return {
            "package": {
                "name": self.name,
                "ecosystem": self.ecosystem.value,
            },
            "version": self.version,
        }


class ManifestParser:
    """Parses dependency manifests from various ecosystems."""

    # Map of filename patterns to parser methods
    PARSERS: dict[str, tuple[str, Ecosystem]] = {
        "package.json": ("_parse_npm_package_json", Ecosystem.NPM),
        "package-lock.json": ("_parse_npm_package_lock", Ecosystem.NPM),
        "requirements.txt": ("_parse_pip_requirements", Ecosystem.PYPI),
        "Pipfile": ("_parse_pipfile", Ecosystem.PYPI),
        "pyproject.toml": ("_parse_pyproject_toml", Ecosystem.PYPI),
        "go.mod": ("_parse_go_mod", Ecosystem.GO),
        "go.sum": ("_parse_go_sum", Ecosystem.GO),
        "Cargo.toml": ("_parse_cargo_toml", Ecosystem.CARGO),
        "Cargo.lock": ("_parse_cargo_lock", Ecosystem.CARGO),
        "pom.xml": ("_parse_maven_pom", Ecosystem.MAVEN),
        "Gemfile": ("_parse_gemfile", Ecosystem.RUBYGEMS),
        "Gemfile.lock": ("_parse_gemfile_lock", Ecosystem.RUBYGEMS),
        "composer.json": ("_parse_composer_json", Ecosystem.PACKAGIST),
        "composer.lock": ("_parse_composer_lock", Ecosystem.PACKAGIST),
        "packages.config": ("_parse_nuget_packages_config", Ecosystem.NUGET),
    }

    def parse(self, content: str, filename: str) -> list[Dependency]:
        """Parse a manifest file and extract dependencies.

        Args:
            content: The manifest file content
            filename: The filename (used to determine parser)

        Returns:
            List of parsed dependencies

        Raises:
            ValueError: If the manifest format is not supported
        """
        # Get just the filename without path
        base_filename = Path(filename).name

        # Check for .csproj files (NuGet)
        if base_filename.endswith(".csproj"):
            return self._parse_csproj(content, filename)

        # Look up parser
        if base_filename not in self.PARSERS:
            raise ValueError(f"Unsupported manifest file: {filename}")

        parser_name, ecosystem = self.PARSERS[base_filename]
        parser = getattr(self, parser_name)

        deps = parser(content, filename)
        # Ensure source_file is set
        for dep in deps:
            if not dep.source_file:
                dep.source_file = filename

        return deps

    def _parse_npm_package_json(self, content: str, filename: str) -> list[Dependency]:
        """Parse npm package.json for dependencies."""
        deps: list[Dependency] = []
        try:
            data = json.loads(content)
        except json.JSONDecodeError:
            return deps

        # Production dependencies
        for name, version in data.get("dependencies", {}).items():
            clean_version = self._clean_npm_version(version)
            if clean_version:
                deps.append(
                    Dependency(
                        name=name,
                        version=clean_version,
                        ecosystem=Ecosystem.NPM,
                        source_file=filename,
                        is_dev=False,
                    )
                )

        # Dev dependencies
        for name, version in data.get("devDependencies", {}).items():
            clean_version = self._clean_npm_version(version)
            if clean_version:
                deps.append(
                    Dependency(
                        name=name,
                        version=clean_version,
                        ecosystem=Ecosystem.NPM,
                        source_file=filename,
                        is_dev=True,
                    )
                )

        return deps

    def _parse_npm_package_lock(self, content: str, filename: str) -> list[Dependency]:
        """Parse npm package-lock.json for exact versions."""
        deps: list[Dependency] = []
        try:
            data = json.loads(content)
        except json.JSONDecodeError:
            return deps

        # v2/v3 lockfile format (packages key)
        packages = data.get("packages", {})
        for pkg_path, pkg_info in packages.items():
            if not pkg_path or pkg_path == "":  # Skip root package
                continue
            # Extract package name from path (node_modules/name or node_modules/@scope/name)
            name = pkg_path.split("node_modules/")[-1] if "node_modules/" in pkg_path else None
            if name and "version" in pkg_info:
                deps.append(
                    Dependency(
                        name=name,
                        version=pkg_info["version"],
                        ecosystem=Ecosystem.NPM,
                        source_file=filename,
                        is_dev=pkg_info.get("dev", False),
                    )
                )

        # v1 lockfile format (dependencies key at top level)
        if not deps and "dependencies" in data:
            deps = self._parse_npm_lock_v1_deps(data["dependencies"], filename)

        return deps

    def _parse_npm_lock_v1_deps(
        self, deps_dict: dict, filename: str, is_dev: bool = False
    ) -> list[Dependency]:
        """Recursively parse v1 lockfile dependencies."""
        deps: list[Dependency] = []
        for name, info in deps_dict.items():
            if "version" in info:
                deps.append(
                    Dependency(
                        name=name,
                        version=info["version"],
                        ecosystem=Ecosystem.NPM,
                        source_file=filename,
                        is_dev=info.get("dev", is_dev),
                    )
                )
            # Recurse into nested dependencies
            if "dependencies" in info:
                deps.extend(
                    self._parse_npm_lock_v1_deps(
                        info["dependencies"], filename, info.get("dev", is_dev)
                    )
                )
        return deps

    def _clean_npm_version(self, version: str) -> str | None:
        """Extract clean version from npm version specifier."""
        if not version:
            return None
        # Handle various npm version formats
        # Remove ^, ~, >=, <=, >, <, =
        clean = re.sub(r"^[\^~>=<]+", "", version.strip())
        # Handle ranges - take first version
        if " " in clean:
            clean = clean.split()[0]
        # Skip URLs, file paths, tags
        if clean.startswith(("http", "git", "file:", "/", ".")) or clean in (
            "latest",
            "next",
            "*",
        ):
            return None
        return clean if clean else None

    def _parse_pip_requirements(self, content: str, filename: str) -> list[Dependency]:
        """Parse pip requirements.txt format."""
        deps: list[Dependency] = []

        for line in content.splitlines():
            line = line.strip()
            # Skip comments and empty lines
            if not line or line.startswith("#") or line.startswith("-"):
                continue
            # Skip URLs and editable installs
            if line.startswith(("http", "git+", "-e", ".")):
                continue

            # Parse package==version, package>=version, etc.
            match = re.match(r"^([a-zA-Z0-9_\-\.]+)\s*([=<>!~]+)\s*([^\s;#]+)", line)
            if match:
                name, op, version = match.groups()
                # For ==, use exact version; for others, use as-is
                if op == "==":
                    deps.append(
                        Dependency(
                            name=name.lower(),
                            version=version,
                            ecosystem=Ecosystem.PYPI,
                            source_file=filename,
                        )
                    )
                elif op in (">=", "~="):
                    # Use minimum version for scanning
                    deps.append(
                        Dependency(
                            name=name.lower(),
                            version=version,
                            ecosystem=Ecosystem.PYPI,
                            source_file=filename,
                        )
                    )
            else:
                # Package without version specifier
                match = re.match(r"^([a-zA-Z0-9_\-\.]+)", line)
                if match:
                    # Skip packages without versions - can't scan them
                    pass

        return deps

    def _parse_pipfile(self, content: str, filename: str) -> list[Dependency]:
        """Parse Pipfile (TOML-like format)."""
        deps: list[Dependency] = []
        current_section = None
        is_dev = False

        for line in content.splitlines():
            line = line.strip()

            # Section headers
            if line == "[packages]":
                current_section = "packages"
                is_dev = False
            elif line == "[dev-packages]":
                current_section = "dev-packages"
                is_dev = True
            elif line.startswith("["):
                current_section = None
            elif current_section and "=" in line:
                # Parse package = "version" or package = {version = "x"}
                match = re.match(r'^([a-zA-Z0-9_\-]+)\s*=\s*"([^"]+)"', line)
                if match:
                    name, version = match.groups()
                    if version != "*":
                        deps.append(
                            Dependency(
                                name=name.lower(),
                                version=version.lstrip("~>=<"),
                                ecosystem=Ecosystem.PYPI,
                                source_file=filename,
                                is_dev=is_dev,
                            )
                        )

        return deps

    def _parse_pyproject_toml(self, content: str, filename: str) -> list[Dependency]:
        """Parse pyproject.toml for dependencies."""
        deps: list[Dependency] = []

        # Simple regex-based parsing for dependencies
        # Look for dependencies = [...] or [project.dependencies]
        in_deps_array = False

        for line in content.splitlines():
            stripped = line.strip()

            # Check for dependencies array start
            if re.match(r"^dependencies\s*=\s*\[", stripped):
                in_deps_array = True
                # Handle single-line array
                if "]" in stripped:
                    deps.extend(
                        self._parse_pep508_deps(
                            stripped.split("[", 1)[1].rsplit("]", 1)[0], filename
                        )
                    )
                    in_deps_array = False
                continue

            if in_deps_array:
                if "]" in stripped:
                    in_deps_array = False
                # Parse quoted dependency string
                match = re.match(r'^"([^"]+)"', stripped)
                if match:
                    dep = self._parse_pep508_dep(match.group(1), filename)
                    if dep:
                        deps.append(dep)

        return deps

    def _parse_pep508_deps(self, deps_str: str, filename: str) -> list[Dependency]:
        """Parse a comma-separated list of PEP 508 dependencies."""
        deps: list[Dependency] = []
        for part in deps_str.split(","):
            part = part.strip().strip('"').strip("'")
            if part:
                dep = self._parse_pep508_dep(part, filename)
                if dep:
                    deps.append(dep)
        return deps

    def _parse_pep508_dep(self, dep_str: str, filename: str) -> Dependency | None:
        """Parse a single PEP 508 dependency string."""
        # Match: package>=1.0.0 or package==1.0.0 etc.
        match = re.match(r"^([a-zA-Z0-9_\-\.]+)\s*([=<>!~]+)\s*([^\s;,\]]+)", dep_str)
        if match:
            name, _, version = match.groups()
            return Dependency(
                name=name.lower(),
                version=version,
                ecosystem=Ecosystem.PYPI,
                source_file=filename,
            )
        return None

    def _parse_go_mod(self, content: str, filename: str) -> list[Dependency]:
        """Parse go.mod file."""
        deps: list[Dependency] = []
        in_require = False

        for line in content.splitlines():
            line = line.strip()

            # Start of require block
            if line.startswith("require ("):
                in_require = True
                continue
            if line == ")" and in_require:
                in_require = False
                continue

            # Single require line
            if line.startswith("require ") and "(" not in line:
                parts = line[8:].strip().split()
                if len(parts) >= 2:
                    deps.append(
                        Dependency(
                            name=parts[0],
                            version=parts[1].lstrip("v"),
                            ecosystem=Ecosystem.GO,
                            source_file=filename,
                        )
                    )

            # Inside require block
            if in_require and line and not line.startswith("//"):
                parts = line.split()
                if len(parts) >= 2:
                    name = parts[0]
                    version = parts[1].lstrip("v")
                    # Skip indirect dependencies if marked
                    is_indirect = "// indirect" in line
                    deps.append(
                        Dependency(
                            name=name,
                            version=version,
                            ecosystem=Ecosystem.GO,
                            source_file=filename,
                            is_dev=is_indirect,
                        )
                    )

        return deps

    def _parse_go_sum(self, content: str, filename: str) -> list[Dependency]:
        """Parse go.sum file for exact versions."""
        deps: list[Dependency] = []
        seen: set[tuple[str, str]] = set()

        for line in content.splitlines():
            line = line.strip()
            if not line:
                continue

            parts = line.split()
            if len(parts) >= 2:
                name = parts[0]
                version = parts[1].split("/")[0].lstrip("v")  # Remove /go.mod suffix
                # Deduplicate
                key = (name, version)
                if key not in seen:
                    seen.add(key)
                    deps.append(
                        Dependency(
                            name=name,
                            version=version,
                            ecosystem=Ecosystem.GO,
                            source_file=filename,
                        )
                    )

        return deps

    def _parse_cargo_toml(self, content: str, filename: str) -> list[Dependency]:
        """Parse Cargo.toml for Rust dependencies."""
        deps: list[Dependency] = []
        current_section = None
        is_dev = False

        for line in content.splitlines():
            line = line.strip()

            # Section headers
            if line == "[dependencies]":
                current_section = "dependencies"
                is_dev = False
            elif line == "[dev-dependencies]":
                current_section = "dev-dependencies"
                is_dev = True
            elif line == "[build-dependencies]":
                current_section = "build-dependencies"
                is_dev = False
            elif line.startswith("["):
                current_section = None
            elif current_section and "=" in line and not line.startswith("#"):
                # Parse: name = "version" or name = { version = "x" }
                match = re.match(r'^([a-zA-Z0-9_\-]+)\s*=\s*"([^"]+)"', line)
                if match:
                    name, version = match.groups()
                    deps.append(
                        Dependency(
                            name=name,
                            version=version.lstrip("^~>=<"),
                            ecosystem=Ecosystem.CARGO,
                            source_file=filename,
                            is_dev=is_dev,
                        )
                    )
                else:
                    # Handle table format: name = { version = "x", ... }
                    match = re.match(
                        r'^([a-zA-Z0-9_\-]+)\s*=\s*\{.*version\s*=\s*"([^"]+)"',
                        line,
                    )
                    if match:
                        name, version = match.groups()
                        deps.append(
                            Dependency(
                                name=name,
                                version=version.lstrip("^~>=<"),
                                ecosystem=Ecosystem.CARGO,
                                source_file=filename,
                                is_dev=is_dev,
                            )
                        )

        return deps

    def _parse_cargo_lock(self, content: str, filename: str) -> list[Dependency]:
        """Parse Cargo.lock for exact versions."""
        deps: list[Dependency] = []
        current_name = None
        current_version = None

        for line in content.splitlines():
            line = line.strip()

            if line == "[[package]]":
                if current_name and current_version:
                    deps.append(
                        Dependency(
                            name=current_name,
                            version=current_version,
                            ecosystem=Ecosystem.CARGO,
                            source_file=filename,
                        )
                    )
                current_name = None
                current_version = None

            match = re.match(r'^name\s*=\s*"([^"]+)"', line)
            if match:
                current_name = match.group(1)

            match = re.match(r'^version\s*=\s*"([^"]+)"', line)
            if match:
                current_version = match.group(1)

        # Don't forget the last package
        if current_name and current_version:
            deps.append(
                Dependency(
                    name=current_name,
                    version=current_version,
                    ecosystem=Ecosystem.CARGO,
                    source_file=filename,
                )
            )

        return deps

    def _parse_maven_pom(self, content: str, filename: str) -> list[Dependency]:
        """Parse Maven pom.xml for dependencies."""
        deps: list[Dependency] = []

        try:
            # Handle XML namespaces
            content_clean = re.sub(r'\sxmlns[^"]*"[^"]*"', "", content)
            root = safe_fromstring(content_clean)
        except ET.ParseError:
            return deps

        # Find all dependency elements
        for dep_elem in root.iter("dependency"):
            group_id = dep_elem.find("groupId")
            artifact_id = dep_elem.find("artifactId")
            version = dep_elem.find("version")
            scope = dep_elem.find("scope")

            if group_id is not None and artifact_id is not None and version is not None:
                name = f"{group_id.text}:{artifact_id.text}"
                ver = version.text or ""
                # Skip property references like ${project.version}
                if not ver.startswith("${"):
                    deps.append(
                        Dependency(
                            name=name,
                            version=ver,
                            ecosystem=Ecosystem.MAVEN,
                            source_file=filename,
                            is_dev=scope is not None and scope.text == "test",
                        )
                    )

        return deps

    def _parse_gemfile(self, content: str, filename: str) -> list[Dependency]:
        """Parse Ruby Gemfile."""
        deps: list[Dependency] = []

        for line in content.splitlines():
            line = line.strip()
            if not line or line.startswith("#"):
                continue

            # Parse: gem 'name', '~> 1.0'
            match = re.match(r"gem\s+['\"]([^'\"]+)['\"](?:,\s*['\"]([^'\"]+)['\"])?", line)
            if match:
                name = match.group(1)
                version = match.group(2)
                if version:
                    # Clean version constraint
                    clean_ver = re.sub(r"^[~>=<]+\s*", "", version)
                    deps.append(
                        Dependency(
                            name=name,
                            version=clean_ver,
                            ecosystem=Ecosystem.RUBYGEMS,
                            source_file=filename,
                        )
                    )

        return deps

    def _parse_gemfile_lock(self, content: str, filename: str) -> list[Dependency]:
        """Parse Gemfile.lock for exact versions."""
        deps: list[Dependency] = []
        in_specs = False

        for line in content.splitlines():
            # Look for GEM specs section
            if line.strip() == "specs:":
                in_specs = True
                continue
            if line and not line.startswith(" ") and in_specs:
                in_specs = False

            if in_specs:
                # Match: "    gem_name (version)"
                match = re.match(r"^\s{4}([a-zA-Z0-9_\-]+)\s+\(([^)]+)\)", line)
                if match:
                    name, version = match.groups()
                    deps.append(
                        Dependency(
                            name=name,
                            version=version,
                            ecosystem=Ecosystem.RUBYGEMS,
                            source_file=filename,
                        )
                    )

        return deps

    def _parse_composer_json(self, content: str, filename: str) -> list[Dependency]:
        """Parse PHP composer.json."""
        deps: list[Dependency] = []

        try:
            data = json.loads(content)
        except json.JSONDecodeError:
            return deps

        for name, version in data.get("require", {}).items():
            if name == "php" or name.startswith("ext-"):
                continue  # Skip PHP version and extensions
            clean_ver = re.sub(r"^[\^~>=<|]+", "", version)
            if clean_ver and clean_ver != "*":
                deps.append(
                    Dependency(
                        name=name,
                        version=clean_ver.split("|")[0].strip(),  # Take first of OR
                        ecosystem=Ecosystem.PACKAGIST,
                        source_file=filename,
                        is_dev=False,
                    )
                )

        for name, version in data.get("require-dev", {}).items():
            if name == "php" or name.startswith("ext-"):
                continue
            clean_ver = re.sub(r"^[\^~>=<|]+", "", version)
            if clean_ver and clean_ver != "*":
                deps.append(
                    Dependency(
                        name=name,
                        version=clean_ver.split("|")[0].strip(),
                        ecosystem=Ecosystem.PACKAGIST,
                        source_file=filename,
                        is_dev=True,
                    )
                )

        return deps

    def _parse_composer_lock(self, content: str, filename: str) -> list[Dependency]:
        """Parse composer.lock for exact versions."""
        deps: list[Dependency] = []

        try:
            data = json.loads(content)
        except json.JSONDecodeError:
            return deps

        for pkg in data.get("packages", []):
            if "name" in pkg and "version" in pkg:
                deps.append(
                    Dependency(
                        name=pkg["name"],
                        version=pkg["version"].lstrip("v"),
                        ecosystem=Ecosystem.PACKAGIST,
                        source_file=filename,
                        is_dev=False,
                    )
                )

        for pkg in data.get("packages-dev", []):
            if "name" in pkg and "version" in pkg:
                deps.append(
                    Dependency(
                        name=pkg["name"],
                        version=pkg["version"].lstrip("v"),
                        ecosystem=Ecosystem.PACKAGIST,
                        source_file=filename,
                        is_dev=True,
                    )
                )

        return deps

    def _parse_nuget_packages_config(self, content: str, filename: str) -> list[Dependency]:
        """Parse NuGet packages.config."""
        deps: list[Dependency] = []

        try:
            root = safe_fromstring(content)
        except ET.ParseError:
            return deps

        for pkg in root.findall(".//package"):
            pkg_id = pkg.get("id")
            version = pkg.get("version")
            if pkg_id and version:
                deps.append(
                    Dependency(
                        name=pkg_id,
                        version=version,
                        ecosystem=Ecosystem.NUGET,
                        source_file=filename,
                    )
                )

        return deps

    def _parse_csproj(self, content: str, filename: str) -> list[Dependency]:
        """Parse .NET .csproj file for PackageReference elements."""
        deps: list[Dependency] = []

        try:
            root = safe_fromstring(content)
        except ET.ParseError:
            return deps

        for pkg_ref in root.findall(".//PackageReference"):
            name = pkg_ref.get("Include")
            version = pkg_ref.get("Version")
            if name and version:
                deps.append(
                    Dependency(
                        name=name,
                        version=version,
                        ecosystem=Ecosystem.NUGET,
                        source_file=filename,
                    )
                )

        return deps


def parse_manifest(content: str, filename: str) -> list[Dependency]:
    """Convenience function to parse a manifest file.

    Args:
        content: The manifest file content
        filename: The filename (used to determine parser)

    Returns:
        List of parsed dependencies
    """
    parser = ManifestParser()
    return parser.parse(content, filename)
