"""
Universal Finding Normalizer for ACR-QA v2.0
Converts tool-specific outputs to canonical schema
"""

import logging
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent.parent.parent))


import json
import uuid
from pathlib import Path
from typing import Any

from pydantic import BaseModel, ConfigDict, Field, field_validator

# Universal Rule Mapping: Tool-specific → Canonical
RULE_MAPPING = {
    # Ruff rules → Universal IDs
    "F401": "IMPORT-001",  # Unused import
    "F841": "VAR-001",  # Unused variable
    "PLR0913": "SOLID-001",  # Too many parameters
    "B006": "PATTERN-001",  # Mutable default argument
    "E501": "STYLE-001",  # Line too long
    "D100": "STYLE-002",  # Missing docstring (module)
    "D101": "STYLE-002",  # Missing docstring (class)
    "D102": "STYLE-002",  # Missing docstring (method)
    "D103": "STYLE-002",  # Missing docstring (function)
    "N801": "NAMING-001",  # Bad class name
    "N802": "NAMING-001",  # Bad function name
    "N803": "NAMING-001",  # Bad argument name
    "N806": "NAMING-001",  # Bad variable name
    # Semgrep rules → Universal IDs
    "dangerous-eval-usage": "SECURITY-001",
    "mutable-default-argument": "PATTERN-001",
    # Vulture → Universal IDs
    "unused-code": "DEAD-001",
    "unused-import": "IMPORT-001",
    "unused-variable": "VAR-001",
    "unused-function": "DEAD-001",
    "unused-class": "DEAD-001",
    # jscpd → Universal IDs
    # Radon → Universal IDs
    "high-complexity": "COMPLEXITY-001",
    "code-duplication": "DUP-001",
    # Bandit security rules → Universal IDs
    "B101": "SECURITY-002",  # assert used
    "B102": "SECURITY-001",  # exec used
    "B103": "SECURITY-003",  # set_bad_file_permissions
    "B104": "SECURITY-004",  # hardcoded_bind_all_interfaces
    "B105": "SECURITY-005",  # hardcoded_password_string
    "B106": "SECURITY-005",  # hardcoded_password_funcarg
    "B107": "SECURITY-005",  # hardcoded_password_default
    # Ruff additional rules
    "E722": "EXCEPT-001",  # Bare except
    "B001": "EXCEPT-001",  # Bare except (flake8-bugbear)
    "ASYNC100": "ASYNC-001",  # Async function no await
    "ASYNC101": "ASYNC-001",  # Async function open call
    "ANN001": "TYPE-001",  # Missing type annotation for argument
    "ANN002": "TYPE-001",  # Missing type annotation for *args
    "ANN003": "TYPE-001",  # Missing type annotation for **kwargs
    "ANN201": "TYPE-001",  # Missing return type annotation
    "S105": "HARDCODE-001",  # Hardcoded password (flake8-bandit)
    "B108": "SECURITY-006",  # hardcoded_tmp_directory
    "B110": "SECURITY-007",  # try_except_pass
    "B112": "SECURITY-007",  # try_except_continue
    "B301": "SECURITY-008",  # pickle usage
    "B302": "SECURITY-008",  # marshal usage
    "B303": "SECURITY-009",  # md5 usage (weak hash)
    "B304": "SECURITY-010",  # des usage (weak cipher)
    "B305": "SECURITY-010",  # cipher usage
    "B306": "SECURITY-011",  # mktemp usage
    "B307": "SECURITY-001",  # eval usage
    "B308": "SECURITY-012",  # mark_safe usage
    "B324": "SECURITY-009",  # hashlib weak hash
    "B501": "SECURITY-013",  # request_with_no_cert_validation
    "B502": "SECURITY-014",  # ssl_with_bad_version
    "B503": "SECURITY-015",  # ssl_with_bad_defaults
    "B504": "SECURITY-016",  # ssl_with_no_version
    "B505": "SECURITY-017",  # weak_cryptographic_key
    "B506": "SECURITY-018",  # yaml_load
    "B507": "SECURITY-019",  # ssh_no_host_key_verification
    "B601": "SECURITY-020",  # paramiko_calls
    "B602": "SECURITY-021",  # subprocess_popen_with_shell
    "B603": "SECURITY-022",  # subprocess_without_shell
    "B604": "SECURITY-023",  # any_other_function_with_shell
    "B605": "SECURITY-024",  # start_process_with_a_shell
    "B606": "SECURITY-025",  # start_process_with_no_shell
    "B607": "SECURITY-026",  # start_process_with_partial_path
    "B608": "SECURITY-027",  # hardcoded_sql_expressions
    "B609": "SECURITY-028",  # linux_commands_wildcard_injection
    "B610": "SECURITY-029",  # django_extra_used
    "B611": "SECURITY-030",  # django_rawsql_used
    "B701": "SECURITY-031",  # jinja2_autoescape_false
    "B702": "SECURITY-032",  # use_of_mako_templates
    "B703": "SECURITY-033",  # django_mark_safe
    # ── Ruff: Import sorting ──
    "I001": "IMPORT-002",  # Import block unsorted
    "I002": "IMPORT-002",  # Missing required import
    # ── Ruff: Modernization (UP) ──
    "UP007": "STYLE-003",  # Use X | Y for type unions
    "UP031": "STYLE-004",  # Use format specifiers instead of %
    "UP035": "STYLE-005",  # Deprecated import
    "UP038": "STYLE-006",  # Use X | Y in isinstance
    # ── Ruff: Error detection ──
    "F821": "ERROR-001",  # Undefined name
    "F811": "ERROR-002",  # Redefined unused name
    # ── Ruff: Best practices ──
    "B904": "EXCEPT-002",  # Use raise ... from within except
    "T201": "STYLE-007",  # logger.info() found
    "T203": "STYLE-007",  # pprint() found
    # ── Ruff: Naming ──
    "N807": "NAMING-002",  # Function name should not start/end with __
    "N812": "NAMING-003",  # Lowercase imported as non-lowercase
    "N815": "NAMING-001",  # mixedCase variable
    "N816": "NAMING-001",  # mixedCase global
    # ── Ruff: Type checking ──
    "TCH001": "TYPE-002",  # Move import into TYPE_CHECKING block
    "TCH002": "TYPE-002",  # Move third-party import into TYPE_CHECKING
    "TCH003": "TYPE-002",  # Move stdlib import into TYPE_CHECKING
    # ── Ruff: Modernization (UP) — Round 3 god-mode ──
    "UP006": "STYLE-008",  # Use builtin type (list, tuple) for annotations
    "UP017": "STYLE-009",  # Use datetime.UTC alias
    "UP024": "STYLE-010",  # Use errors instead of aliases in os module
    "UP032": "STYLE-011",  # Use f-string instead of format call
    # ── Ruff: Additional style rules ──
    "E401": "IMPORT-002",  # Multiple imports on one line
    "E713": "STYLE-012",  # Test for membership should be 'not in x'
    "E703": "STYLE-013",  # Statement ends with semicolon
    "B007": "VAR-001",  # Loop control variable not used
    # ── Ruff: Naming — Round 3 ──
    "N804": "NAMING-004",  # First argument of classmethod should be cls
    "N818": "NAMING-005",  # Exception name should end in Error
    # ── Ruff/Bandit: Security — Round 3 ──
    "B028": "BEST-PRACTICE-001",  # No explicit stacklevel keyword in warnings.warn
    "B403": "SECURITY-034",  # import_pickle — pickle module imported
    "B404": "SECURITY-035",  # import_subprocess — subprocess module imported
    "B905": "BEST-PRACTICE-002",  # zip() without explicit strict parameter
    # ── Ruff: Wildcard imports ──
    "F403": "IMPORT-003",  # Wildcard import used
    # ── Bandit: Additional security — Round 3 deep scan ──
    "B113": "SECURITY-036",  # TLS certificate verification disabled
    "B311": "SECURITY-037",  # Standard pseudo-random not suitable for security
    "B406": "SECURITY-038",  # Unsafe XML parsing (SAX)
    "B410": "SECURITY-039",  # Unsafe XML parsing (lxml)
    "B321": "SECURITY-040",  # FTP cleartext protocol
    "B402": "SECURITY-041",  # import_ftplib — FTP module imported
    # ── Bandit: XML/Network/Misc security (previously CUSTOM-*) ──
    "B201": "SECURITY-065",  # flask_debug_true
    "B310": "SECURITY-042",  # urllib_urlopen — audit url open for permitted schemes
    "B314": "SECURITY-043",  # xml_bad_etree — unsafe XML parsing (ElementTree)
    "B317": "SECURITY-043",  # xml_bad_sax — unsafe XML parsing (SAX)
    "B319": "SECURITY-043",  # xml_bad_pulldom — unsafe XML parsing (pulldom)
    "B320": "SECURITY-044",  # xml_bad_lxml — unsafe XML parsing (lxml)
    "B409": "SECURITY-039",  # import_lxml — lxml imported (unsafe XML)
    # ── Semgrep custom rules → Canonical IDs ──
    "print-in-production": "STYLE-007",  # logger.info() found — same as Ruff T201
    "hardcoded-password": "SECURITY-005",  # Same as B105/B106/B107
    "shell-injection": "SECURITY-021",  # Same as B602
    "unsafe-pickle": "SECURITY-008",  # Same as B301
    "command-injection": "SECURITY-021",  # Same as B602
    "sql-injection": "SECURITY-027",  # Same as B608
    # ── New Semgrep rules (added in precision/recall overhaul) ──
    "flask-xss-render-string": "SECURITY-045",
    "aiohttp-jinja2-autoescape-false": "SECURITY-045",
    "aiohttp-debug-enabled": "SECURITY-065",  # aiohttp Application(debug=True)
    "ssrf-requests-user-url": "SECURITY-046",
    "jwt-none-algorithm": "SECURITY-047",
    "lxml-xxe": "SECURITY-044",  # XXE via lxml (maps to existing lxml rule)
    "open-redirect": "SECURITY-048",  # Open redirect via user-controlled URL
    # ── Bugs found in god-mode live test (2026-03-31) ──
    "bare-except": "EXCEPT-001",  # Semgrep custom rule — bare except clause
    "too-many-parameters": "SOLID-001",  # Semgrep custom rule — too many function params
    "yaml-load-unsafe": "SECURITY-018",  # Semgrep custom rule — unsafe yaml.load()
    "assert-for-validation": "ASSERT-001",  # Semgrep custom rule — assert for input validation
    # ── Remaining CUSTOM-* eliminations (god-mode phase 2) ──
    "N813": "NAMING-003",  # Ruff: camelCase import alias is not pep8-convention
    "F405": "IMPORT-003",  # Ruff: wildcard import may be undefined names
    "UP036": "STYLE-005",  # Ruff: deprecated typing compatibility syntax
    # ── New rules — Round 5 (Django, SQLAlchemy, aiohttp, black, Pillow) ──
    "UP041": "STYLE-014",  # Ruff: Replace `timeout=None` with `timeout=httpx.Timeout(None)`
    "B027": "BEST-PRACTICE-003",  # Ruff: Empty method in abstract class (should be abstractmethod)
    "UP030": "STYLE-015",  # Ruff: Use implicit references for positional format fields
    "B011": "ASSERT-002",  # Ruff: Do not `assert False` — raise AssertionError() instead
    "B018": "STYLE-016",  # Ruff: Found useless expression (statement with no side effects)
    "sql-injection-string-concat": "SECURITY-027",  # Semgrep: SQL built via string concatenation
    "global-variable": "BEST-PRACTICE-004",  # Semgrep: Use of global statement — prefer class/function scope
    "open-without-context-manager": "BEST-PRACTICE-005",  # Semgrep: open() not in 'with' statement
    "B023": "SECURITY-050",  # Ruff: function defined in loop uses variable from outer scope (closure bug)
    "B026": "STYLE-016",  # Ruff: star-arg unpacking after keyword arg — confusing call signature
    "B009": "BEST-PRACTICE-006",  # Ruff: Do not call getattr with constant attr string
    "path-traversal": "SECURITY-049",  # Semgrep: user-controlled path traversal risk
    # ── Ruff: Previously unmapped style/quality rules ──
    "E402": "IMPORT-005",  # Module level import not at top of file
    "E711": "PATTERN-003",  # Comparison to None should use `is`/`is not`
    "E712": "PATTERN-004",  # Comparison to True/False should use `is` or truthiness check
    "F601": "BEST-PRACTICE-008",  # Membership test should use `not in` not `not x in`
    "UP008": "STYLE-019",  # Use super() without arguments (PEP 3135)
    "UP015": "STYLE-020",  # Prefer pathlib alternatives to `os.path` operations
    # ── Ruff: pyupgrade rules (Phase 0 baseline gap — May 6, 2026) ──
    "UP012": "STYLE-027",  # Unnecessary call to encode as utf-8
    "UP028": "STYLE-028",  # Replace `yield` over for loop with `yield from`
    "UP045": "STYLE-029",  # Use `X | None` instead of `Optional[X]` (PEP 604)
}

