"""SEC1 - Configuration security scanner.

Scans YAML, TOML, and generic config files for disallowed patterns that
could indicate code-injection vulnerabilities (e.g. ``eval()``, ``exec()``,
``__import__``).

Each violation is reported with file path, line number, matched token, and a
severity level.  The scanner is designed to be called from both the CLI and
automated test suites.

**Usage (programmatic)**::

    from cleveragents.config.security_scanner import scan_files, Severity

    results = scan_files(["config.yaml", "settings.toml"])
    for v in results:
        print(f"{v.file_path}:{v.line_number}  [{v.severity.name}]  {v.message}")

**Usage (CLI)**::

    python -m cleveragents.config.security_scanner path/to/config.yaml
"""

from __future__ import annotations

import re
import sys
from collections.abc import Sequence
from dataclasses import dataclass
from enum import Enum
from pathlib import Path


class Severity(Enum):
    """Severity of a detected security violation."""

    CRITICAL = "critical"
    HIGH = "high"
    MEDIUM = "medium"


@dataclass(frozen=True, slots=True)
class Violation:
    """A single security violation found in a config file."""

    file_path: str
    line_number: int
    token: str
    severity: Severity
    message: str


# ---------------------------------------------------------------------------
# Pattern registry
# ---------------------------------------------------------------------------


@dataclass(frozen=True, slots=True)
class _PatternEntry:
    """Internal: compiled pattern with metadata."""

    pattern: re.Pattern[str]
    token: str
    severity: Severity
    description: str


_DISALLOWED: list[_PatternEntry] = []


def _register(token: str, severity: Severity, description: str) -> None:
    """Compile and register a disallowed token pattern."""
    escaped = re.escape(token)
    regex = re.compile(escaped)
    _DISALLOWED.append(_PatternEntry(regex, token, severity, description))


# -- Critical: direct code execution primitives ----------------------------
_register("eval(", Severity.CRITICAL, "eval() can execute arbitrary Python code")
_register("exec(", Severity.CRITICAL, "exec() can execute arbitrary Python code")
_register("__import__", Severity.CRITICAL, "__import__ can dynamically load any module")
_register("os.system", Severity.CRITICAL, "os.system() executes shell commands")
_register(
    "subprocess.call", Severity.CRITICAL, "subprocess.call() executes shell commands"
)
_register(
    "subprocess.run", Severity.CRITICAL, "subprocess.run() executes shell commands"
)
_register("subprocess.Popen", Severity.CRITICAL, "subprocess.Popen() spawns processes")
_register(
    "subprocess.check_output",
    Severity.CRITICAL,
    "subprocess.check_output() executes shell commands",
)
_register(
    "subprocess.check_call",
    Severity.CRITICAL,
    "subprocess.check_call() executes shell commands",
)

# -- High: compile / popen / importlib / pickle ----------------------------
_register("compile(", Severity.HIGH, "compile() can prepare code for execution")
_register("os.popen", Severity.HIGH, "os.popen() executes shell commands")
_register(
    "importlib.import_module",
    Severity.HIGH,
    "importlib.import_module() can dynamically import any module",
)
_register("pickle.loads", Severity.HIGH, "pickle.loads() can execute arbitrary code")

# -- Medium: templating directives that may hide injection -----------------
_register("{{", Severity.MEDIUM, "Template directive may contain injected code")
_register("{%", Severity.MEDIUM, "Template directive may contain injected code")


# ---------------------------------------------------------------------------
# Comment stripping
# ---------------------------------------------------------------------------


