# Copyright (c) 2026 MergeGuide, Inc. All rights reserved.
# SPDX-License-Identifier: Proprietary
"""Specialized scanner integration for PR Gate.

Wires the five specialized scanning modules (vulnerability, CI/CD, IaC,
license, build) into the PR evaluation workflow. Each scanner is invoked
only when the PR diff contains files relevant to that scanner.

Content fetching strategy:
- Vulnerability & License scanners: Need full manifest file content
  (fetched via GitHub Contents API) because OSV and license detection
  require complete dependency lists.
- CI/CD & IaC scanners: Work on diff content (added lines) since they
  use pattern matching that works on partial content.
- Build scanner: Works on diff content for build config files.

All scanner invocations are non-fatal: failures are logged as warnings
and do not block the PR policy check.
"""

from __future__ import annotations

import asyncio
import logging
from dataclasses import dataclass, field
from fnmatch import fnmatch
from pathlib import Path
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    from mergeguide.engine.parser import DiffFile
    from mergeguide.github.api import GitHubAPI

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# File routing patterns
# ---------------------------------------------------------------------------

MANIFEST_PATTERNS = [
    "package.json",
    "package-lock.json",
    "requirements.txt",
    "requirements*.txt",
    "Pipfile",
    "pyproject.toml",
    "go.mod",
    "go.sum",
    "Cargo.toml",
    "Cargo.lock",
    "pom.xml",
    "Gemfile",
    "Gemfile.lock",
    "composer.json",
    "composer.lock",
    "*.csproj",
    "packages.config",
]

IAC_PATTERNS = [
    "*.tf",
    "*.tf.json",
    "template.yaml",
    "template.yml",
    "template.json",
    "cloudformation.yaml",
    "cloudformation.yml",
    "Chart.yaml",
    "Chart.yml",
    "values.yaml",
    "values.yml",
    "playbook.yaml",
    "playbook.yml",
    "Pulumi.yaml",
    "Pulumi.yml",
]

CICD_PATTERNS = [
    ".github/workflows/*.yml",
    ".github/workflows/*.yaml",
    ".gitlab-ci.yml",
    ".gitlab-ci.yaml",
    "Jenkinsfile",
    ".circleci/config.yml",
    ".circleci/config.yaml",
    "azure-pipelines.yml",
    "azure-pipelines.yaml",
    "bitbucket-pipelines.yml",
    "bitbucket-pipelines.yaml",
    ".travis.yml",
    ".travis.yaml",
    "buildkite.yml",
    "buildkite.yaml",
    ".buildkite/pipeline.yml",
]

BUILD_PATTERNS = [
    "Dockerfile",
    "Dockerfile.*",
    "*.dockerfile",
    "docker-compose.yml",
    "docker-compose.yaml",
    "compose.yml",
    "compose.yaml",
    "Makefile",
    "makefile",
    "GNUmakefile",
]


def _matches_any(file_path: str, patterns: list[str]) -> bool:
    """Check if a file path matches any of the given glob patterns.

    Matches against both the full path and the basename to handle
    patterns like '*.tf' (basename) and '.github/workflows/*.yml' (path).
    """
    basename = Path(file_path).name
    for pattern in patterns:
        if fnmatch(basename, pattern) or fnmatch(file_path, pattern):
            return True
    return False


def get_relevant_files(changed_paths: list[str]) -> dict[str, list[str]]:
    """Route changed file paths to the scanners that should process them.

    Args:
        changed_paths: List of file paths changed in the PR.

    Returns:
        Dict mapping scanner name to list of relevant file paths.
        Keys: "vulnerability", "license", "iac", "cicd", "build".
        Only includes keys where at least one file matches.
    """
    result: dict[str, list[str]] = {}

    for path in changed_paths:
        if _matches_any(path, MANIFEST_PATTERNS):
            result.setdefault("vulnerability", []).append(path)
            result.setdefault("license", []).append(path)

        if _matches_any(path, IAC_PATTERNS):
            result.setdefault("iac", []).append(path)

        if _matches_any(path, CICD_PATTERNS):
            result.setdefault("cicd", []).append(path)

        if _matches_any(path, BUILD_PATTERNS):
            result.setdefault("build", []).append(path)

        # Also check IaC for YAML/YML files that might be K8s manifests
        # (detected by content, handled in scanner)
        basename = Path(path).name
        if basename.endswith((".yaml", ".yml")) and "iac" not in result.get(path, []):
            # IaC scanner will auto-detect K8s from content
            if not _matches_any(path, CICD_PATTERNS):
                result.setdefault("iac", []).append(path)

    return result