# Category Mapping
CATEGORY_MAPPING = {
    "security": "security",
    "best-practice": "best-practice",
    "style-or-practice": "style",
    "style": "style",
    "dead-code": "dead-code",
    "duplication": "duplication",
    "design": "design",
}

logger = logging.getLogger(__name__)


class CanonicalFinding(BaseModel):
    """Universal finding format following ACR-QA canonical schema with Pydantic validation"""

    # Core fields
    finding_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
    fingerprint: str = ""
    canonical_rule_id: str
    original_rule_id: str
    severity: str
    category: str
    file: str
    line: int
    column: int = 0
    language: str
    message: str

    # Evidence
    evidence: dict[str, Any] = Field(
        default_factory=lambda: {
            "snippet": "",
            "context_before": [],
            "context_after": [],
        }
    )

    # Tool metadata
    tool_raw: dict[str, Any]

    # Original severity for audit trail
    original_severity: str = ""

    # Pydantic v2 configuration (replaces deprecated class Config)
    model_config = ConfigDict(arbitrary_types_allowed=True)

    @field_validator("severity")
    @classmethod
    def validate_severity(cls, v: str) -> str:
        """Validate severity is one of: high, medium, low"""
        valid_severities = {"high", "medium", "low"}
        v_lower = v.lower()
        if v_lower not in valid_severities:
            raise ValueError(f"Invalid severity '{v}'. Must be one of: {', '.join(valid_severities)}")
        return v_lower

    @field_validator("category")
    @classmethod
    def validate_category(cls, v: str) -> str:
        """Validate category is one of the known categories"""
        valid_categories = {
            "security",
            "best-practice",
            "style",
            "dead-code",
            "duplication",
            "design",
        }
        if v not in valid_categories:
            # Log warning but allow custom categories
            import sys

            sys.stderr.write(f"Warning: Unknown category '{v}'. Valid categories: {', '.join(valid_categories)}\n")
        return v

    @classmethod
    def create(
        cls,
        rule_id: str,
        file: str,
        line: int,
        severity: str,
        category: str,
        message: str,
        tool_name: str,
        tool_output: dict[str, Any],
        column: int = 0,
        canonical_rule_id: str | None = None,
    ) -> "CanonicalFinding":
        """
        Factory method to create a CanonicalFinding with intelligent severity scoring.

        This method:
        1. Maps tool-specific rule IDs to canonical IDs
        2. Detects language from file extension
        3. Applies intelligent severity scoring
        4. Returns validated Pydantic model
        """
        # Map to canonical rule ID if not provided
        if not canonical_rule_id:
            canonical_rule_id = RULE_MAPPING.get(rule_id, f"CUSTOM-{rule_id}")

        # Map to canonical category
        canonical_category = CATEGORY_MAPPING.get(category, category)

        # Detect language
        ext = Path(file).suffix.lower()
        language_map = {
            ".py": "python",
            ".js": "javascript",
            ".ts": "typescript",
            ".java": "java",
            ".cpp": "cpp",
            ".c": "c",
            ".go": "go",
            ".rs": "rust",
            ".rb": "ruby",
            ".php": "php",
            ".sh": "shell",
        }
        language = language_map.get(ext, "unknown")

        # Build tool metadata
        tool_raw = {
            "tool_name": tool_name,
            "original_rule_id": rule_id,
            "original_severity": severity,
            "original_output": tool_output,
        }

        # Apply intelligent severity scoring
        from CORE.engines.severity_scorer import SeverityScorer

        scorer = SeverityScorer()

        # Build finding dict for scoring
        finding_dict = {
            "canonical_rule_id": canonical_rule_id,
            "message": message,
            "category": canonical_category,
            "tool_raw": tool_raw,
        }

        # Score severity intelligently
        scored_severity = scorer.score(canonical_rule_id, finding_dict)

        # Create and validate the Pydantic model
        try:
            finding = cls(
                canonical_rule_id=canonical_rule_id,
                original_rule_id=rule_id,
                severity=scored_severity,
                original_severity=severity,
                category=canonical_category,
                file=file,
                line=line,
                column=column,
                language=language,
                message=message,
                tool_raw=tool_raw,
            )
            return finding
        except Exception as e:
            # Log validation error but don't crash
            logger.error(f"ERROR: Pydantic validation failed for finding: {e}")
            logger.info(f"  Rule: {canonical_rule_id}, File: {file}:{line}")
            raise

    def extract_evidence(self, context_lines: int = 3) -> None:
        """Extract code snippet and context"""
        try:
            from CORE.utils.code_extractor import extract_code_snippet

            # Get snippet with context
            full_snippet = extract_code_snippet(self.file, self.line, context_lines)

            # Parse snippet into parts
            lines = full_snippet.split("\n")

            context_before = []
            context_after = []
            snippet = ""

            for line in lines:
                if ">>>" in line:
                    # This is the issue line
                    snippet = line.split("|", 1)[1].strip() if "|" in line else line
                elif "|" in line:
                    line_num_str, code = line.split("|", 1)
                    line_num = int(line_num_str.strip())

                    if line_num < self.line:
                        context_before.append(code.rstrip())
                    elif line_num > self.line:
                        context_after.append(code.rstrip())

            self.evidence = {
                "snippet": snippet,
                "context_before": context_before[-3:] if context_before else [],  # Last 3
                "context_after": context_after[:3] if context_after else [],  # First 3
            }

        except Exception:
            # Fallback: just use the message
            self.evidence = {
                "snippet": f"# Line {self.line}",
                "context_before": [],
                "context_after": [],
            }

        # Generate fingerprint
        import hashlib

        snippet = self.evidence.get("snippet", "")[:200]
        raw = f"{self.canonical_rule_id}:{self.file}:{snippet}"
        self.fingerprint = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]

    def to_dict(self) -> dict[str, Any]:
        """Convert to dictionary for JSON serialization (uses Pydantic's model_dump)"""
        return self.model_dump()


