"""
Security validation for automations.

This module provides security checks for automation code,
identifying potential vulnerabilities or policy violations.
"""

import ast
import fnmatch
import os
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any


@dataclass
class SecurityIssue:
    """A security issue found during validation."""

    severity: str  # "critical", "high", "medium", "low"
    code: str
    message: str
    file: str
    line: int | None = None

    def to_dict(self) -> dict[str, Any]:
        """Convert to dictionary."""
        return {
            "severity": self.severity,
            "code": self.code,
            "message": self.message,
            "file": self.file,
            "line": self.line,
        }


@dataclass
class SecurityResult:
    """Result of security validation."""

    passed: bool
    issues: list[SecurityIssue]
    score: float  # 0-100, higher is better

    def to_dict(self) -> dict[str, Any]:
        """Convert to dictionary."""
        return {
            "passed": self.passed,
            "issues": [i.to_dict() for i in self.issues],
            "score": self.score,
        }


def _is_test_file(name: str, path: str = "") -> bool:
    """Check if a file is a test file using strict patterns.

    A file is a test file if:
    - Its basename starts with ``test_`` or ends with ``_test.py``
    - Its basename is ``conftest.py``
    - It lives under a ``tests/`` or ``test/`` directory segment

    This avoids false positives on names like ``latest.py`` that merely
    *contain* the substring ``test``.
    """
    lower = name.lower()
    if (
        lower.startswith("test_")
        or lower.endswith("_test.py")
        or lower == "conftest.py"
    ):
        return True
    # Check full path for tests/ directory segments
    full = path.lower() if path else lower
    parts = full.replace("\\", "/").split("/")
    return "tests" in parts or "test" in parts