def _extract_added_content(diff_file: DiffFile) -> str:
    """Extract added-line content from a parsed DiffFile.

    Reconstructs a view of the file containing only added lines,
    suitable for pattern-based scanners (IaC, CI/CD, build).
    """
    lines = []
    for _line_no, content in diff_file.added_lines():
        lines.append(content)
    return "\n".join(lines)


# ---------------------------------------------------------------------------
# Scanner result dataclass
# ---------------------------------------------------------------------------


@dataclass
class SpecializedScannerResults:
    """Combined results from all specialized scanners.

    Each scanner's results are stored as optional dicts (serializable).
    A scanner that was not invoked (no relevant files) has None.
    A scanner that failed has an entry in scanner_errors.
    """

    vulnerability: dict[str, Any] | None = None
    cicd: dict[str, Any] | None = None
    iac: dict[str, Any] | None = None
    license: dict[str, Any] | None = None
    build: dict[str, Any] | None = None
    scanner_errors: dict[str, str] = field(default_factory=dict)

    @property
    def has_findings(self) -> bool:
        """True if any scanner produced findings."""
        for result in [self.vulnerability, self.cicd, self.iac, self.license, self.build]:
            if result and result.get("total_issues", result.get("total_vulnerabilities", 0)) > 0:
                return True
        return False


# ---------------------------------------------------------------------------
# Scanner runner
# ---------------------------------------------------------------------------


async def run_specialized_scanners(
    *,
    diff_files: list[DiffFile],
    github_api: GitHubAPI,
    installation_id: int,
    owner: str,
    repo: str,
    head_sha: str,
    repository: str = "",
) -> SpecializedScannerResults:
    """Run all relevant specialized scanners for a PR.

    Parses the diff to identify relevant file types, fetches full content
    for scanners that need it (vulnerability, license), and runs each
    scanner. All scanner failures are non-fatal.

    Args:
        diff_files: Parsed DiffFile objects from the PR diff.
        github_api: GitHubAPI client for fetching file content.
        installation_id: GitHub App installation ID.
        owner: Repository owner.
        repo: Repository name.
        head_sha: Head commit SHA for fetching file content.
        repository: Full repository name (owner/repo) for scan results.

    Returns:
        SpecializedScannerResults with all scanner outputs.
    """
    results = SpecializedScannerResults()

    if not repository:
        repository = f"{owner}/{repo}"

    # Build path list and path-to-DiffFile map
    changed_paths = [df.path for df in diff_files if not df.is_binary and df.path]
    diff_file_map = {df.path: df for df in diff_files if df.path}

    if not changed_paths:
        return results

    relevant = get_relevant_files(changed_paths)
    if not relevant:
        return results

    logger.info(
        "Specialized scanners: relevant files found — %s",
        {k: len(v) for k, v in relevant.items()},
    )

    # Fetch full file content for manifest-based scanners
    manifest_content: dict[str, str] = {}
    if "vulnerability" in relevant or "license" in relevant:
        manifest_paths = set(relevant.get("vulnerability", []) + relevant.get("license", []))
        manifest_content = await _fetch_file_contents(
            github_api=github_api,
            installation_id=installation_id,
            owner=owner,
            repo=repo,
            ref=head_sha,
            paths=list(manifest_paths),
        )

    # Run scanners concurrently
    tasks = []

    if "vulnerability" in relevant:
        tasks.append(_run_vulnerability_scanner(manifest_content, repository, head_sha, results))

    if "license" in relevant:
        tasks.append(_run_license_scanner(manifest_content, repository, head_sha, results))

    if "iac" in relevant:
        iac_files = _build_content_map(relevant["iac"], diff_file_map)
        tasks.append(_run_iac_scanner(iac_files, repository, head_sha, results))

    if "cicd" in relevant:
        cicd_files = _build_content_map(relevant["cicd"], diff_file_map)
        tasks.append(_run_cicd_scanner(cicd_files, repository, head_sha, results))

    if "build" in relevant:
        build_files = _build_content_map(relevant["build"], diff_file_map)
        tasks.append(_run_build_scanner(build_files, repository, head_sha, results))

    if tasks:
        await asyncio.gather(*tasks)

    return results


def _build_content_map(paths: list[str], diff_file_map: dict[str, DiffFile]) -> dict[str, str]:
    """Build a {path: content} map from diff files using added-line content."""
    content_map: dict[str, str] = {}
    for path in paths:
        df = diff_file_map.get(path)
        if df:
            content = _extract_added_content(df)
            if content:
                content_map[path] = content
    return content_map