def normalize_ruff(ruff_json) -> list[CanonicalFinding]:
    """Convert Ruff findings to canonical format"""
    findings: list[CanonicalFinding] = []

    if isinstance(ruff_json, dict):
        ruff_json = ruff_json.get("results", [])
    if not isinstance(ruff_json, list):
        return findings

    for item in ruff_json:
        if not isinstance(item, dict):
            continue
        try:
            loc = item.get("location") or {}
            finding = CanonicalFinding.create(
                rule_id=item.get("code", "UNKNOWN"),
                file=item.get("filename", "unknown"),
                line=loc.get("row", 0) if isinstance(loc, dict) else 0,
                column=loc.get("column", 0) if isinstance(loc, dict) else 0,
                severity="warning",
                category="style",
                message=item.get("message", ""),
                tool_name="ruff",
                tool_output=item,
            )
            finding.extract_evidence()
            findings.append(finding)
        except Exception:
            continue

    return findings


def normalize_semgrep(semgrep_json) -> list[CanonicalFinding]:
    """Convert Semgrep findings to canonical format"""
    findings: list[CanonicalFinding] = []

    if not isinstance(semgrep_json, dict):
        return findings
    results = semgrep_json.get("results", [])
    if not isinstance(results, list):
        return findings

    for item in results:
        if not isinstance(item, dict):
            continue
        try:
            check_id = item.get("check_id", "") or ""
            if not isinstance(check_id, str):
                check_id = str(check_id)
            rule_id = check_id.split(".")[-1] if "." in check_id else check_id
            extra = item.get("extra") or {}
            if not isinstance(extra, dict):
                extra = {}
            finding = CanonicalFinding.create(
                rule_id=rule_id,
                file=item.get("path", "unknown"),
                line=item.get("start", {}).get("line", 0) if isinstance(item.get("start"), dict) else 0,
                column=item.get("start", {}).get("col", 0) if isinstance(item.get("start"), dict) else 0,
                severity=extra.get("severity", "WARNING"),
                category=(extra.get("metadata") or {}).get("category", "security")
                if isinstance(extra.get("metadata"), dict)
                else "security",
                message=extra.get("message", ""),
                tool_name="semgrep",
                tool_output=item,
            )
            finding.extract_evidence()
            findings.append(finding)
        except Exception:
            continue

    return findings