class SecurityValidator:
    """
    Security validator for automation code.

    This validator checks Python code for security issues such as:
    - Dangerous imports (os, subprocess, etc.)
    - Disallowed third-party imports
    - Network access attempts
    - File system access
    - Code execution patterns (eval, exec)
    - Credential exposure risks
    - Potential secrets in code

    Example:
        validator = SecurityValidator()
        result = validator.validate_directory("./my-automation")

        if not result.passed:
            for issue in result.issues:
                print(f"[{issue.severity}] {issue.message}")

    Or validate imports specifically:
        result = validator.validate_imports("./my-automation/main.py")

    Or check for dangerous functions:
        result = validator.check_dangerous_functions("./my-automation")

    Or scan for secrets:
        result = validator.scan_for_secrets("./my-automation")
    """

    # Allowed imports - only these modules/packages can be imported
    # NOTE: This list MUST be kept in sync with:
    #   - docker/sandbox/entrypoint.py (ALLOWED_IMPORTS)
    #   - apps/ai-engine-v2/review/validators.py (ALLOWED_IMPORTS)
    #   - apps/ai-engine-v2/tasks/submission_review.py (ALLOWED_IMPORTS)
    #   - docker/sandbox/requirements.txt
    #   - docs/developers/best-practices/security.md
    ALLOWED_IMPORTS = {
        # Standard library
        "abc",
        "asyncio",
        "base64",
        "collections",
        "contextlib",
        "copy",
        "dataclasses",
        "datetime",
        "decimal",
        "enum",
        "fractions",
        "functools",
        "hashlib",
        "heapq",
        "html",
        "inspect",
        "io",
        "itertools",
        "json",
        "logging",
        "math",
        "operator",
        "pathlib",
        "random",
        "re",
        "secrets",
        "statistics",
        "string",
        "textwrap",
        "time",
        "typing",
        "typing_extensions",
        "unicodedata",
        "urllib.parse",
        "uuid",
        "warnings",
        "zlib",
        # ToRivers SDK
        "torivers_sdk",
        # Approved third-party packages
        "beautifulsoup4",
        "bs4",
        "dateutil",
        "jinja2",
        "langchain",
        "langchain_anthropic",
        "langchain_core",
        "langchain_openai",
        "langgraph",
        "lxml",
        "matplotlib",
        "numpy",
        "openpyxl",
        "pandas",
        "PIL",
        "pillow",
        "plotly",
        "pydantic",
        "pydantic_core",
        "python_dateutil",
        "pytz",
        "seaborn",
        "xlrd",
        "yaml",
        "pyyaml",
    }

    # Functions that are explicitly blocked
    # NOTE: This list MUST be kept in sync with:
    #   - docker/sandbox/entrypoint.py (BLOCKED_FUNCTIONS)
    #   - apps/ai-engine-v2/review/validators.py (BLOCKED_FUNCTIONS)
    #   - apps/ai-engine-v2/tasks/submission_review.py (BLOCKED_FUNCTIONS)
    BLOCKED_FUNCTIONS = {
        "eval",
        "exec",
        "compile",
        "__import__",
        "open",
        "breakpoint",
        "input",
        "globals",
        "locals",
        "vars",
        "subprocess",
        "os.system",
        "os.popen",
        "os.spawn",
        "os.exec",
        "os.fork",
        "os.kill",
        "os.remove",
        "os.unlink",
        "os.rmdir",
        "shutil.rmtree",
    }

    # Modules that are explicitly forbidden in sandbox
    # NOTE: This list MUST be kept in sync with:
    #   - docker/sandbox/entrypoint.py (FORBIDDEN_MODULES)
    #   - apps/ai-engine-v2/review/validators.py (FORBIDDEN_MODULES)
    #   - apps/ai-engine-v2/tasks/submission_review.py (FORBIDDEN_MODULES)
    FORBIDDEN_MODULES = {
        "os",
        "subprocess",
        "multiprocessing",
        "threading",
        "socket",
        "ctypes",
        "sys",
        "importlib",
        "builtins",
        "pickle",
        "marshal",
        "shelve",
        "tempfile",
        "shutil",
        "glob",
        "pty",
        "fcntl",
        "termios",
        "grp",
        "pwd",
        "resource",
        "signal",
        "code",
        "codeop",
        "gc",
        "_thread",
    }

    # Dangerous function patterns
    DANGEROUS_PATTERNS = [
        (r"\beval\s*\(", "S001", "Use of eval() is forbidden"),
        (r"\bexec\s*\(", "S002", "Use of exec() is forbidden"),
        (r"\bcompile\s*\(", "S003", "Use of compile() is forbidden"),
        (r"\b__import__\s*\(", "S004", "Use of __import__() is forbidden"),
        (r"\bopen\s*\(", "S005", "Direct file access is forbidden. Use StorageClient."),
        (r"\brequests\.", "S006", "Direct HTTP requests forbidden. Use HttpClient."),
        (r"\burllib\.", "S007", "Direct HTTP requests forbidden. Use HttpClient."),
        (
            r"\bhttpx\.(?!.*Client)",
            "S008",
            "Direct HTTP requests forbidden. Use HttpClient.",
        ),
    ]

    # Patterns that may indicate hardcoded secrets
    SECRET_PATTERNS = [
        (
            r'(?i)(api[_-]?key|apikey)\s*[=:]\s*["\'][^"\']{10,}["\']',
            "S400",
            "Possible hardcoded API key detected",
        ),
        (
            r'(?i)(secret|password|passwd|pwd)\s*[=:]\s*["\'][^"\']{6,}["\']',
            "S401",
            "Possible hardcoded secret/password detected",
        ),
        (
            r'(?i)(token|auth)\s*[=:]\s*["\'][^"\']{10,}["\']',
            "S402",
            "Possible hardcoded token detected",
        ),
        (
            r'(?i)(aws|azure|gcp)_?(secret|key|token)\s*[=:]\s*["\'][^"\']+["\']',
            "S403",
            "Possible cloud provider credential detected",
        ),
        (
            r"-----BEGIN (RSA |DSA |EC |OPENSSH )?PRIVATE KEY-----",
            "S404",
            "Private key detected in source code",
        ),
        (
            r"(?i)bearer\s+[a-zA-Z0-9_\-\.]+",
            "S405",
            "Possible bearer token in code",
        ),
    ]

    # Paths ignored during directory-level validation scans
    IGNORED_DIRS = {
        ".git",
        "__pycache__",
        ".venv",
        "venv",
        "node_modules",
        ".pytest_cache",
        ".mypy_cache",
        ".ruff_cache",
        ".tox",
        ".nox",
        "dist",
        "build",
        ".eggs",
    }

    IGNORED_FILE_PATTERNS = {
        "*.pyc",
        "*.pyo",
        ".DS_Store",
        "Thumbs.db",
        "*.log",
        ".coverage",
        ".env",
        ".env.*",
    }

    def validate_file(self, path: str | Path) -> list[SecurityIssue]:
        """
        Validate a single Python file.

        Args:
            path: Path to Python file

        Returns:
            List of security issues found
        """
        path = Path(path)
        issues: list[SecurityIssue] = []

        if not path.exists() or not path.suffix == ".py":
            return issues

        content = path.read_text()

        # Check for dangerous patterns using regex
        for pattern, code, message in self.DANGEROUS_PATTERNS:
            for match in re.finditer(pattern, content):
                line_num = content[: match.start()].count("\n") + 1
                issues.append(
                    SecurityIssue(
                        severity="high",
                        code=code,
                        message=message,
                        file=str(path),
                        line=line_num,
                    )
                )

        # Parse AST for deeper analysis
        try:
            tree = ast.parse(content)
            issues.extend(self._check_imports(tree, str(path)))
            issues.extend(self._check_function_calls(tree, str(path)))
        except SyntaxError:
            issues.append(
                SecurityIssue(
                    severity="medium",
                    code="S100",
                    message="Could not parse file for security analysis",
                    file=str(path),
                )
            )

        return issues

    def validate_directory(self, path: str | Path) -> SecurityResult:
        """
        Validate all Python files in a directory.

        Args:
            path: Path to directory

        Returns:
            Security validation result
        """
        path = Path(path)
        all_issues: list[SecurityIssue] = []

        # Find all Python files
        for py_file in self._iter_files(path, {".py"}):
            # Skip test files
            if _is_test_file(py_file.name, str(py_file)):
                continue
            all_issues.extend(self.validate_file(py_file))

        # Calculate score
        score = self._calculate_score(all_issues)
        passed = not any(i.severity in ("critical", "high") for i in all_issues)

        return SecurityResult(passed=passed, issues=all_issues, score=score)

    def _check_imports(self, tree: ast.AST, file_path: str) -> list[SecurityIssue]:
        """Check for forbidden imports."""
        issues: list[SecurityIssue] = []

        for node in ast.walk(tree):
            if isinstance(node, ast.Import):
                for alias in node.names:
                    module = alias.name.split(".")[0]
                    if module in self.FORBIDDEN_MODULES:
                        issues.append(
                            SecurityIssue(
                                severity="critical",
                                code="S200",
                                message=f"Forbidden import: {alias.name}",
                                file=file_path,
                                line=node.lineno,
                            )
                        )

            elif isinstance(node, ast.ImportFrom):
                if node.module:
                    module = node.module.split(".")[0]
                    if module in self.FORBIDDEN_MODULES:
                        issues.append(
                            SecurityIssue(
                                severity="critical",
                                code="S201",
                                message=f"Forbidden import from: {node.module}",
                                file=file_path,
                                line=node.lineno,
                            )
                        )

        return issues

    def _check_function_calls(
        self, tree: ast.AST, file_path: str
    ) -> list[SecurityIssue]:
        """Check for dangerous function calls."""
        issues: list[SecurityIssue] = []

        for node in ast.walk(tree):
            if isinstance(node, ast.Call):
                # Check for getattr/setattr with dynamic names
                if isinstance(node.func, ast.Name):
                    if node.func.id in ("getattr", "setattr", "delattr"):
                        issues.append(
                            SecurityIssue(
                                severity="medium",
                                code="S300",
                                message=f"Dynamic attribute access: {node.func.id}()",
                                file=file_path,
                                line=node.lineno,
                            )
                        )

        return issues

    def _calculate_score(self, issues: list[SecurityIssue]) -> float:
        """Calculate security score based on issues."""
        if not issues:
            return 100.0

        # Deduct points based on severity
        deductions = {
            "critical": 25,
            "high": 15,
            "medium": 5,
            "low": 2,
        }

        total_deduction = sum(deductions.get(i.severity, 0) for i in issues)
        return max(0.0, 100.0 - total_deduction)

    def validate_imports(self, path: str | Path) -> SecurityResult:
        """
        Validate all imports are from the allowed list.

        This method checks that a file or directory only imports from
        the ALLOWED_IMPORTS set. Intra-project imports (modules that
        exist within the automation directory) are always allowed.

        Args:
            path: Path to Python file or directory

        Returns:
            SecurityResult with import validation issues

        Example:
            result = validator.validate_imports("./my-automation")
            if not result.passed:
                for issue in result.issues:
                    print(f"Disallowed import: {issue.message}")
        """
        path = Path(path)
        all_issues: list[SecurityIssue] = []

        if path.is_file():
            all_issues.extend(self._check_imports_allowlist(path))
        elif path.is_dir():
            local_modules = self._discover_local_modules(path)
            for py_file in self._iter_files(path, {".py"}):
                if _is_test_file(py_file.name, str(py_file)):
                    continue
                all_issues.extend(self._check_imports_allowlist(py_file, local_modules))

        score = self._calculate_score(all_issues)
        passed = not any(i.severity in ("critical", "high") for i in all_issues)

        return SecurityResult(passed=passed, issues=all_issues, score=score)

    def _check_imports_allowlist(
        self, path: Path, local_modules: set[str] | None = None
    ) -> list[SecurityIssue]:
        """Check imports against the allowlist."""
        issues: list[SecurityIssue] = []

        if not path.exists() or path.suffix != ".py":
            return issues

        try:
            content = path.read_text()
            tree = ast.parse(content)
        except (SyntaxError, IOError):
            return issues

        for node in ast.walk(tree):
            if isinstance(node, ast.Import):
                for alias in node.names:
                    if not self._is_import_allowed(alias.name, local_modules):
                        issues.append(
                            SecurityIssue(
                                severity="high",
                                code="S210",
                                message=f"Import not in allowed list: {alias.name}",
                                file=str(path),
                                line=node.lineno,
                            )
                        )

            elif isinstance(node, ast.ImportFrom):
                if node.module:
                    if not self._is_import_allowed(node.module, local_modules):
                        issues.append(
                            SecurityIssue(
                                severity="high",
                                code="S211",
                                message=f"Import from not in allowed list: {node.module}",
                                file=str(path),
                                line=node.lineno,
                            )
                        )

        return issues

    def _is_import_allowed(
        self, module_name: str, local_modules: set[str] | None = None
    ) -> bool:
        """Check if a module import is allowed."""
        # Get the base module name
        parts = module_name.split(".")
        base_module = parts[0]

        # Check if base module is in allowed list
        if base_module in self.ALLOWED_IMPORTS:
            return True

        # Check full module path against allowed list using module boundaries
        # to avoid prefix collisions (e.g. "requests" matching "re").
        for allowed in self.ALLOWED_IMPORTS:
            if module_name == allowed or module_name.startswith(f"{allowed}."):
                return True

        # Allow intra-project imports (local modules within the automation)
        if local_modules and base_module in local_modules:
            return True

        return False

    @staticmethod
    def _discover_local_modules(directory: Path) -> set[str]:
        """Discover Python modules within the automation directory.

        Returns module names for:
        - Python files directly in the directory (e.g. state.py → "state")
        - Python packages (directories with __init__.py)
        """
        modules: set[str] = set()

        for item in directory.iterdir():
            if item.is_file() and item.suffix == ".py" and item.stem != "__init__":
                modules.add(item.stem)
            elif item.is_dir() and (item / "__init__.py").exists():
                modules.add(item.name)

        return modules

    def check_dangerous_functions(self, path: str | Path) -> SecurityResult:
        """
        Check for blocked/dangerous function calls.

        This method scans code for usage of functions in the
        BLOCKED_FUNCTIONS set, such as eval, exec, open, etc.

        Args:
            path: Path to Python file or directory

        Returns:
            SecurityResult with dangerous function issues

        Example:
            result = validator.check_dangerous_functions("./my-automation")
            for issue in result.issues:
                print(f"[{issue.severity}] {issue.message} at line {issue.line}")
        """
        path = Path(path)
        all_issues: list[SecurityIssue] = []

        if path.is_file():
            all_issues.extend(self._check_dangerous_patterns(path))
        elif path.is_dir():
            for py_file in self._iter_files(path, {".py"}):
                if _is_test_file(py_file.name, str(py_file)):
                    continue
                all_issues.extend(self._check_dangerous_patterns(py_file))

        score = self._calculate_score(all_issues)
        passed = not any(i.severity in ("critical", "high") for i in all_issues)

        return SecurityResult(passed=passed, issues=all_issues, score=score)

    @staticmethod
    def _get_dotted_name(node: ast.Attribute) -> str:
        """Resolve dotted attribute name from AST (e.g. ``os.system``)."""
        parts = [node.attr]
        current = node.value
        while isinstance(current, ast.Attribute):
            parts.append(current.attr)
            current = current.value
        if isinstance(current, ast.Name):
            parts.append(current.id)
        return ".".join(reversed(parts))

    def _check_dangerous_patterns(self, path: Path) -> list[SecurityIssue]:
        """Check for dangerous function patterns in a file."""
        issues: list[SecurityIssue] = []

        if not path.exists() or path.suffix != ".py":
            return issues

        try:
            content = path.read_text()
        except IOError:
            return issues

        # Check regex patterns
        for pattern, code, message in self.DANGEROUS_PATTERNS:
            for match in re.finditer(pattern, content):
                line_num = content[: match.start()].count("\n") + 1
                issues.append(
                    SecurityIssue(
                        severity="high",
                        code=code,
                        message=message,
                        file=str(path),
                        line=line_num,
                    )
                )

        # AST-based enforcement of BLOCKED_FUNCTIONS (covers names not in
        # the regex patterns, e.g. breakpoint, globals, os.fork, etc.)
        try:
            tree = ast.parse(content)
        except SyntaxError:
            return issues

        for node in ast.walk(tree):
            if isinstance(node, ast.Call):
                if isinstance(node.func, ast.Name):
                    if node.func.id in self.BLOCKED_FUNCTIONS:
                        issues.append(
                            SecurityIssue(
                                severity="high",
                                code="S010",
                                message=f"Blocked function: {node.func.id}()",
                                file=str(path),
                                line=node.lineno,
                            )
                        )
                elif isinstance(node.func, ast.Attribute):
                    dotted = self._get_dotted_name(node.func)
                    if dotted in self.BLOCKED_FUNCTIONS:
                        issues.append(
                            SecurityIssue(
                                severity="high",
                                code="S011",
                                message=f"Blocked function: {dotted}()",
                                file=str(path),
                                line=node.lineno,
                            )
                        )

        return issues

    def scan_for_secrets(self, path: str | Path) -> SecurityResult:
        """
        Detect potential secrets/credentials in code.

        This method scans for patterns that may indicate hardcoded
        secrets, API keys, passwords, or tokens.

        Args:
            path: Path to Python file or directory

        Returns:
            SecurityResult with potential secret findings

        Example:
            result = validator.scan_for_secrets("./my-automation")
            if not result.passed:
                print("WARNING: Potential secrets detected!")
                for issue in result.issues:
                    print(f"  {issue.file}:{issue.line} - {issue.message}")
        """
        path = Path(path)
        all_issues: list[SecurityIssue] = []

        if path.is_file():
            all_issues.extend(self._scan_file_for_secrets(path))
        elif path.is_dir():
            for source_file in self._iter_files(
                path, {".py", ".yaml", ".yml", ".json"}
            ):
                if source_file.suffix == ".py" and _is_test_file(
                    source_file.name, str(source_file)
                ):
                    continue
                if source_file.suffix == ".json" and "package" in source_file.name:
                    continue
                all_issues.extend(self._scan_file_for_secrets(source_file))

        score = self._calculate_score(all_issues)
        # Secrets are critical - any finding is a failure
        passed = len(all_issues) == 0

        return SecurityResult(passed=passed, issues=all_issues, score=score)

    def _iter_files(self, directory: Path, suffixes: set[str]) -> list[Path]:
        """Iterate source files while skipping virtualenv/cache/build paths."""
        root = Path(directory)
        if not root.exists():
            return []

        gitignore_patterns = self._load_gitignore_patterns(root)
        matched: list[Path] = []

        for current_root, dirs, files in os.walk(root, topdown=True):
            current_root_path = Path(current_root)

            # Prune ignored directories before descending.
            dirs[:] = [
                dirname
                for dirname in dirs
                if not self._is_ignored_path(
                    current_root_path / dirname,
                    root,
                    gitignore_patterns,
                )
            ]

            for filename in files:
                candidate = current_root_path / filename
                if candidate.suffix not in suffixes:
                    continue
                if self._is_ignored_path(candidate, root, gitignore_patterns):
                    continue
                matched.append(candidate)

        return matched

    def _load_gitignore_patterns(self, root: Path) -> list[str]:
        """Load simple ignore patterns from a project's .gitignore."""
        gitignore_path = root / ".gitignore"
        if not gitignore_path.exists():
            return []

        patterns: list[str] = []
        for raw_line in gitignore_path.read_text().splitlines():
            line = raw_line.strip()
            if not line or line.startswith("#"):
                continue
            # Negation patterns are ignored intentionally for conservative scanning.
            if line.startswith("!"):
                continue
            cleaned = line.lstrip("/").rstrip("/")
            if cleaned:
                patterns.append(cleaned)
        return patterns

    def _is_ignored_path(
        self,
        candidate: Path,
        root: Path,
        gitignore_patterns: list[str],
    ) -> bool:
        """Return True if path should be skipped by security scans."""
        relative = candidate.relative_to(root)

        for part in relative.parts:
            if part in self.IGNORED_DIRS:
                return True

        for pattern in self.IGNORED_FILE_PATTERNS:
            if fnmatch.fnmatch(relative.name, pattern) or fnmatch.fnmatch(
                relative.as_posix(), pattern
            ):
                return True

        return any(
            fnmatch.fnmatch(relative.as_posix(), pattern)
            or any(fnmatch.fnmatch(part, pattern) for part in relative.parts)
            for pattern in gitignore_patterns
        )

    def _scan_file_for_secrets(self, path: Path) -> list[SecurityIssue]:
        """Scan a file for potential secrets."""
        issues: list[SecurityIssue] = []

        if not path.exists():
            return issues

        try:
            content = path.read_text()
        except (IOError, UnicodeDecodeError):
            return issues

        for pattern, code, message in self.SECRET_PATTERNS:
            for match in re.finditer(pattern, content):
                line_num = content[: match.start()].count("\n") + 1
                issues.append(
                    SecurityIssue(
                        severity="critical",
                        code=code,
                        message=message,
                        file=str(path),
                        line=line_num,
                    )
                )

        return issues

    def validate_all(self, path: str | Path) -> SecurityResult:
        """
        Run all security validations.

        This is a convenience method that runs:
        - Import validation
        - Dangerous function detection
        - Secret scanning
        - Forbidden module detection

        Args:
            path: Path to file or directory

        Returns:
            Combined SecurityResult
        """
        all_issues: list[SecurityIssue] = []

        # Run all validations
        import_result = self.validate_imports(path)
        all_issues.extend(import_result.issues)

        dangerous_result = self.check_dangerous_functions(path)
        all_issues.extend(dangerous_result.issues)

        secrets_result = self.scan_for_secrets(path)
        all_issues.extend(secrets_result.issues)

        # Also run the original validation
        dir_result = self.validate_directory(path)
        # Merge unique issues (avoid duplicates)
        existing_keys = {(i.code, i.file, i.line) for i in all_issues}
        for issue in dir_result.issues:
            key = (issue.code, issue.file, issue.line)
            if key not in existing_keys:
                all_issues.append(issue)

        score = self._calculate_score(all_issues)
        passed = not any(i.severity in ("critical", "high") for i in all_issues)

        return SecurityResult(passed=passed, issues=all_issues, score=score)
