# Copyright (c) 2026 MergeGuide, Inc. All rights reserved.
# SPDX-License-Identifier: Proprietary
"""Absence-of-controls scanner.

Detects when web frameworks are in use but standard security controls
are missing. Produces repository-level findings that are structurally
distinct from line-level Violations.

Architecture: Loads framework profiles from YAML, compiles regex patterns
at init time (fail-fast on re.error), and checks file contents for
evidence of expected security controls.
"""

from __future__ import annotations

import logging
import re
import uuid
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from typing import Any

logger = logging.getLogger(__name__)

# Tier rank mapping for gating controls by subscription level.
TIER_RANK: dict[str, int] = {
    "free": 0,
    "pro": 1,
    "team": 2,
    "business": 3,
    "enterprise": 4,
}


@dataclass
class Evidence:
    """Evidence of a security control's presence or absence.

    Each evidence level (dependency, import_statement, usage) is optional
    per-control. ``None`` means the check was not defined for this control
    and is therefore not required. ``True``/``False`` indicates the check
    was performed and whether evidence was found.
    """

    dependency: bool | None = None
    import_statement: bool | None = None
    usage: bool | None = None
    present: bool = False

    def to_dict(self) -> dict[str, Any]:
        return {
            "dependency": self.dependency,
            "import_statement": self.import_statement,
            "usage": self.usage,
            "present": self.present,
        }


@dataclass
class AbsenceFinding:
    """A repository-level finding for a missing security control."""

    framework: str
    control_id: str
    control_name: str
    severity: str
    message: str
    remediation: str
    evidence: Evidence
    finding_type: str = "absence"

    def to_dict(self) -> dict[str, Any]:
        return {
            "framework": self.framework,
            "control_id": self.control_id,
            "control_name": self.control_name,
            "severity": self.severity,
            "message": self.message,
            "remediation": self.remediation,
            "evidence": self.evidence.to_dict(),
            "finding_type": self.finding_type,
        }


@dataclass
class AbsenceScanResult:
    """Result of an absence-of-controls scan."""

    scan_id: str
    timestamp: str
    frameworks_detected: list[str] = field(default_factory=list)
    findings: list[AbsenceFinding] = field(default_factory=list)
    errors: list[str] = field(default_factory=list)

    @property
    def absence_passed(self) -> bool:
        """True if no warning/error-level absence findings.

        Info-level findings do NOT cause failure. Separate from
        Violation-level ``passed`` boolean.
        """
        return not any(f.severity in ("warning", "error") for f in self.findings)

    def to_dict(self) -> dict[str, Any]:
        return {
            "scan_id": self.scan_id,
            "timestamp": self.timestamp,
            "frameworks_detected": self.frameworks_detected,
            "findings": [f.to_dict() for f in self.findings],
            "errors": self.errors,
            "absence_passed": self.absence_passed,
        }


# ---------------------------------------------------------------------------
# Internal profile representation (compiled from YAML)
# ---------------------------------------------------------------------------


@dataclass
class _CompiledControl:
    """A control with pre-compiled regex patterns."""

    id: str
    name: str
    description: str
    severity: str
    min_tier: str
    message: str
    remediation: str
    # Dependency evidence — mutually exclusive, both optional.
    dependency: str | None = None
    dependency_any: list[str] | None = None
    # Compiled regex patterns — None means check is not defined.
    import_pattern: re.Pattern[str] | None = None
    usage_pattern: re.Pattern[str] | None = None


@dataclass
class _FrameworkProfile:
    """A framework profile with compiled detection patterns."""

    framework: str
    display_name: str
    language: str
    manifest: str | None
    manifest_key: str | None
    detection_import: re.Pattern[str]
    controls: list[_CompiledControl] = field(default_factory=list)