def normalize_vulture(vulture_txt: str) -> list[CanonicalFinding]:
    """Convert Vulture text output to canonical format"""
    findings = []

    for line in vulture_txt.strip().split("\n"):
        if not line or line.startswith("#"):
            continue

        # Format: filepath:line: message (confidence%)
        parts = line.split(":", 2)
        if len(parts) < 3:
            continue

        filepath = parts[0].strip()
        try:
            lineno = int(parts[1].strip())
        except ValueError:
            lineno = 0

        message = parts[2].strip()

        # Determine rule type from message
        rule_id = "unused-code"
        if "import" in message.lower():
            rule_id = "unused-import"
        elif "variable" in message.lower():
            rule_id = "unused-variable"
        elif "function" in message.lower():
            rule_id = "unused-function"
        elif "class" in message.lower():
            rule_id = "unused-class"

        finding = CanonicalFinding.create(
            rule_id=rule_id,
            file=filepath,
            line=lineno,
            column=0,
            severity="info",
            category="dead-code",
            message=message,
            tool_name="vulture",
            tool_output={"raw_line": line},
        )
        finding.extract_evidence()
        findings.append(finding)

    return findings


def normalize_jscpd(jscpd_json: dict) -> list[CanonicalFinding]:
    """Convert jscpd findings to canonical format"""
    findings = []

    duplicates = jscpd_json.get("duplicates", [])

    for dup in duplicates:
        first_file = dup.get("firstFile", {})
        second_file = dup.get("secondFile", {})

        message = (
            f"Duplicate code block: {dup.get('lines', 0)} lines, "
            f"{dup.get('tokens', 0)} tokens. "
            f"Also found in {second_file.get('name', 'unknown')} "
            f"at line {second_file.get('start', 0)}"
        )

        finding = CanonicalFinding.create(
            rule_id="code-duplication",
            file=first_file.get("name", "unknown"),
            line=first_file.get("start", 0),
            column=0,
            severity="info",
            category="duplication",
            message=message,
            tool_name="jscpd",
            tool_output=dup,
        )
        finding.extract_evidence()
        findings.append(finding)

    return findings