def _strip_inline_comment(line: str) -> str:
    """Return *line* with any inline ``#`` comment removed.

    The function respects single- and double-quoted strings so that ``#``
    characters inside quotes are preserved.  A ``#`` preceded by at least
    one whitespace character outside of any quoted region is treated as the
    start of a comment.

    Pure comment lines (starting with ``#``) are returned as-is so the
    caller can decide whether to skip them entirely.
    """
    stripped = line.lstrip()
    if stripped.startswith("#") or stripped.startswith(";"):
        return ""

    in_single = False
    in_double = False
    prev_char = ""
    for idx, ch in enumerate(line):
        if ch == "'" and not in_double:
            in_single = not in_single
        elif ch == '"' and not in_single:
            in_double = not in_double
        elif (
            ch == "#"
            and not in_single
            and not in_double
            and (idx == 0 or prev_char in (" ", "\t"))
        ):
            # YAML/TOML: comment starts when # is preceded by whitespace
            return line[:idx]
        prev_char = ch
    return line


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


def scan_content(
    content: str,
    file_path: str = "<string>",
) -> list[Violation]:
    """Scan a string of config content for disallowed patterns.

    Args:
        content: Raw file content.
        file_path: Logical file path for error reporting.

    Returns:
        A list of :class:`Violation` objects (may be empty).
    """
    violations: list[Violation] = []
    for line_number, line in enumerate(content.splitlines(), start=1):
        stripped = line.strip()
        # Skip pure comment lines in YAML / TOML / INI
        if stripped.startswith("#") or stripped.startswith(";"):
            continue
        # Strip inline comments before scanning
        effective = _strip_inline_comment(line)
        for entry in _DISALLOWED:
            if entry.pattern.search(effective):
                violations.append(
                    Violation(
                        file_path=file_path,
                        line_number=line_number,
                        token=entry.token,
                        severity=entry.severity,
                        message=(
                            f"{entry.description} "
                            f"(found '{entry.token}' at line {line_number})"
                        ),
                    )
                )
    return violations


def scan_file(path: str | Path) -> list[Violation]:
    """Scan a single file and return any violations.

    Args:
        path: Filesystem path to the config file.

    Returns:
        A list of :class:`Violation` objects.

    Raises:
        FileNotFoundError: If *path* does not exist.
    """
    resolved = Path(path).resolve()
    try:
        text = resolved.read_text(encoding="utf-8")
    except UnicodeDecodeError:
        # Fall back to replacement mode so non-UTF-8 files still get scanned
        text = resolved.read_text(encoding="utf-8", errors="replace")
    return scan_content(text, file_path=str(resolved))


def scan_files(paths: Sequence[str | Path]) -> list[Violation]:
    """Scan multiple config files and return aggregated violations.

    Args:
        paths: An iterable of filesystem paths to scan.

    Returns:
        A flat list of violations across all files, ordered by file then
        line number.
    """
    all_violations: list[Violation] = []
    for p in paths:
        all_violations.extend(scan_file(p))
    return all_violations


def validate_config_safety(
    content: str,
    file_path: str = "<string>",
) -> None:
    """Validate that *content* contains no disallowed patterns.

    Raises:
        :class:`~cleveragents.core.exceptions.ConfigurationError`
            if any violations are found.  The error message lists every
            violation with file path and line number.
    """
    violations = scan_content(content, file_path)
    if not violations:
        return

    from cleveragents.core.exceptions import ConfigurationError

    lines: list[str] = [
        "Unsafe configuration detected — the following violations were found:"
    ]
    for v in violations:
        lines.append(f"  {v.file_path}:{v.line_number} [{v.severity.name}] {v.message}")
    raise ConfigurationError("\n".join(lines))


# ---------------------------------------------------------------------------
# CLI entry-point
# ---------------------------------------------------------------------------


def _cli_main(argv: list[str] | None = None) -> int:
    """Minimal CLI wrapper for the config scanner."""
    args = argv if argv is not None else sys.argv[1:]
    if not args:
        print(
            "Usage: python -m cleveragents.config.security_scanner <file> ...",
            file=sys.stderr,
        )
        return 2

    violations = scan_files(args)
    if not violations:
        print("No security violations found.")
        return 0

    for v in violations:
        sev = v.severity.name
        print(f"{v.file_path}:{v.line_number} [{sev}] {v.message}")
    print(f"\n{len(violations)} violation(s) found.")
    return 1


if __name__ == "__main__":
    raise SystemExit(_cli_main())
