"""
Vulnerability Pattern Database - Persistent storage for discovered vulnerabilities.

This module:
1. Records vulnerability patterns discovered by red team analysis
2. Persists patterns across missions and sessions
3. Uses accumulated patterns to improve future red team prompts
4. Tracks which patterns are most common/severe
"""

import fcntl
import json
import hashlib
import logging
import sys
import threading
import time
from pathlib import Path
from dataclasses import dataclass, field, fields as dataclass_fields, asdict
from typing import List, Dict, Optional, Any
from datetime import datetime, timezone
from collections import defaultdict
from enum import Enum

logger = logging.getLogger(__name__)


def _parse_dt(s: str) -> datetime:
    """Parse ISO datetime string, normalizing tz-aware to UTC for comparison."""
    try:
        dt = datetime.fromisoformat(s)
        if dt.tzinfo is not None:
            dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
        return dt
    except (ValueError, TypeError):
        return datetime.min

# Add parent directory to path for imports — append (not insert) to avoid shadowing stdlib/site-packages
sys.path.append(str(Path(__file__).parent.parent))
from atlasforge_config import AF_DATA_DIR


class VulnerabilityCategory(Enum):
    """Categories of vulnerabilities."""
    INJECTION = "injection"
    BOUNDARY = "boundary"
    TYPE_CONFUSION = "type_confusion"
    RESOURCE_EXHAUSTION = "resource_exhaustion"
    STATE_CORRUPTION = "state_corruption"
    CONCURRENCY = "concurrency"
    ERROR_HANDLING = "error_handling"
    LOGIC_FLAW = "logic_flaw"
    AUTHENTICATION = "authentication"
    AUTHORIZATION = "authorization"
    CRYPTO = "crypto"
    CONFIG = "config"
    OTHER = "other"


@dataclass
class VulnerabilityPattern:
    """A vulnerability pattern discovered across missions."""
    pattern_id: str
    category: VulnerabilityCategory
    name: str
    description: str
    code_pattern: str  # Regex or code pattern that identifies this vuln
    example_code: str  # Example vulnerable code
    fix_guidance: str  # How to fix this type of vuln
    severity_typical: str  # Typical severity: critical/high/medium/low
    occurrences: int = 1
    missions_found_in: List[str] = field(default_factory=list)
    first_seen: str = ""
    last_seen: str = ""
    tags: List[str] = field(default_factory=list)

    def __hash__(self):
        return hash(self.pattern_id)

    def to_dict(self) -> dict:
        result = asdict(self)
        result['category'] = self.category.value
        return result

    @staticmethod
    def from_dict(data: dict) -> 'VulnerabilityPattern':
        # VDB-1 fix: shallow copy to avoid mutating caller's dict
        data = dict(data)
        # VDB-3 fix: handle already-converted enum or unrecognized category string
        cat = data.get('category')
        if isinstance(cat, VulnerabilityCategory):
            pass  # already converted
        else:
            try:
                data['category'] = VulnerabilityCategory(cat)
            except (ValueError, KeyError):
                data['category'] = VulnerabilityCategory.OTHER
        # VDB-C3-1: filter to known dataclass fields to prevent crash on extra keys
        known_fields = {f.name for f in dataclass_fields(VulnerabilityPattern)}
        filtered = {k: v for k, v in data.items() if k in known_fields}
        # Provide defaults for required positional fields to prevent TypeError on empty/partial dicts
        filtered.setdefault('pattern_id', '')
        filtered.setdefault('name', '')
        filtered.setdefault('description', '')
        filtered.setdefault('code_pattern', '')
        filtered.setdefault('example_code', '')
        filtered.setdefault('fix_guidance', '')
        filtered.setdefault('severity_typical', 'medium')
        return VulnerabilityPattern(**filtered)


@dataclass
class MissionVulnerabilityRecord:
    """Record of vulnerabilities found in a specific mission."""
    mission_id: str
    timestamp: str
    code_path: str
    patterns_found: List[str]  # Pattern IDs
    findings_count: int
    critical_count: int
    high_count: int
    medium_count: int
    low_count: int

    def to_dict(self) -> dict:
        return asdict(self)


