"""Bandit security linter integration."""

import json
import subprocess  # nosec B404 - deliberate, shell disabled
from dataclasses import dataclass, field
from typing import Any

from loguru import logger

from lintro.enums.bandit_levels import (
    BanditConfidenceLevel,
    BanditSeverityLevel,
    normalize_bandit_confidence_level,
    normalize_bandit_severity_level,
)
from lintro.enums.tool_type import ToolType
from lintro.models.core.tool_config import ToolConfig
from lintro.models.core.tool_result import ToolResult
from lintro.parsers.bandit.bandit_parser import parse_bandit_output
from lintro.tools.core.tool_base import BaseTool
from lintro.utils.config_utils import load_bandit_config

# Constants for Bandit configuration
BANDIT_DEFAULT_TIMEOUT: int = 30
BANDIT_DEFAULT_PRIORITY: int = 90  # High priority for security tool
BANDIT_FILE_PATTERNS: list[str] = ["*.py", "*.pyi"]
BANDIT_OUTPUT_FORMAT: str = "json"


def _extract_bandit_json(raw_text: str) -> dict[str, Any]:
    """Extract Bandit's JSON object from mixed stdout/stderr text.

    Bandit may print informational lines and a progress bar alongside the
    JSON report. This helper locates the first opening brace and the last
    closing brace and attempts to parse the enclosed JSON object.

    Args:
        raw_text: str: Combined stdout+stderr text from Bandit.

    Returns:
        dict[str, Any]: Parsed JSON object.

    Raises:
        JSONDecodeError: If JSON cannot be parsed.
        ValueError: If no JSON object boundaries are found.
    """
    if not raw_text or not raw_text.strip():
        raise json.JSONDecodeError("Empty output", raw_text or "", 0)

    text: str = raw_text.strip()

    # Quick path: if the entire text is JSON
    if text.startswith("{") and text.endswith("}"):
        result: dict[str, Any] = json.loads(text)
        return result

    start: int = text.find("{")
    end: int = text.rfind("}")
    if start == -1 or end == -1 or end < start:
        raise ValueError("Could not locate JSON object in Bandit output")

    json_str: str = text[start : end + 1]
    parsed: dict[str, Any] = json.loads(json_str)
    return parsed