class AbsenceScanner:
    """Scans a repository for missing security controls.

    Loads YAML framework profiles at init time, compiles all regex
    patterns, and provides ``scan_directory`` / ``scan_files`` entry
    points that mirror the existing scanner pattern.
    """

    def __init__(self, profiles_dir: Path | str | None = None) -> None:
        if profiles_dir is None:
            profiles_dir = Path(__file__).parent / "framework_profiles"
        self.profiles_dir = Path(profiles_dir)
        self.profiles: dict[str, _FrameworkProfile] = {}
        self._load_profiles()

    # ------------------------------------------------------------------
    # Profile loading
    # ------------------------------------------------------------------

    def _load_profiles(self) -> None:
        """Load and compile all YAML profiles from the profiles directory."""
        try:
            import yaml
        except ImportError:
            logger.warning(
                "PyYAML not installed — absence scanning disabled. Install with: pip install pyyaml"
            )
            return

        if not self.profiles_dir.is_dir():
            logger.warning("Profiles directory not found: %s", self.profiles_dir)
            return

        for yaml_path in sorted(self.profiles_dir.glob("*.yaml")):
            try:
                self._load_profile(yaml_path, yaml)
            except Exception as exc:
                logger.warning(
                    "Failed to load profile %s: %s — skipping",
                    yaml_path.name,
                    exc,
                )

    def _load_profile(self, path: Path, yaml: Any) -> None:
        """Parse a single YAML profile and compile its regex patterns."""
        with open(path, encoding="utf-8") as fh:
            data = yaml.safe_load(fh)

        if not isinstance(data, dict):
            raise ValueError(f"Profile {path.name} is not a YAML mapping")

        schema_version = data.get("schema_version", "1.0")
        if schema_version != "1.0":
            raise ValueError(f"Unsupported schema_version '{schema_version}' in {path.name}")

        # Compile detection import regex — fatal on error.
        detection_import = re.compile(data["detection_import"])

        controls: list[_CompiledControl] = []
        for ctrl_data in data.get("controls", []):
            # Validate dependency / dependency_any mutual exclusivity.
            has_dep = "dependency" in ctrl_data
            has_dep_any = "dependency_any" in ctrl_data
            if has_dep and has_dep_any:
                raise ValueError(
                    f"Control {ctrl_data.get('id')}: dependency and "
                    f"dependency_any are mutually exclusive"
                )

            import_pat = None
            if ctrl_data.get("import_pattern"):
                import_pat = re.compile(ctrl_data["import_pattern"])

            usage_pat = None
            if ctrl_data.get("usage_pattern"):
                usage_pat = re.compile(ctrl_data["usage_pattern"])

            controls.append(
                _CompiledControl(
                    id=ctrl_data["id"],
                    name=ctrl_data["name"],
                    description=ctrl_data.get("description", ""),
                    severity=ctrl_data.get("severity", "warning"),
                    min_tier=ctrl_data.get("min_tier", "free"),
                    message=ctrl_data.get("message", ""),
                    remediation=ctrl_data.get("remediation", ""),
                    dependency=ctrl_data.get("dependency"),
                    dependency_any=ctrl_data.get("dependency_any"),
                    import_pattern=import_pat,
                    usage_pattern=usage_pat,
                )
            )

        profile = _FrameworkProfile(
            framework=data["framework"],
            display_name=str(data.get("display_name", data["framework"])),
            language=data.get("language", ""),
            manifest=data.get("manifest"),
            manifest_key=data.get("manifest_key"),
            detection_import=detection_import,
            controls=controls,
        )
        self.profiles[profile.framework] = profile
        logger.debug("Loaded profile: %s (%d controls)", profile.framework, len(controls))

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def scan_directory(self, directory: Path | str, org_tier: str = "free") -> AbsenceScanResult:
        """Scan a directory for missing security controls.

        Collects all relevant files and delegates to ``scan_files``.
        """
        directory = Path(directory)
        if not directory.is_dir():
            return self._empty_result(errors=[f"Directory not found: {directory}"])

        files: list[Path] = []
        for p in directory.rglob("*"):
            if p.is_file() and not any(
                part.startswith(".") or part == "node_modules" for part in p.parts
            ):
                files.append(p)

        return self.scan_files(files, org_tier)

    def scan_files(self, files: list[Path], org_tier: str = "free") -> AbsenceScanResult:
        """Scan a list of files for missing security controls.

        Takes ``list[Path]`` per the IAA-approved signature (CRITICAL-3).
        The scanner reads file contents internally.
        """
        scan_id = str(uuid.uuid4())
        timestamp = datetime.now(UTC).isoformat()
        findings: list[AbsenceFinding] = []
        frameworks_detected: list[str] = []
        errors: list[str] = []

        if not files:
            return AbsenceScanResult(
                scan_id=scan_id,
                timestamp=timestamp,
                frameworks_detected=[],
                findings=[],
                errors=[],
            )

        # Read all file contents once.
        file_contents: dict[str, str] = {}
        for fpath in files:
            try:
                file_contents[str(fpath)] = fpath.read_text(encoding="utf-8", errors="replace")
            except Exception as exc:
                errors.append(f"Could not read {fpath}: {exc}")

        for profile in self.profiles.values():
            if not self._detect_framework(profile, file_contents):
                continue

            frameworks_detected.append(profile.framework)
            filtered_controls = self._apply_tier_filter(profile.controls, org_tier)

            for control in filtered_controls:
                evidence = self._check_control(control, file_contents)
                if not evidence.present:
                    findings.append(
                        AbsenceFinding(
                            framework=profile.framework,
                            control_id=control.id,
                            control_name=control.name,
                            severity=control.severity,
                            message=control.message,
                            remediation=control.remediation,
                            evidence=evidence,
                        )
                    )

        return AbsenceScanResult(
            scan_id=scan_id,
            timestamp=timestamp,
            frameworks_detected=frameworks_detected,
            findings=findings,
            errors=errors,
        )

    # ------------------------------------------------------------------
    # Internal detection logic
    # ------------------------------------------------------------------

    def _detect_framework(self, profile: _FrameworkProfile, file_contents: dict[str, str]) -> bool:
        """Detect whether a framework is used in the scanned files.

        Step 1: Check for manifest file containing the manifest_key.
        Step 2: Check source files for detection_import pattern.
        Either match is sufficient.
        """
        # Step 1: Manifest check.
        if profile.manifest and profile.manifest_key:
            for fpath, content in file_contents.items():
                if Path(fpath).name == profile.manifest:
                    if profile.manifest_key in content:
                        return True

        # Step 2: Source import check.
        for content in file_contents.values():
            if profile.detection_import.search(content):
                return True

        return False

    def _check_control(self, control: _CompiledControl, file_contents: dict[str, str]) -> Evidence:
        """Check whether a security control is present in the codebase.

        Each evidence level (dependency, import, usage) is optional
        per-control. Only defined levels must be satisfied for the
        control to be considered present.
        """
        dep_found: bool | None = None
        import_found: bool | None = None
        usage_found: bool | None = None

        # --- Dependency check ---
        if control.dependency is not None:
            dep_found = False
            for _fpath, content in file_contents.items():
                if control.dependency in content:
                    dep_found = True
                    break
        elif control.dependency_any is not None:
            dep_found = False
            for _fpath, content in file_contents.items():
                for dep_name in control.dependency_any:
                    if dep_name in content:
                        dep_found = True
                        break
                if dep_found:
                    break

        # --- Import check ---
        if control.import_pattern is not None:
            import_found = False
            for content in file_contents.values():
                if control.import_pattern.search(content):
                    import_found = True
                    break

        # --- Usage check ---
        if control.usage_pattern is not None:
            usage_found = False
            for content in file_contents.values():
                if control.usage_pattern.search(content):
                    usage_found = True
                    break

        # --- Determine overall presence ---
        # Control is "present" if ALL defined evidence levels are satisfied.
        # Undefined levels (None) are skipped.
        defined_checks = [v for v in [dep_found, import_found, usage_found] if v is not None]
        present = len(defined_checks) > 0 and all(defined_checks)

        return Evidence(
            dependency=dep_found,
            import_statement=import_found,
            usage=usage_found,
            present=present,
        )

    def _apply_tier_filter(
        self, controls: list[_CompiledControl], org_tier: str
    ) -> list[_CompiledControl]:
        """Filter controls to only those visible at the given tier."""
        org_rank = TIER_RANK.get(org_tier, 0)
        return [c for c in controls if TIER_RANK.get(c.min_tier, 0) <= org_rank]

    # ------------------------------------------------------------------
    # Helpers
    # ------------------------------------------------------------------

    def _empty_result(self, errors: list[str] | None = None) -> AbsenceScanResult:
        return AbsenceScanResult(
            scan_id=str(uuid.uuid4()),
            timestamp=datetime.now(UTC).isoformat(),
            frameworks_detected=[],
            findings=[],
            errors=errors or [],
        )