class VulnerabilityDatabase:
    """
    Persistent database of vulnerability patterns.

    Usage:
        db = VulnerabilityDatabase()

        # Record a finding
        db.record_finding(
            mission_id="mission_123",
            category=VulnerabilityCategory.INJECTION,
            name="SQL Injection via f-string",
            description="Using f-string interpolation in SQL queries",
            code_pattern=r'f".*SELECT.*{.*}.*"',
            example_code='query = f"SELECT * FROM users WHERE id = {user_id}"',
            fix_guidance="Use parameterized queries instead",
            severity="critical",
            code_path="path/to/file.py"
        )

        # Get patterns for improving red team prompts
        patterns = db.get_common_patterns(limit=10)

        # Generate enhanced prompts
        prompt_enhancement = db.generate_prompt_enhancement()
    """

    DEFAULT_DB_PATH = AF_DATA_DIR / "vulnerability_patterns.json"

    def __init__(self, db_path: Optional[Path] = None):
        """Initialize database, loading from disk if exists."""
        self.db_path = Path(db_path) if db_path is not None else self.DEFAULT_DB_PATH
        # Cycle 2 Fix 1.5: threading lock for in-memory state mutations
        self._lock = threading.Lock()
        self._export_lock = threading.Lock()  # serialize concurrent thread exports
        self._dirty: bool = False  # red team fix: initialize before _load() may call _save()
        self.patterns: Dict[str, VulnerabilityPattern] = {}
        self.mission_records: Dict[str, MissionVulnerabilityRecord] = {}
        self.stats = {
            "total_findings": 0,
            "total_missions": 0,
            "by_category": defaultdict(int),
            "by_severity": defaultdict(int)
        }
        self._load()

    def _load(self):
        """Load database from disk."""
        if self.db_path.exists():
            try:
                with open(self.db_path, 'r') as f:
                    data = json.load(f)

                # Iter 4 Fix B3: type guard — patterns must be dict
                patterns_raw = data.get("patterns", {})
                if not isinstance(patterns_raw, dict):
                    patterns_raw = {}
                self.patterns = {
                    pid: VulnerabilityPattern.from_dict(pdata)
                    for pid, pdata in patterns_raw.items()
                }

                # VDB-C3-4: filter to known fields to prevent crash on extra keys
                # P1: provide defaults for required MissionVulnerabilityRecord fields
                _mvr_fields = {f.name for f in dataclass_fields(MissionVulnerabilityRecord)}
                _mvr_defaults = {
                    'mission_id': '', 'timestamp': '', 'code_path': '',
                    'patterns_found': [], 'findings_count': 0,
                    'critical_count': 0, 'high_count': 0, 'medium_count': 0, 'low_count': 0
                }
                self.mission_records = {}
                for mid, mdata in data.get("mission_records", {}).items():
                    filtered = {k: v for k, v in mdata.items() if k in _mvr_fields}
                    for field_name, default_val in _mvr_defaults.items():
                        # Iter 3 Fix M2: copy mutable defaults to prevent shared references
                        filtered.setdefault(field_name, list(default_val) if isinstance(default_val, list) else default_val)
                    self.mission_records[mid] = MissionVulnerabilityRecord(**filtered)

                # VDB-5: handle null/non-dict stats gracefully; filter to known keys to prevent pollution
                stats_data = data.get("stats", None)
                if not isinstance(stats_data, dict):
                    stats_data = {}
                _known_stats_keys = {"total_findings", "total_missions", "by_category", "by_severity"}
                self.stats = {k: v for k, v in stats_data.items() if k in _known_stats_keys}
                # VDB-C3-2: ensure all expected stats keys exist with defaults
                self.stats.setdefault("total_findings", 0)
                self.stats.setdefault("total_missions", 0)
                # VDB-C5-3: coerce non-numeric stats to int, reset to 0 if invalid
                for _key in ("total_findings", "total_missions"):
                    _val = self.stats[_key]
                    if not isinstance(_val, int) or isinstance(_val, bool):
                        try:
                            self.stats[_key] = int(_val)
                        except (TypeError, ValueError):
                            self.stats[_key] = 0
                # Cycle 2 Fix 1.1: per-value try/except to prevent single corrupt value
                # from wiping all loaded data via the broad except at the end
                def _safe_int_dict(raw: Any) -> defaultdict:
                    """Coerce dict values to int, skipping corrupt entries."""
                    result = defaultdict(int)
                    if not isinstance(raw, dict):
                        return result
                    for k, v in raw.items():
                        if isinstance(v, bool):
                            result[k] = int(v)  # Cycle 3 Fix P2c: coerce bool to int (0/1) for consistency
                            continue
                        try:
                            result[k] = int(v)
                        except (TypeError, ValueError):
                            logger.warning("Corrupt stats value for key %r: %r — defaulting to 0", k, v)
                            result[k] = 0
                    return result
                self.stats["by_category"] = _safe_int_dict(self.stats.get("by_category", {}))
                self.stats["by_severity"] = _safe_int_dict(self.stats.get("by_severity", {}))
            except json.JSONDecodeError as e:
                # V2: corrupt JSON — reset to clean state, don't leave partial data
                logger.warning("Corrupt vulnerability database %s: %s", self.db_path, e)
                self._reset_state()
            except Exception as e:
                # V2: other errors (PermissionError, UnicodeDecodeError, etc.)
                logger.warning("Could not load vulnerability database: %s", e)
                self._reset_state()

    def _reset_state(self):
        """Reset all in-memory state to clean defaults."""
        self.patterns = {}
        self.mission_records = {}
        self.stats = {
            "total_findings": 0,
            "total_missions": 0,
            "by_category": defaultdict(int),
            "by_severity": defaultdict(int)
        }

    def _build_snapshot(self) -> dict:
        """Build a serializable snapshot of current state.

        Caller MUST hold self._lock to ensure a consistent view of in-memory state.
        The returned dict is a plain JSON-serializable structure (no live references).
        """
        return {
            "patterns": {pid: p.to_dict() for pid, p in self.patterns.items()},
            "mission_records": {mid: m.to_dict() for mid, m in self.mission_records.items()},
            "stats": {
                "total_findings": self.stats["total_findings"],
                "total_missions": self.stats["total_missions"],
                "by_category": dict(self.stats["by_category"]),
                "by_severity": dict(self.stats["by_severity"]),
            },
            "last_updated": datetime.now().isoformat(),
        }

    def _save(self, data: Optional[dict] = None):
        """Save database to disk with advisory file locking for concurrent safety.

        Cycle 2 Fix (flock deadlock): _save() is called while self._lock is held.
        Using fcntl.LOCK_EX (blocking) would stall ALL threads in this process if
        another process holds the file lock. Fix: LOCK_EX|LOCK_NB with bounded retry.

        Cycle 2 Fix (state divergence): self._dirty=True before write; False on success.

        Cycle 3 Fix (I/O under lock / split-brain): Accepts an optional pre-built
        *data* snapshot so callers can release self._lock BEFORE disk I/O.
        When *data* is None the method builds from live state (legacy path — caller
        must still hold self._lock for consistency).

        Cycle 5 Fix: when data=None (legacy path), caller MUST hold self._lock.
        Defensively detect if caller forgot: try to acquire non-blocking; if we get
        it, caller didn't hold it — warn and release.
        """
        self.db_path.parent.mkdir(parents=True, exist_ok=True)

        if data is None:
            # Legacy path: caller MUST hold self._lock for a consistent snapshot.
            # No TOCTOU lock check here — acquire/release to test creates a race window.
            # Legacy path: caller holds self._lock, build snapshot from live state
            data = {
                "patterns": {pid: p.to_dict() for pid, p in self.patterns.items()},
                "mission_records": {mid: m.to_dict() for mid, m in self.mission_records.items()},
                "stats": {
                    "total_findings": self.stats["total_findings"],
                    "total_missions": self.stats["total_missions"],
                    "by_category": dict(self.stats["by_category"]),
                    "by_severity": dict(self.stats["by_severity"]),
                },
                "last_updated": datetime.now().isoformat(),
            }

        # V1: atomic write via temp file + rename to prevent corruption on crash
        # Cycle 2: LOCK_EX|LOCK_NB with bounded retry — never block indefinitely
        lock_path = self.db_path.with_suffix('.lock')
        tmp_path = self.db_path.with_suffix('.tmp')
        _max_wait, _interval, _elapsed = 10.0, 0.05, 0.0
        with open(lock_path, 'w') as lock_file:
            while True:
                try:
                    fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
                    break
                except BlockingIOError:
                    if _elapsed >= _max_wait:
                        raise TimeoutError(
                            f"Could not acquire file lock on {lock_path} after {_max_wait}s"
                        )
                    time.sleep(_interval)
                    _elapsed += _interval
            try:
                # _dirty invariant:
                #   True  = in-memory state may diverge from disk (write in flight or failed)
                #   False = in-memory matches disk
                # Set True BEFORE writing (write may fail partway through);
                # set False ONLY after the atomic replace succeeds.
                # Callers that observe _dirty=True after an exception can detect divergence
                # and invalidate / reload from disk.
                self._dirty = True  # set after lock acquired — write attempt begins now
                with open(tmp_path, 'w') as f:
                    json.dump(data, f, indent=2)
                tmp_path.replace(self.db_path)
                self._dirty = False  # write succeeded — in-memory matches disk
            except Exception:
                # Iter 3 Fix L3: clean up stale .tmp on json.dump failure
                tmp_path.unlink(missing_ok=True)
                raise  # _dirty stays True — caller can detect divergence
            finally:
                fcntl.flock(lock_file, fcntl.LOCK_UN)
        lock_path.unlink(missing_ok=True)

    def _generate_pattern_id(
        self,
        category: VulnerabilityCategory,
        name: str,
        code_pattern: str
    ) -> str:
        """Generate unique pattern ID from components."""
        content = f"{category.value}:{name}:{code_pattern}"
        # VDB-6: extend from 16 (64-bit) to 32 (128-bit) to reduce collision risk
        return hashlib.sha256(content.encode()).hexdigest()[:32]

    def record_finding(
        self,
        mission_id: str,
        category: VulnerabilityCategory,
        name: str,
        description: str,
        code_pattern: str,
        example_code: str,
        fix_guidance: str,
        severity: str,
        code_path: str,
        tags: Optional[List[str]] = None
    ) -> VulnerabilityPattern:
        """
        Record a vulnerability finding.

        If the pattern already exists, updates occurrence count.
        If new, creates a new pattern.

        Returns the pattern (new or existing).
        """
        # VDB-C3-5: validate required arguments
        if mission_id is None or not isinstance(mission_id, str) or not mission_id.strip():
            raise ValueError(f"mission_id must be a non-empty string, got {mission_id!r}")
        if severity is None or not isinstance(severity, str) or not severity.strip():
            raise ValueError(f"severity must be a non-empty string, got {severity!r}")
        # VDB-4: validate category is proper enum
        if not isinstance(category, VulnerabilityCategory):
            raise ValueError(f"category must be VulnerabilityCategory enum, got {type(category).__name__}")
        pattern_id = self._generate_pattern_id(category, name, code_pattern)
        now = datetime.now().isoformat()

        # Cycle 2 Fix 1.5: protect in-memory state mutations with threading lock
        with self._lock:
            if pattern_id in self.patterns:
                # Update existing pattern
                pattern = self.patterns[pattern_id]
                pattern.occurrences += 1
                pattern.last_seen = now
                if mission_id not in pattern.missions_found_in:
                    pattern.missions_found_in.append(mission_id)
                if tags:
                    pattern.tags = list(set(pattern.tags + tags))
            else:
                # Create new pattern
                pattern = VulnerabilityPattern(
                    pattern_id=pattern_id,
                    category=category,
                    name=name,
                    description=description,
                    code_pattern=code_pattern,
                    example_code=example_code,
                    fix_guidance=fix_guidance,
                    severity_typical=severity,
                    occurrences=1,
                    missions_found_in=[mission_id],
                    first_seen=now,
                    last_seen=now,
                    tags=tags or []
                )
                self.patterns[pattern_id] = pattern

            # Update stats
            self.stats["total_findings"] += 1
            self.stats["by_category"][category.value] += 1
            self.stats["by_severity"][severity] += 1

            # Cycle 3 Fix (I/O under lock): build snapshot while still holding lock
            # (data is consistent here), then release lock before the disk write.
            snapshot = self._build_snapshot()
        # Lock released — do disk I/O outside the lock to unblock concurrent readers.
        self._save(snapshot)
        return pattern

    def record_mission(
        self,
        mission_id: str,
        code_path: str,
        patterns_found: List[str],
        severity_counts: Dict[str, int]
    ):
        """Record a mission's vulnerability scan results."""
        # Cycle 3 Fix P2a: validate mission_id type and value
        if not isinstance(mission_id, str):
            raise TypeError(f"mission_id must be a non-empty string, got {type(mission_id).__name__}: {mission_id!r}")
        if not mission_id.strip():
            raise ValueError("mission_id must be a non-empty string")
        # Validate code_path: must be a non-None string
        if code_path is None or not isinstance(code_path, str):
            raise TypeError(f"code_path must be a string, got {type(code_path).__name__}: {code_path!r}")
        # Validate patterns_found: must be a list
        if not isinstance(patterns_found, list):
            raise TypeError(f"patterns_found must be a list, got {type(patterns_found).__name__}")
        # VDB-C5-2: validate severity_counts values are non-negative integers
        if not isinstance(severity_counts, dict):
            raise TypeError(f"severity_counts must be a dict, got {type(severity_counts).__name__}")
        for k, v in severity_counts.items():
            if isinstance(v, bool) or not isinstance(v, int):
                raise TypeError(f"severity_counts[{k!r}] must be int, got {type(v).__name__}: {v!r}")
            if v < 0:
                raise ValueError(f"severity_counts[{k!r}] must be non-negative, got {v}")
        record = MissionVulnerabilityRecord(
            mission_id=mission_id,
            timestamp=datetime.now().isoformat(),
            code_path=code_path,
            patterns_found=patterns_found,
            findings_count=sum(severity_counts.values()),
            critical_count=severity_counts.get("critical", 0),
            high_count=severity_counts.get("high", 0),
            medium_count=severity_counts.get("medium", 0),
            low_count=severity_counts.get("low", 0)
        )
        # Iter 3 Fix H5: protect in-memory mutations with lock (matches record_finding)
        with self._lock:
            # Bug 15: check BEFORE storing, otherwise mission_id is always found
            if mission_id not in self.mission_records:
                self.stats["total_missions"] += 1
            self.mission_records[mission_id] = record
            # Cycle 3 Fix (I/O under lock): snapshot under lock, write outside.
            snapshot = self._build_snapshot()
        self._save(snapshot)

    def get_pattern(self, pattern_id: str) -> Optional[VulnerabilityPattern]:
        """Get a specific pattern by ID."""
        # Iter 4 Fix B1: protect reads with threading lock
        with self._lock:
            return self.patterns.get(pattern_id)

    def get_patterns_by_category(
        self,
        category: VulnerabilityCategory
    ) -> List[VulnerabilityPattern]:
        """Get all patterns in a category."""
        # Iter 4 Fix B1: protect reads with threading lock
        with self._lock:
            return [p for p in self.patterns.values() if p.category == category]

    def get_common_patterns(self, limit: int = 10) -> List[VulnerabilityPattern]:
        """Get most common patterns sorted by occurrence count."""
        # V3: type guard — reject non-int types (float, None, str, bool)
        if isinstance(limit, bool) or not isinstance(limit, int):
            raise TypeError(f"limit must be int, got {type(limit).__name__}")
        limit = max(0, limit)  # VDB-C3-3: clamp negative limit
        # Iter 4 Fix B1: protect reads with threading lock
        with self._lock:
            return sorted(
                self.patterns.values(),
                key=lambda p: p.occurrences,
                reverse=True
            )[:limit]

    def get_severe_patterns(self, limit: int = 10) -> List[VulnerabilityPattern]:
        """Get patterns sorted by severity (critical first)."""
        # Cycle 2 V3-sibling: type guard matching get_common_patterns
        if isinstance(limit, bool) or not isinstance(limit, int):
            raise TypeError(f"limit must be int, got {type(limit).__name__}")
        limit = max(0, limit)  # VDB-C3-3: clamp negative limit
        severity_order = {"critical": 4, "high": 3, "medium": 2, "low": 1}
        # Iter 4 Fix B1: protect reads with threading lock
        with self._lock:
            return sorted(
                self.patterns.values(),
                key=lambda p: severity_order.get(p.severity_typical, 0),
                reverse=True
            )[:limit]

    def get_recent_patterns(self, limit: int = 10) -> List[VulnerabilityPattern]:
        """Get most recently seen patterns."""
        # Cycle 2 V3-sibling: type guard matching get_common_patterns
        if isinstance(limit, bool) or not isinstance(limit, int):
            raise TypeError(f"limit must be int, got {type(limit).__name__}")
        limit = max(0, limit)  # VDB-C3-3: clamp negative limit
        # Iter 4 Fix B1: protect reads with threading lock
        with self._lock:
            return sorted(
                self.patterns.values(),
                key=lambda p: _parse_dt(p.last_seen),
                reverse=True
            )[:limit]

    def generate_prompt_enhancement(self, max_patterns: int = 5) -> str:
        """
        Generate an enhancement for red team prompts based on common patterns.

        This adds context about commonly found vulnerabilities to help
        the red team agent focus on real-world issues.
        """
        common = self.get_common_patterns(max_patterns)

        if not common:
            return ""

        lines = [
            "\n## Common Vulnerability Patterns (from historical data)",
            "Based on previous analysis, pay special attention to:",
            ""
        ]

        for i, pattern in enumerate(common, 1):
            lines.append(f"{i}. **{pattern.name}** ({pattern.category.value})")
            lines.append(f"   - Found {pattern.occurrences} times across {len(pattern.missions_found_in)} missions")
            lines.append(f"   - Look for: {pattern.code_pattern}")
            lines.append(f"   - Example: `{pattern.example_code[:80]}...`" if len(pattern.example_code) > 80 else f"   - Example: `{pattern.example_code}`")
            lines.append("")

        return "\n".join(lines)

    def get_statistics(self) -> Dict[str, Any]:
        """Get database statistics."""
        # Iter 4 Fix B1: protect reads with threading lock
        with self._lock:
            return {
                "total_patterns": len(self.patterns),
                "total_findings": self.stats["total_findings"],
                "total_missions": self.stats["total_missions"],
                "by_category": dict(self.stats["by_category"]),
                "by_severity": dict(self.stats["by_severity"]),
                "most_common": [
                    {"name": p.name, "count": p.occurrences}
                    for p in sorted(self.patterns.values(), key=lambda p: p.occurrences, reverse=True)[:5]
                ]
            }

    def export_patterns(self, filepath: Path, allowed_dir: Optional[Path] = None) -> None:
        """Export all patterns to a file.

        Args:
            filepath: Destination path for export.
            allowed_dir: If provided, filepath must resolve within this directory.
                         Prevents path traversal attacks on user-supplied paths.
        """
        # VDB-C5-1: sanitize path to prevent traversal attacks
        filepath = Path(filepath).resolve()
        if allowed_dir is not None:
            allowed_dir = Path(allowed_dir).resolve()
            # Iter 3 Fix M3: use relative_to() instead of string-prefix check
            try:
                filepath.relative_to(allowed_dir)
            except ValueError:
                raise ValueError(f"export path {filepath} is outside allowed directory {allowed_dir}")
        # Cycle 2 Fix 1.4: removed dead '..' check — Path.resolve() already eliminates '..'
        # Iter 4 Fix B2 / Cycle 6 Fix: _export_lock serializes concurrent thread exports;
        # _lock is held briefly for snapshot only (not during disk I/O).
        with self._export_lock:
            with self._lock:
                export_data = {
                    "patterns": [p.to_dict() for p in self.patterns.values()],
                    "stats": {
                        "total_patterns": len(self.patterns),
                        "total_findings": self.stats["total_findings"],
                        "total_missions": self.stats["total_missions"],
                        "by_category": dict(self.stats["by_category"]),
                        "by_severity": dict(self.stats["by_severity"]),
                    },
                    "exported_at": datetime.now().isoformat()
                }
            # Disk write is outside self._lock but inside _export_lock
            # Cycle 2 Fix 1.3: add file locking matching _save() pattern
            # Use unique tmp name per call (pid + object id) to prevent concurrent-call race on shared .tmp
            import os as _os
            lock_path = filepath.with_suffix('.export_lock')
            tmp_path = filepath.with_name(f"{filepath.stem}.tmp.{_os.getpid()}.{id(export_data)}")
            with open(lock_path, 'w') as lock_file:
                fcntl.flock(lock_file, fcntl.LOCK_EX)
                try:
                    with open(tmp_path, 'w') as f:
                        json.dump(export_data, f, indent=2)
                    tmp_path.replace(filepath)
                except BaseException:
                    # Iter 4 Fix B2: clean up stale .tmp on failure
                    tmp_path.unlink(missing_ok=True)
                    raise
                finally:
                    fcntl.flock(lock_file, fcntl.LOCK_UN)
            lock_path.unlink(missing_ok=True)

    def clear(self):
        """Clear all data (use with caution!)."""
        # RT-04 Fix: hold self._lock for the entire operation so no concurrent
        # record_finding() can insert between disk write and in-memory reset.
        with self._lock:
            empty_snapshot = {
                "patterns": {},
                "mission_records": {},
                "stats": {
                    "total_findings": 0,
                    "total_missions": 0,
                    "by_category": {},
                    "by_severity": {},
                },
                "last_updated": datetime.now().isoformat(),
            }
            self._save(empty_snapshot)
            self.patterns = {}
            self.mission_records = {}
            self.stats = {
                "total_findings": 0,
                "total_missions": 0,
                "by_category": defaultdict(int),
                "by_severity": defaultdict(int),
            }