@dataclass
class BanditTool(BaseTool):
    """Bandit security linter integration.

    Bandit is a security linter designed to find common security issues in Python code.
    It processes Python files, builds an AST, and runs security plugins against the
    AST nodes. Bandit does not support auto-fixing of issues.

    Attributes:
        name: str: Tool name.
        description: str: Tool description.
        can_fix: bool: Whether the tool can fix issues.
        config: ToolConfig: Tool configuration.
        exclude_patterns: list[str]: List of patterns to exclude.
        include_venv: bool: Whether to include virtual environment files.
    """

    name: str = field(default="bandit")
    description: str = field(
        default="Security linter that finds common security issues in Python code",
    )
    can_fix: bool = field(default=False)  # Bandit does not support auto-fixing
    config: ToolConfig = field(
        default_factory=lambda: ToolConfig(
            priority=BANDIT_DEFAULT_PRIORITY,  # High priority for security
            conflicts_with=[],  # Can work alongside other tools
            file_patterns=BANDIT_FILE_PATTERNS,  # Python files only
            tool_type=ToolType.SECURITY,  # Security-focused tool
            options={
                "timeout": BANDIT_DEFAULT_TIMEOUT,  # Default timeout in seconds
                "severity": None,  # Minimum severity level (LOW, MEDIUM, HIGH)
                "confidence": None,  # Minimum confidence level (LOW, MEDIUM, HIGH)
                "tests": None,  # Comma-separated list of test IDs to run
                "skips": None,  # Comma-separated list of test IDs to skip
                "profile": None,  # Profile to use
                "configfile": None,  # Path to config file
                "baseline": None,  # Path to baseline report for comparison
                "ignore_nosec": False,  # Ignore # nosec comments
                "aggregate": "vuln",  # Aggregate by vulnerability or file
                "verbose": False,  # Verbose output
                "quiet": False,  # Quiet mode
            },
        ),
    )

    def __post_init__(self) -> None:
        """Initialize the tool with default configuration."""
        super().__post_init__()

        # Load bandit configuration from pyproject.toml
        bandit_config = load_bandit_config()

        # Apply configuration overrides
        if "exclude_dirs" in bandit_config:
            # Convert exclude_dirs to exclude patterns
            # Add both /* for immediate children and /**/* for nested subdirectories
            exclude_dirs = bandit_config["exclude_dirs"]
            if isinstance(exclude_dirs, list):
                for exclude_dir in exclude_dirs:
                    # Pattern for immediate children
                    pattern = f"{exclude_dir}/*"
                    if pattern not in self.exclude_patterns:
                        self.exclude_patterns.append(pattern)
                    # Pattern for nested subdirectories (recursive)
                    recursive_pattern = f"{exclude_dir}/**/*"
                    if recursive_pattern not in self.exclude_patterns:
                        self.exclude_patterns.append(recursive_pattern)

        # Set other options from configuration
        config_mapping = {
            "tests": "tests",
            "skips": "skips",
            "profile": "profile",
            "configfile": "configfile",
            "baseline": "baseline",
            "ignore_nosec": "ignore_nosec",
            "aggregate": "aggregate",
            # Newly mapped options from pyproject
            "severity": "severity",
            "confidence": "confidence",
        }

        for config_key, option_key in config_mapping.items():
            if config_key in bandit_config:
                value = bandit_config[config_key]
                # Validate and normalize severity/confidence early to fail fast
                if config_key == "severity" and value is not None:
                    value = normalize_bandit_severity_level(
                        value,
                    ).value  # Normalize and convert to string
                elif config_key == "confidence" and value is not None:
                    value = normalize_bandit_confidence_level(
                        value,
                    ).value  # Normalize and convert to string
                # Convert lists to comma-separated strings for skips/tests
                elif config_key in ("skips", "tests") and isinstance(value, list):
                    value = ",".join(value)
                self.options[option_key] = value

    def set_options(  # type: ignore[override]
        self,
        severity: str | None = None,
        confidence: str | None = None,
        tests: str | None = None,
        skips: str | None = None,
        profile: str | None = None,
        configfile: str | None = None,
        baseline: str | None = None,
        ignore_nosec: bool | None = None,
        aggregate: str | None = None,
        verbose: bool | None = None,
        quiet: bool | None = None,
        **kwargs: Any,
    ) -> None:
        """Set Bandit-specific options.

        Args:
            severity: str | None: Minimum severity level (LOW, MEDIUM, HIGH).
            confidence: str | None: Minimum confidence level (LOW, MEDIUM, HIGH).
            tests: str | None: Comma-separated list of test IDs to run.
            skips: str | None: Comma-separated list of test IDs to skip.
            profile: str | None: Profile to use.
            configfile: str | None: Path to config file.
            baseline: str | None: Path to baseline report for comparison.
            ignore_nosec: bool | None: Ignore # nosec comments.
            aggregate: str | None: Aggregate by vulnerability or file.
            verbose: bool | None: Verbose output.
            quiet: bool | None: Quiet mode.
            **kwargs: Other tool options.

        Raises:
            ValueError: If an option value is invalid.
        """
        # Validate severity level using enum normalization
        if severity is not None:
            severity = normalize_bandit_severity_level(severity).value

        # Validate confidence level using enum normalization
        if confidence is not None:
            confidence = normalize_bandit_confidence_level(confidence).value

        # Validate aggregate option
        if aggregate is not None:
            valid_aggregates = ["vuln", "file"]
            if aggregate not in valid_aggregates:
                raise ValueError(f"aggregate must be one of {valid_aggregates}")

        options: dict[str, Any] = {
            "severity": severity,
            "confidence": confidence,
            "tests": tests,
            "skips": skips,
            "profile": profile,
            "configfile": configfile,
            "baseline": baseline,
            "ignore_nosec": ignore_nosec,
            "aggregate": aggregate,
            "verbose": verbose,
            "quiet": quiet,
        }
        # Remove None values
        options = {k: v for k, v in options.items() if v is not None}
        super().set_options(**options, **kwargs)

    def _build_check_command(
        self,
        files: list[str],
    ) -> list[str]:
        """Build the bandit check command.

        Args:
            files: list[str]: List of files to check.

        Returns:
            list[str]: List of command arguments.
        """
        # Resolve executable via BaseTool preferences to ensure reliable
        # execution inside the active environment (prefers 'uv run bandit' when
        # available), falling back to a direct executable.
        exec_cmd: list[str] = self._get_executable_command("bandit")

        cmd: list[str] = exec_cmd + ["-r"]

        # Add configuration options
        severity_opt = self.options.get("severity")
        if severity_opt is not None:
            severity = normalize_bandit_severity_level(str(severity_opt))
            if severity == BanditSeverityLevel.LOW:
                cmd.append("-l")
            elif severity == BanditSeverityLevel.MEDIUM:
                cmd.extend(["-ll"])
            elif severity == BanditSeverityLevel.HIGH:
                cmd.extend(["-lll"])

        confidence_opt = self.options.get("confidence")
        if confidence_opt is not None:
            confidence = normalize_bandit_confidence_level(str(confidence_opt))
            if confidence == BanditConfidenceLevel.LOW:
                cmd.append("-i")
            elif confidence == BanditConfidenceLevel.MEDIUM:
                cmd.extend(["-ii"])
            elif confidence == BanditConfidenceLevel.HIGH:
                cmd.extend(["-iii"])

        tests_opt = self.options.get("tests")
        if tests_opt is not None:
            cmd.extend(["-t", str(tests_opt)])

        skips_opt = self.options.get("skips")
        if skips_opt is not None:
            cmd.extend(["-s", str(skips_opt)])

        profile_opt = self.options.get("profile")
        if profile_opt is not None:
            cmd.extend(["-p", str(profile_opt)])

        configfile_opt = self.options.get("configfile")
        if configfile_opt is not None:
            cmd.extend(["-c", str(configfile_opt)])

        baseline_opt = self.options.get("baseline")
        if baseline_opt is not None:
            cmd.extend(["-b", str(baseline_opt)])

        if self.options.get("ignore_nosec"):
            cmd.append("--ignore-nosec")

        aggregate_opt = self.options.get("aggregate")
        if aggregate_opt is not None:
            cmd.extend(["-a", str(aggregate_opt)])

        if self.options.get("verbose"):
            cmd.append("-v")

        if self.options.get("quiet"):
            cmd.append("-q")

        # Output format
        cmd.extend(["-f", BANDIT_OUTPUT_FORMAT])

        # Add quiet flag (once) to suppress log messages that interfere with JSON
        # parsing. Guard against duplicates when quiet=True already added it.
        if "-q" not in cmd:
            cmd.append("-q")

        # Add files
        cmd.extend(files)

        return cmd

    def check(
        self,
        paths: list[str],
    ) -> ToolResult:
        """Check files with Bandit for security issues.

        Args:
            paths: list[str]: List of file or directory paths to check.

        Returns:
            ToolResult: ToolResult instance.
        """
        # Use shared preparation to handle version check, path validation,
        # file discovery, and cwd computation
        ctx = self._prepare_execution(
            paths,
            default_timeout=BANDIT_DEFAULT_TIMEOUT,
        )
        if ctx.should_skip:
            return ctx.early_result  # type: ignore[return-value]

        cmd: list[str] = self._build_check_command(files=ctx.rel_files)

        output: str
        execution_failure: bool = False
        # Run Bandit via the shared safe runner in BaseTool. This enforces
        # argument validation and consistent subprocess handling across tools.
        try:
            success, combined = self._run_subprocess(
                cmd=cmd,
                timeout=ctx.timeout,
                cwd=ctx.cwd,
            )
            output = (combined or "").strip()
            rc: int = 0 if success else 1
        except subprocess.TimeoutExpired:
            # Handle timeout gracefully using shared utility
            from lintro.tools.core.timeout_utils import create_timeout_result

            timeout_result = create_timeout_result(
                tool=self,
                timeout=ctx.timeout,
                cmd=cmd,
            )
            return ToolResult(
                name=self.name,
                success=timeout_result.success,
                output=timeout_result.output,
                issues_count=timeout_result.issues_count,
            )
        except Exception as e:
            logger.error(f"Failed to run Bandit: {e}")
            output = f"Bandit failed: {e}"
            execution_failure = True
            rc = 1

        # Parse the JSON output
        try:
            # If command failed and no obvious JSON present, surface error cleanly
            if ("{" not in output or "}" not in output) and rc != 0:
                return ToolResult(
                    name=self.name,
                    success=False,
                    output=output,
                    issues_count=0,
                )

            # Attempt robust JSON extraction from mixed output
            bandit_data = _extract_bandit_json(raw_text=output)
            issues = parse_bandit_output(bandit_data)
            issues_count = len(issues)

            # Bandit returns 0 if no issues; 1 if issues found (still successful run)
            execution_success = (
                len(bandit_data.get("errors", [])) == 0 and not execution_failure
            )

            return ToolResult(
                name=self.name,
                success=execution_success,
                output=output if execution_failure else None,
                issues_count=issues_count,
                issues=issues,
            )

        except (json.JSONDecodeError, ValueError) as e:
            logger.error(f"Failed to parse bandit output: {e}")
            return ToolResult(
                name=self.name,
                success=False,
                output=(output or f"Failed to parse bandit output: {str(e)}"),
                issues_count=0,
            )

    def fix(
        self,
        paths: list[str],
    ) -> ToolResult:
        """Fix issues in files with Bandit.

        Note: Bandit does not support auto-fixing of security issues.
        This method raises NotImplementedError.

        Args:
            paths: list[str]: List of file or directory paths to fix.

        Raises:
            NotImplementedError: Always raised since Bandit doesn't support fixing.
        """
        # Bandit cannot auto-fix issues; explicitly signal this.
        raise NotImplementedError("Bandit does not support auto-fixing.")