async def _fetch_file_contents(
    *,
    github_api: GitHubAPI,
    installation_id: int,
    owner: str,
    repo: str,
    ref: str,
    paths: list[str],
) -> dict[str, str]:
    """Fetch full file content from GitHub for multiple paths.

    Fetches files concurrently with a 10-second timeout per file.

    Returns:
        Dict mapping path to file content. Paths that failed are omitted.
    """
    result: dict[str, str] = {}

    async def _fetch_one(path: str) -> None:
        try:
            content = await asyncio.wait_for(
                github_api.get_file_content(
                    installation_id=installation_id,
                    owner=owner,
                    repo=repo,
                    path=path,
                    ref=ref,
                ),
                timeout=10.0,
            )
            if content:
                result[path] = content
        except TimeoutError:
            logger.warning("Timeout fetching file content for %s", path)
        except Exception as e:
            logger.warning("Failed to fetch %s: %s", path, e)

    await asyncio.gather(*[_fetch_one(p) for p in paths])
    return result


# ---------------------------------------------------------------------------
# Individual scanner runners (all non-fatal)
# ---------------------------------------------------------------------------


async def _run_vulnerability_scanner(
    files: dict[str, str],
    repository: str,
    ref: str,
    results: SpecializedScannerResults,
) -> None:
    """Run the vulnerability scanner on manifest files."""
    if not files:
        return
    try:
        from mergeguide.scanning.vulnerability_scanner import VulnerabilityScanner

        scanner = VulnerabilityScanner()
        scan_result = scanner.scan_files(files, repository=repository, ref=ref)
        results.vulnerability = scan_result.to_dict()
        logger.info(
            "Vulnerability scan: %d deps, %d vulns found",
            scan_result.total_dependencies,
            scan_result.total_vulnerabilities,
        )
    except Exception as e:
        logger.warning("Vulnerability scanner failed: %s", e)
        results.scanner_errors["vulnerability"] = str(e)


async def _run_license_scanner(
    files: dict[str, str],
    repository: str,
    ref: str,
    results: SpecializedScannerResults,
) -> None:
    """Run the license scanner on manifest files."""
    if not files:
        return
    try:
        from mergeguide.scanning.license_scanner import LicenseScanner

        scanner = LicenseScanner()
        scan_result = scanner.scan_files(files, repository=repository, ref=ref)
        results.license = scan_result.to_dict()
        logger.info(
            "License scan: %d deps, %d incompatible",
            scan_result.total_dependencies,
            scan_result.incompatible_count,
        )
    except Exception as e:
        logger.warning("License scanner failed: %s", e)
        results.scanner_errors["license"] = str(e)


async def _run_iac_scanner(
    files: dict[str, str],
    repository: str,
    ref: str,
    results: SpecializedScannerResults,
) -> None:
    """Run the IaC scanner on infrastructure files."""
    if not files:
        return
    try:
        from mergeguide.scanning.iac_scanner import IaCScanner

        scanner = IaCScanner()
        scan_result = scanner.scan_files(files, repository=repository, ref=ref)
        results.iac = scan_result.to_dict()
        logger.info(
            "IaC scan: %d files, %d issues found",
            len(scan_result.files_scanned),
            scan_result.total_issues,
        )
    except Exception as e:
        logger.warning("IaC scanner failed: %s", e)
        results.scanner_errors["iac"] = str(e)


async def _run_cicd_scanner(
    files: dict[str, str],
    repository: str,
    ref: str,
    results: SpecializedScannerResults,
) -> None:
    """Run the CI/CD scanner on pipeline configuration files."""
    if not files:
        return
    try:
        from mergeguide.scanning.cicd_scanner import CICDScanner

        scanner = CICDScanner()
        scan_result = scanner.scan_files(files, repository=repository, ref=ref)
        results.cicd = scan_result.to_dict()
        logger.info(
            "CI/CD scan: %d files, %d issues found",
            len(scan_result.files_scanned),
            scan_result.total_issues,
        )
    except Exception as e:
        logger.warning("CI/CD scanner failed: %s", e)
        results.scanner_errors["cicd"] = str(e)


async def _run_build_scanner(
    files: dict[str, str],
    repository: str,
    ref: str,
    results: SpecializedScannerResults,
) -> None:
    """Run the build scanner on build configuration files."""
    if not files:
        return
    try:
        from mergeguide.scanning.build_scanner import BuildScanner

        scanner = BuildScanner()
        scan_result = scanner.scan_files(files, repository=repository, ref=ref)
        results.build = scan_result.to_dict()
        logger.info(
            "Build scan: %d files, %d issues found",
            len(scan_result.files_scanned),
            scan_result.total_issues,
        )
    except Exception as e:
        logger.warning("Build scanner failed: %s", e)
        results.scanner_errors["build"] = str(e)