def record_red_team_findings(
    db: VulnerabilityDatabase,
    mission_id: str,
    code_path: str,
    findings: List[Dict[str, Any]]
):
    """
    Helper function to record findings from a red team result.

    Args:
        db: VulnerabilityDatabase instance
        mission_id: Current mission ID
        code_path: Path to code being analyzed
        findings: List of finding dicts from RedTeamResult
    """
    # Cycle 3 Fix P2b: validate mission_id before passing to db methods
    if not isinstance(mission_id, str):
        raise TypeError(f"mission_id must be a non-empty string, got {type(mission_id).__name__}: {mission_id!r}")
    if not mission_id.strip():
        raise ValueError("mission_id must be a non-empty string")
    # Iter 3 Fix M4: type guard for findings parameter
    if not isinstance(findings, list):
        raise TypeError(f"findings must be a list, got {type(findings).__name__}")
    patterns_found = []
    severity_counts = defaultdict(int)

    for finding in findings:
        # VDB-C5-4: wrap each finding in try/except so one malformed finding
        # doesn't abort the entire batch
        try:
            # Map red team categories to vulnerability categories
            category_map = {
                "injection": VulnerabilityCategory.INJECTION,
                "boundary": VulnerabilityCategory.BOUNDARY,
                "type_confusion": VulnerabilityCategory.TYPE_CONFUSION,
                "resource": VulnerabilityCategory.RESOURCE_EXHAUSTION,
                "state_corruption": VulnerabilityCategory.STATE_CORRUPTION,
                "concurrency": VulnerabilityCategory.CONCURRENCY,
                "error_handling": VulnerabilityCategory.ERROR_HANDLING,
                "logic": VulnerabilityCategory.LOGIC_FLAW,
            }

            category = category_map.get(
                finding.get("category", "other"),
                VulnerabilityCategory.OTHER
            )

            pattern = db.record_finding(
                mission_id=mission_id,
                category=category,
                name=finding.get("title", "Unknown"),
                description=finding.get("description", ""),
                code_pattern=finding.get("affected_code", ""),
                example_code=(lambda steps: steps[0] if steps else "")(
                    finding.get("reproduction_steps", []) if isinstance(finding.get("reproduction_steps"), list)
                    else [finding.get("reproduction_steps", "")] if isinstance(finding.get("reproduction_steps"), str)
                    else []
                ),
                fix_guidance=finding.get("suggested_fix", ""),
                severity=finding.get("severity", "medium"),
                code_path=code_path,
                tags=[category.value]
            )
            patterns_found.append(pattern.pattern_id)
            severity_counts[finding.get("severity", "medium")] += 1
        except Exception as e:
            logger.warning("Skipping malformed finding: %s (error: %s)", finding, e)

    # C3-3b: detect total failure — all findings skipped due to errors
    if findings and not patterns_found:
        logger.error(
            "record_red_team_findings: ALL %d findings were skipped due to errors "
            "(mission_id=%r, code_path=%r)",
            len(findings), mission_id, code_path
        )

    # Record mission summary
    db.record_mission(
        mission_id=mission_id,
        code_path=code_path,
        patterns_found=patterns_found,
        severity_counts=dict(severity_counts)
    )