def normalize_radon(radon_json: dict) -> list[CanonicalFinding]:
    """Convert Radon complexity findings to canonical format"""
    findings = []

    # Radon output format: {filename: [function_data]}
    for filepath, functions in radon_json.items():
        if not isinstance(functions, list):
            continue

        for func in functions:
            # Only flag high complexity (> 10)
            complexity = func.get("complexity", 0)

            if complexity > 10:
                # Determine severity based on complexity
                if complexity > 20:
                    severity = "high"
                elif complexity > 15:
                    severity = "medium"
                else:
                    severity = "low"

                message = (
                    f"Function '{func.get('name', 'unknown')}' has cyclomatic complexity of {complexity}. "
                    f"High complexity makes code hard to test and maintain. "
                    f"Consider refactoring into smaller functions."
                )

                finding = CanonicalFinding.create(
                    rule_id="high-complexity",
                    file=filepath,
                    line=func.get("lineno", 0),
                    column=func.get("col_offset", 0),
                    severity=severity,
                    category="design",
                    message=message,
                    tool_name="radon",
                    tool_output=func,
                )
                finding.extract_evidence()
                findings.append(finding)

    return findings


def normalize_all(outputs_dir: str = "outputs") -> list[CanonicalFinding]:
    """
    Load and normalize all tool outputs from directory

    Args:
        outputs_dir: Directory containing tool output files

    Returns:
        List of CanonicalFinding objects
    """
    all_findings = []
    outputs_path = Path(outputs_dir)

    # Load and normalize Ruff
    ruff_file = outputs_path / "ruff.json"
    if ruff_file.exists():
        try:
            with open(ruff_file) as f:
                content = f.read().strip()
                if content:  # Only parse if not empty
                    ruff_data = json.loads(content)
                    ruff_findings = normalize_ruff(ruff_data)
                    all_findings.extend(ruff_findings)
                    logger.info(f"  Normalized {len(ruff_findings)} Ruff findings")
        except json.JSONDecodeError:
            logger.info(f"  Warning: Could not parse {ruff_file}")
        except Exception as e:
            logger.error(f"  Error processing Ruff: {e}")

    # Load and normalize Semgrep
    semgrep_file = outputs_path / "semgrep.json"
    if semgrep_file.exists():
        try:
            with open(semgrep_file) as f:
                semgrep_data = json.load(f)
                semgrep_findings = normalize_semgrep(semgrep_data)
                all_findings.extend(semgrep_findings)
                logger.info(f"  Normalized {len(semgrep_findings)} Semgrep findings")
        except Exception as e:
            logger.error(f"  Error processing Semgrep: {e}")

    # Load and normalize Vulture
    vulture_file = outputs_path / "vulture.txt"
    if vulture_file.exists():
        try:
            with open(vulture_file) as f:
                vulture_data = f.read()
                vulture_findings = normalize_vulture(vulture_data)
                all_findings.extend(vulture_findings)
                logger.info(f"  Normalized {len(vulture_findings)} Vulture findings")
        except Exception as e:
            logger.error(f"  Error processing Vulture: {e}")

    # Load and normalize jscpd
    jscpd_file = outputs_path / "jscpd.json"
    if jscpd_file.exists():
        try:
            with open(jscpd_file) as f:
                jscpd_data = json.load(f)
                jscpd_findings = normalize_jscpd(jscpd_data)
                all_findings.extend(jscpd_findings)
                logger.info(f"  Normalized {len(jscpd_findings)} jscpd findings")
        except Exception as e:
            logger.error(f"  Error processing jscpd: {e}")

    # Load and normalize Radon
    radon_file = outputs_path / "radon.json"
    if radon_file.exists():
        try:
            with open(radon_file) as f:
                radon_data = json.load(f)
                radon_findings = normalize_radon(radon_data)
                all_findings.extend(radon_findings)
                logger.info(f"  Normalized {len(radon_findings)} Radon findings")
        except Exception as e:
            logger.error(f"  Error processing Radon: {e}")

    # Load and normalize Bandit
    bandit_file = outputs_path / "bandit.json"
    if bandit_file.exists():
        try:
            with open(bandit_file) as f:
                bandit_data = json.load(f)
                bandit_findings = normalize_bandit(bandit_data)
                all_findings.extend(bandit_findings)
                logger.info(f"  Normalized {len(bandit_findings)} Bandit findings")
        except Exception as e:
            logger.error(f"  Error processing Bandit: {e}")

    # Inline suppression: filter out findings with # acr-qa:ignore or # acrqa:disable
    suppressed = 0
    filtered_findings = []
    for finding in all_findings:
        try:
            file_path = finding.file if hasattr(finding, "file") else ""
            line_num = finding.line if hasattr(finding, "line") else 0
            if file_path and line_num and Path(file_path).exists():
                with open(file_path) as src:
                    lines = src.readlines()
                    if 0 < line_num <= len(lines):
                        source_line = lines[line_num - 1]
                        rule_id = finding.canonical_rule_id if hasattr(finding, "canonical_rule_id") else ""
                        # Check for blanket ignore
                        if "# acr-qa:ignore" in source_line or "# acrqa:ignore" in source_line:
                            suppressed += 1
                            continue
                        # Check for rule-specific disable
                        if "# acrqa:disable" in source_line:
                            if rule_id and rule_id in source_line:
                                suppressed += 1
                                continue
                            elif rule_id == "" or f"disable {rule_id}" in source_line:
                                suppressed += 1
                                continue
            filtered_findings.append(finding)
        except Exception:
            filtered_findings.append(finding)

    if suppressed:
        logger.info(f"  Suppressed {suppressed} findings via inline comments")

    logger.info(f"\n  Total canonical findings: {len(filtered_findings)}")
    return filtered_findings