if __name__ == "__main__":
    # Self-test
    print("Vulnerability Database - Self Test")
    print("=" * 50)

    # Use temp location for test
    test_db_path = Path("/tmp/test_vuln_db.json")
    db = VulnerabilityDatabase(db_path=test_db_path)

    # Clear any existing test data
    db.clear()

    # Record some test findings
    print("\n1. Recording test findings...")
    db.record_finding(
        mission_id="test_mission_1",
        category=VulnerabilityCategory.INJECTION,
        name="SQL Injection via f-string",
        description="Using f-string interpolation in SQL queries allows injection",
        code_pattern=r'f".*SELECT.*{.*}.*"',
        example_code='query = f"SELECT * FROM users WHERE id = {user_id}"',
        fix_guidance="Use parameterized queries with ? placeholders",
        severity="critical",
        code_path="test/file.py"
    )

    db.record_finding(
        mission_id="test_mission_1",
        category=VulnerabilityCategory.BOUNDARY,
        name="Missing array bounds check",
        description="Accessing array without checking length",
        code_pattern=r'\[.*\](?!.*if.*len)',
        example_code='return items[index]',
        fix_guidance="Check array length before accessing",
        severity="high",
        code_path="test/file.py"
    )

    # Record same pattern again (different mission)
    db.record_finding(
        mission_id="test_mission_2",
        category=VulnerabilityCategory.INJECTION,
        name="SQL Injection via f-string",
        description="Using f-string interpolation in SQL queries allows injection",
        code_pattern=r'f".*SELECT.*{.*}.*"',
        example_code='query = f"SELECT * FROM users WHERE id = {user_id}"',
        fix_guidance="Use parameterized queries with ? placeholders",
        severity="critical",
        code_path="test/other_file.py"
    )

    print("\n2. Database Statistics:")
    stats = db.get_statistics()
    print(f"   Total patterns: {stats['total_patterns']}")
    print(f"   Total findings: {stats['total_findings']}")
    print(f"   By category: {stats['by_category']}")
    print(f"   By severity: {stats['by_severity']}")

    print("\n3. Common Patterns:")
    for pattern in db.get_common_patterns(5):
        print(f"   - {pattern.name}: {pattern.occurrences} occurrences")

    print("\n4. Prompt Enhancement:")
    enhancement = db.generate_prompt_enhancement()
    print(enhancement[:500] + "..." if len(enhancement) > 500 else enhancement)

    print("\n5. Persistence Test:")
    # Reload from disk
    db2 = VulnerabilityDatabase(db_path=test_db_path)
    print(f"   Loaded {len(db2.patterns)} patterns from disk")
    assert len(db2.patterns) == 2, "Persistence failed!"

    # Cleanup
    test_db_path.unlink(missing_ok=True)

    print("\nVulnerability database self-test complete!")