def normalize_bandit(bandit_json) -> list[CanonicalFinding]:
    """Convert Bandit security findings to canonical format"""
    findings: list[CanonicalFinding] = []

    if not isinstance(bandit_json, dict):
        return findings

    # Bandit severity mapping
    BANDIT_SEVERITY = {"HIGH": "high", "MEDIUM": "medium", "LOW": "low"}

    results = bandit_json.get("results", [])
    if not isinstance(results, list):
        return findings

    for item in results:
        if not isinstance(item, dict):
            continue
        try:
            test_id = item.get("test_id", "UNKNOWN")
            finding = CanonicalFinding.create(
                rule_id=test_id,
                file=item.get("filename", "unknown"),
                line=item.get("line_number", 0),
                column=item.get("col_offset", 0),
                severity=BANDIT_SEVERITY.get(item.get("issue_severity", "LOW"), "low"),
                category="security",
                message=f"{item.get('issue_text', '')} (Confidence: {item.get('issue_confidence', 'UNKNOWN')})",
                tool_name="bandit",
                tool_output=item,
            )
            finding.extract_evidence()
            findings.append(finding)
        except Exception:
            continue

    return findings


if __name__ == "__main__":
    # Test normalization
    logger.info("Testing normalizer...")
    findings = normalize_all()

    if findings:
        logger.info("\nSample canonical finding:")
        logger.info(json.dumps(findings[0].to_dict(), indent=2))
