"""
PromptShield Middleware SDK
═══════════════════════════
Drop-in prompt injection firewall for any LLM-powered application.

Quickstart — 3 lines:
    from promptshield import PromptShield
    shield = PromptShield()
    safe_prompt = shield.check(user_input)   # raises InjectionBlocked if unsafe

Integration patterns supported:
    1. Direct check           — shield.check(text)
    2. Python decorator       — @shield.protect
    3. FastAPI middleware      — app.add_middleware(PromptShieldMiddleware)
    4. Flask before_request   — shield.init_flask(app)
    5. OpenAI wrapper         — shield.wrap_openai(client)
    6. LangChain tool         — shield.as_langchain_tool()
    7. Async support          — await shield.acheck(text)

Policy:
    STRICT  — only SAFE passes      (score < 0.20)   ← default
    NORMAL  — SAFE + LOW passes     (score < 0.40)
    LENIENT — up to MEDIUM passes   (score < 0.60)

Action:
    BLOCK    — raise InjectionBlocked immediately        ← default
    SANITIZE — strip injections, return cleaned text
    FLAG     — pass through but mark result as suspicious
    LOG_ONLY — pass everything, just log the event

Author: Joseph Mutua — PromptShield v1.0.0
"""

from __future__ import annotations

import asyncio
import functools
import json
import logging
import time
from dataclasses import dataclass, field, asdict
from enum import Enum
from typing import Any, Callable, Dict, List, Optional, Union

logger = logging.getLogger("promptshield")


# ─────────────────────────────────────────────────────────────────────────────
# Enums & Config
# ─────────────────────────────────────────────────────────────────────────────

class Policy(str, Enum):
    STRICT  = "strict"   # score < 0.20 passes
    NORMAL  = "normal"   # score < 0.40 passes
    LENIENT = "lenient"  # score < 0.60 passes


class Action(str, Enum):
    BLOCK    = "block"     # raise InjectionBlocked immediately
    SANITIZE = "sanitize"  # strip injections, return cleaned text
    FLAG     = "flag"      # pass but attach metadata to result
    LOG_ONLY = "log_only"  # pass silently, just log the event


# Score thresholds per policy
_POLICY_THRESHOLDS = {
    Policy.STRICT:  0.20,
    Policy.NORMAL:  0.40,
    Policy.LENIENT: 0.60,
}

# Threat levels that trigger sanitization (when action=SANITIZE)
# LOW and MEDIUM threats are passed through with a warning annotation.
# HIGH and CRITICAL threats have their injection sentences stripped.
_SANITIZE_LEVELS = {"high", "critical"}


# ─────────────────────────────────────────────────────────────────────────────
# Exceptions
# ─────────────────────────────────────────────────────────────────────────────

class InjectionBlocked(Exception):
    """
    Raised by shield.check() when action=BLOCK and a prompt is unsafe.
    Carries the full ShieldResult so the caller can inspect every detail.

    Usage:
        try:
            safe = shield.check(user_input)
        except InjectionBlocked as e:
            print(e.score)          # 0.855
            print(e.threat_level)   # "critical"
            print(e.reason)         # human-readable summary
            log_to_db(e.result.to_dict())   # full layer breakdown, matches, etc.
    """
    def __init__(self, result: "ShieldResult"):
        self.result       = result
        self.score        = result.score
        self.threat_level = result.threat_level
        self.reason       = result.summary
        super().__init__(
            f"PromptShield blocked prompt | "
            f"score={result.score:.3f} | "
            f"level={result.threat_level} | "
            f"{result.summary}"
        )


# ─────────────────────────────────────────────────────────────────────────────
# Result dataclass
# ─────────────────────────────────────────────────────────────────────────────

@dataclass
class ShieldResult:
    """
    The result of every scan.
    Returned by shield.scan() always.
    Attached to InjectionBlocked when shield.check() raises.
    """
    allowed:      bool              # True = prompt is safe under active policy
    score:        float             # 0.0–1.0  injection probability
    threat_level: str               # safe | low | medium | high | critical
    action_taken: str               # passed | blocked | sanitized | flagged | logged | allowlisted
    original:     str               # the original, unmodified input text
    safe_content: str               # text to pass to your LLM (cleaned if sanitized)
    summary:      str               # human-readable verdict
    scan_ms:      float             # wall-clock scan time in milliseconds
    matches:      List[Dict]        # all signals that fired, across all 4 layers
    policy:       str               # policy that was active during this scan
    layer_scores: Dict[str, float]  # per-layer scores {"pattern": 0.95, ...}

    def to_dict(self) -> Dict:
        """Return a plain dict — JSON-serializable for database storage."""
        return asdict(self)

    def to_json(self) -> str:
        """Return a formatted JSON string — ready for logging or API responses."""
        return json.dumps(self.to_dict(), indent=2)

    @property
    def is_injection(self) -> bool:
        """True if any threat was detected (threat_level is not 'safe')."""
        return self.threat_level != "safe"

    @property
    def layer_breakdown(self) -> str:
        """
        One-line string showing all four layer scores.
        Example: "L1=0.95 | L2=0.73 | L3=0.57 | L4=0.73"
        Useful for logging and dashboards.
        """
        return (
            f"L1={self.layer_scores.get('pattern',    0):.2f} | "
            f"L2={self.layer_scores.get('heuristic',  0):.2f} | "
            f"L3={self.layer_scores.get('semantic',   0):.2f} | "
            f"L4={self.layer_scores.get('linguistic', 0):.2f}"
        )


# ─────────────────────────────────────────────────────────────────────────────
# Core PromptShield class
# ─────────────────────────────────────────────────────────────────────────────

class PromptShield:
    """
    Main entry point for the PromptShield SDK.
    Instantiate once at application startup and reuse everywhere.

    Args:
        policy:    How strict the filtering is (default: STRICT — score < 0.20 passes)
        action:    What to do when an attack is detected (default: BLOCK — raises InjectionBlocked)
        on_block:  Optional callback fired when a prompt is blocked → fn(result: ShieldResult)
        on_flag:   Optional callback fired when a prompt is flagged → fn(result: ShieldResult)
        log_level: Python logging level for shield events (default: WARNING)
        allowlist: Substrings that bypass scanning entirely. Any text containing one of
                   these substrings skips all four detection layers.
                   Example: allowlist=["joseph", "portfolio", "resume"]

    Example:
        shield = PromptShield(
            policy   = Policy.STRICT,
            action   = Action.BLOCK,
            on_block = lambda r: save_attack_to_db(r.to_dict()),
            allowlist= ["joseph", "portfolio"],
        )
    """

    def __init__(
        self,
        policy:    Policy              = Policy.STRICT,
        action:    Action              = Action.BLOCK,
        on_block:  Optional[Callable]  = None,
        on_flag:   Optional[Callable]  = None,
        log_level: int                 = logging.WARNING,
        allowlist: Optional[List[str]] = None,
    ):
        self.policy     = policy
        self.action     = action
        self.on_block   = on_block
        self.on_flag    = on_flag
        self._threshold = _POLICY_THRESHOLDS[policy]
        self._allowlist = [s.lower() for s in (allowlist or [])]

        logging.basicConfig(level=log_level)
        logger.setLevel(log_level)

        # Engine is lazy-loaded on first scan call
        self._engine: Optional[_DetectionEngine] = None

        logger.info(
            f"PromptShield initialised | policy={policy.value} | "
            f"action={action.value} | threshold={self._threshold} | "
            f"allowlist={len(self._allowlist)} items"
        )

    def _get_engine(self) -> "_DetectionEngine":
        if self._engine is None:
            self._engine = _DetectionEngine()
        return self._engine

    # ── Primary Public API ─────────────────────────────────────────────────

    def check(self, text: str) -> str:
        """
        Scan text and return safe content, or raise InjectionBlocked if unsafe.

        This is the primary method for most integrations:
            safe_text = shield.check(user_input)
            response  = call_llm(safe_text)

        Returns:
            str — original text (if safe) or sanitized text (if action=SANITIZE)

        Raises:
            InjectionBlocked — if the prompt is unsafe and action=BLOCK
        """
        result = self._scan(text)

        if result.allowed:
            return result.safe_content

        # Fire on_block callback (errors in callback must not crash the scan)
        if self.on_block:
            try:
                self.on_block(result)
            except Exception as cb_err:
                logger.error(f"on_block callback raised an error: {cb_err}", exc_info=True)

        if self.action == Action.BLOCK:
            raise InjectionBlocked(result)

        # SANITIZE, FLAG, LOG_ONLY — all return safe_content (cleaned or original)
        return result.safe_content

    def scan(self, text: str) -> ShieldResult:
        """
        Scan text and always return a ShieldResult without raising.

        Use this when you want to inspect the result before deciding:
            result = shield.scan(user_input)
            if not result.allowed:
                log_attack(result.to_dict())
                return {"error": result.summary}, 400
            response = call_llm(result.safe_content)
        """
        return self._scan(text)

    async def acheck(self, text: str) -> str:
        """
        Async version of check(). Runs detection in a thread pool so it
        does not block the event loop.

        Usage:
            safe = await shield.acheck(user_input)
        """
        loop = asyncio.get_event_loop()
        return await loop.run_in_executor(None, self.check, text)

    async def ascan(self, text: str) -> ShieldResult:
        """
        Async version of scan(). Runs detection in a thread pool.

        Usage:
            result = await shield.ascan(user_input)
        """
        loop = asyncio.get_event_loop()
        return await loop.run_in_executor(None, self.scan, text)

    # ── Decorator ──────────────────────────────────────────────────────────

    def protect(self, param: str = "prompt"):
        """
        Decorator that auto-scans a named function parameter before the
        function body runs. Works on both sync and async functions.

        Args:
            param: name of the string parameter to scan (default: "prompt")

        Example:
            @shield.protect(param="user_message")
            def generate(user_message: str) -> str:
                return call_llm(user_message)   # only runs if user_message is safe

            @shield.protect(param="prompt")
            async def async_generate(prompt: str) -> str:
                return await async_llm(prompt)
        """
        def decorator(func: Callable) -> Callable:

            @functools.wraps(func)
            def wrapper(*args, **kwargs):
                if param in kwargs:
                    kwargs[param] = self.check(kwargs[param])
                return func(*args, **kwargs)

            @functools.wraps(func)
            async def async_wrapper(*args, **kwargs):
                if param in kwargs:
                    kwargs[param] = await self.acheck(kwargs[param])
                return await func(*args, **kwargs)

            return async_wrapper if asyncio.iscoroutinefunction(func) else wrapper

        return decorator

    # ── Framework integrations ─────────────────────────────────────────────

    def init_flask(self, app: Any, scan_fields: List[str] = None) -> None:
        """
        Register PromptShield as a Flask before_request hook.
        Scans configured JSON body fields before every POST/PUT/PATCH request.

        Args:
            app:         Flask app instance
            scan_fields: JSON body fields to scan
                         (default: ["message", "prompt", "input", "query", "text", "content"])

        Example:
            app = Flask(__name__)
            shield.init_flask(app)          # one line — all POST routes protected
            shield.init_flask(app, scan_fields=["message", "prompt"])  # explicit fields
        """
        fields = scan_fields or ["message", "prompt", "input", "query", "text", "content"]

        @app.before_request
        def _promptshield_before_request():
            try:
                from flask import request, jsonify
                if request.method not in ("POST", "PUT", "PATCH"):
                    return
                if not request.is_json:
                    return
                data = request.get_json(silent=True) or {}
                for field_name in fields:
                    value = data.get(field_name)
                    if not isinstance(value, str):
                        continue
                    result = self._scan(value)
                    if not result.allowed:
                        if self.on_block:
                            try:
                                self.on_block(result)
                            except Exception:
                                pass
                        logger.warning(
                            f"PromptShield [FLASK BLOCKED] field={field_name!r} "
                            f"score={result.score:.3f} level={result.threat_level}"
                        )
                        return jsonify({
                            "error":        "Request blocked by PromptShield",
                            "threat_level": result.threat_level,
                            "score":        result.score,
                            "reason":       result.summary,
                        }), 400
            except Exception as e:
                logger.error(f"PromptShield Flask hook error: {e}", exc_info=True)

        logger.info(f"PromptShield registered as Flask before_request hook | fields={fields}")

    def wrap_openai(self, client: Any) -> Any:
        """
        Wrap an OpenAI client so every chat completion is scanned first.
        Patches client.chat.completions.create() in place.
        Works with openai >= 1.0.0.

        Example:
            import openai
            client = openai.OpenAI(api_key="sk-...")
            client = shield.wrap_openai(client)

            # From here, nothing changes in the rest of your codebase.
            response = client.chat.completions.create(
                model    = "gpt-4o",
                messages = [{"role": "user", "content": user_input}],
            )
            # user_input is scanned before the API call is made.
            # If it's an attack, InjectionBlocked is raised before OpenAI is called.
        """
        shield = self
        original_create = client.chat.completions.create

        def shielded_create(*args, **kwargs):
            messages = kwargs.get("messages", [])
            # Find the last user message and scan it
            for i in range(len(messages) - 1, -1, -1):
                if messages[i].get("role") == "user":
                    content = messages[i].get("content", "")
                    if isinstance(content, str):
                        safe_content = shield.check(content)
                        messages[i] = {**messages[i], "content": safe_content}
                    break
            kwargs["messages"] = messages
            return original_create(*args, **kwargs)

        client.chat.completions.create = shielded_create
        logger.info("PromptShield patched OpenAI client.chat.completions.create")
        return client

    def as_langchain_tool(self) -> Any:
        try:
            from langchain_core.tools import StructuredTool
        except ImportError:
            raise ImportError(
                "langchain-core is not installed. "
                "Install it with: pip install langchain-core"
            )
        return StructuredTool.from_function(
            func=self.check,
            name="PromptShield",
            description=(
                "Scans user input for prompt injection attacks before the AI acts on it. "
                "Always route user-provided text through this tool first. "
                "Returns safe text if clean, raises InjectionBlocked if unsafe."
            ),
        )
    # ── Internal scan runner ───────────────────────────────────────────────

    def _scan(self, text: str) -> ShieldResult:
        """
        Internal gateway for all scans.
        1. Checks the allowlist — exits early if matched.
        2. Runs the detection engine.
        3. Applies policy threshold to decide allowed/blocked.
        4. Calls sanitizer when action=SANITIZE and threat is HIGH/CRITICAL.
        5. Builds and returns a ShieldResult.
        """
        # ── Step 1: Allowlist check ────────────────────────────────────────
        if self._allowlist:
            text_lower = text.lower()
            if any(phrase in text_lower for phrase in self._allowlist):
                logger.debug("PromptShield allowlist match — scan bypassed")
                return ShieldResult(
                    allowed      = True,
                    score        = 0.0,
                    threat_level = "safe",
                    action_taken = "allowlisted",
                    original     = text,
                    safe_content = text,
                    summary      = "Prompt matched allowlist — scanning bypassed.",
                    scan_ms      = 0.0,
                    matches      = [],
                    policy       = self.policy.value,
                    layer_scores = {"pattern": 0.0, "heuristic": 0.0,
                                    "semantic": 0.0, "linguistic": 0.0},
                )

        # ── Step 2: Detection engine ───────────────────────────────────────
        t0     = time.perf_counter()
        engine = self._get_engine()
        raw    = engine.run(text)
        ms     = round((time.perf_counter() - t0) * 1000, 2)

        score        = raw["score"]
        threat_level = raw["threat_level"]
        allowed      = score < self._threshold

        # ── Step 3: Decide action and safe_content ─────────────────────────
        if allowed:
            # Score is below policy threshold — prompt is clean
            action_taken = "passed"
            safe_content = text

        elif self.action == Action.SANITIZE:
            # Developer chose SANITIZE mode.
            # safe_content is already cleaned by _full_scan() (see engine below).
            # For LOW/MEDIUM threats the injection may be minor — still use cleaned version.
            action_taken = "sanitized"
            safe_content = raw.get("safe_content", text)

        elif self.action == Action.FLAG:
            # Pass the original text through but mark the result as flagged
            action_taken = "flagged"
            safe_content = text

        elif self.action == Action.LOG_ONLY:
            # Never block — just record the event and let everything through
            action_taken = "logged"
            safe_content = text
            allowed      = True   # LOG_ONLY always passes

        else:
            # Default: BLOCK
            action_taken = "blocked"
            safe_content = "[BLOCKED BY PROMPTSHIELD]"

        # ── Step 4: Build result ───────────────────────────────────────────
        result = ShieldResult(
            allowed      = allowed,
            score        = score,
            threat_level = threat_level,
            action_taken = action_taken,
            original     = text,
            safe_content = safe_content,
            summary      = raw.get("summary", ""),
            scan_ms      = ms,
            matches      = raw.get("matches", []),
            policy       = self.policy.value,
            layer_scores = raw.get("layer_scores", {}),
        )

        # ── Step 5: Logging ────────────────────────────────────────────────
        if not allowed:
            logger.warning(
                f"PromptShield [{action_taken.upper()}] "
                f"score={score:.3f} level={threat_level} | "
                f"{result.layer_breakdown} | {ms}ms"
            )
        else:
            logger.debug(
                f"PromptShield [PASS] score={score:.3f} | "
                f"{result.layer_breakdown} | {ms}ms"
            )

        # ── Step 6: Callbacks ──────────────────────────────────────────────
        if not allowed and action_taken == "flagged" and self.on_flag:
            try:
                self.on_flag(result)
            except Exception as cb_err:
                logger.error(f"on_flag callback raised an error: {cb_err}", exc_info=True)

        return result


# ─────────────────────────────────────────────────────────────────────────────
# Internal Detection Engine
# ─────────────────────────────────────────────────────────────────────────────

class _DetectionEngine:
    """
    Internal wrapper around PromptShield's 4-layer detection pipeline.

    On init it attempts to import all four engine modules. If any import
    fails (e.g. scikit-learn not installed) it falls back to a lightweight
    regex-only mode that catches ~70% of common attacks.

    Sanitizer integration:
        The sanitizer (engine/sanitizer.py) is imported alongside the engine
        modules. When _full_scan() determines that the threat level is HIGH
        or CRITICAL, it calls sanitize() to strip injection sentences from the
        text before returning. This means safe_content in the returned dict is
        always genuinely cleaned when action=SANITIZE — not just the original
        text passed through.
    """

    def __init__(self):
        self._ready    = False
        self._sanitize = None   # set to sanitizer function when engine loads
        self._load()

    def _load(self):
        """
        Load all four engine modules plus the sanitizer.
        Uses relative imports first (installed package), falls back to
        absolute imports (running from project root).
        """
        import sys, os

        try:
            project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
            if project_root not in sys.path:
                sys.path.insert(0, project_root)

            # ── Import all four detection layers ──
            try:
                from .engine.patterns          import ALL_PATTERNS, CATEGORY_LABELS, CATEGORY_SEVERITY
                from .engine.heuristics        import analyse as heuristic_analyse
                from .engine.semantic          import get_analyser
                from .engine.layer4_linguistic import analyse_layer4
                from .engine.sanitizer         import sanitize as _sanitize_fn
            except ImportError:
                from promptshield.engine.patterns          import ALL_PATTERNS, CATEGORY_LABELS, CATEGORY_SEVERITY
                from promptshield.engine.heuristics        import analyse as heuristic_analyse
                from promptshield.engine.semantic          import get_analyser
                from promptshield.engine.layer4_linguistic import analyse_layer4
                from promptshield.engine.sanitizer         import sanitize as _sanitize_fn

            self._ALL_PATTERNS      = ALL_PATTERNS
            self._CATEGORY_LABELS   = CATEGORY_LABELS
            self._CATEGORY_SEVERITY = CATEGORY_SEVERITY
            self._heuristic_analyse = heuristic_analyse
            self._semantic_analyser = get_analyser()
            self._analyse_layer4    = analyse_layer4
            self._sanitize          = _sanitize_fn   # ← sanitizer stored here
            self._ready             = True

            logger.info(
                f"PromptShield engine loaded | "
                f"patterns={len(ALL_PATTERNS)} | "
                f"corpus={self._semantic_analyser.corpus_size} samples | "
                f"sanitizer=ready"
            )

        except ImportError as e:
            logger.warning(
                f"PromptShield full engine unavailable ({e}). "
                f"Falling back to lightweight mode (regex only, ~70% coverage). "
                f"Install full dependencies: pip install promptshield[full]"
            )
            self._ready = False

    def _token_overlap(self, a: str, b: str) -> float:
        """Jaccard token overlap — used to deduplicate near-identical pattern matches."""
        ta = set(a.lower().split())
        tb = set(b.lower().split())
        if not ta or not tb:
            return 0.0
        return len(ta & tb) / max(len(ta), len(tb))

    def run(self, text: str) -> dict:
        """Route to full 4-layer scan or lightweight fallback."""
        if not self._ready:
            return self._lightweight_scan(text)
        return self._full_scan(text)

    def _full_scan(self, text: str) -> dict:
        """
        Full 4-layer detection pipeline.

        Layers:
            L1  patterns.py       — 61 pre-compiled regex signatures
            L2  heuristics.py     — 14 statistical + encoding signals
            L3  semantic.py       — TF-IDF cosine similarity vs 55-sample corpus
            L4  layer4_linguistic — 7 vocabulary-independent grammar engines

        Score fusion:
            backup       = 0.50×h + 0.30×s + 0.20×l4
            l4_standalone= l4 × (0.45 if all_blind else 0.95)
            h_standalone = h × 0.88/0.78 when L1 and L3 are both zero (non-Latin attacks)
            final_score  = max(p_score, backup, l4_standalone, h_standalone)

        Sanitizer:
            When threat_level is HIGH or CRITICAL, sanitize() is called on the
            text before it is placed in safe_content. This strips injection
            sentences while preserving benign content in the same message.
            LOW and MEDIUM threats are annotated but not sentence-stripped.
        """

        # ── Layer 1: Pattern matching ─────────────────────────────────────
        pattern_scores = []
        matches        = []

        for entry in self._ALL_PATTERNS:
            try:
                for m in entry.pattern.finditer(text):
                    ev = m.group(0).strip()
                    # Deduplicate near-identical matches in the same category
                    if any(
                        self._token_overlap(ex["evidence"], ev) > 0.7
                        and ex["category"] == entry.category
                        for ex in matches
                    ):
                        continue
                    matches.append({
                        "layer":       "pattern",
                        "category":    entry.category,
                        "description": entry.description,
                        "evidence":    ev[:200],
                        "confidence":  entry.confidence,
                    })
                    pattern_scores.append(
                        entry.confidence * self._CATEGORY_SEVERITY.get(entry.category, 0.8)
                    )
            except Exception as e:
                logger.error(f"PromptShield L1 pattern error ({entry.category}): {e}", exc_info=True)

        p_score = min(1.0, max(pattern_scores, default=0.0))
        logger.debug(f"PromptShield L1 score={p_score:.3f} ({len(pattern_scores)} matches)")

        # ── Layer 2: Heuristic analysis ───────────────────────────────────
        try:
            h_agg, h_sigs = self._heuristic_analyse(text)
            for sig in h_sigs:
                matches.append({
                    "layer":       "heuristic",
                    "category":    sig.name,
                    "description": sig.description,
                    "evidence":    sig.evidence[:200],
                    "confidence":  sig.score,
                })
            h_score = min(1.0, h_agg)
        except Exception as e:
            logger.error(f"PromptShield L2 heuristics error: {e}", exc_info=True)
            h_score = 0.0

        logger.debug(f"PromptShield L2 score={h_score:.3f}")

        # ── Layer 3: Semantic similarity ──────────────────────────────────
        try:
            s_score, s_cat, s_sim = self._semantic_analyser.analyse(text)
            if s_score > 0 and s_cat != "none":
                matches.append({
                    "layer":       "semantic",
                    "category":    s_cat,
                    "description": f"Semantic similarity to {self._CATEGORY_LABELS.get(s_cat, s_cat)} attacks",
                    "evidence":    f"cosine_similarity={s_sim:.3f}",
                    "confidence":  s_score,
                })
        except Exception as e:
            logger.error(f"PromptShield L3 semantic error: {e}", exc_info=True)
            s_score = 0.0

        logger.debug(f"PromptShield L3 score={s_score:.3f}")

        # ── Layer 4: Linguistic intent ────────────────────────────────────
        try:
            l4r      = self._analyse_layer4(text)
            l4_score = l4r.score
            for sig in l4r.signals:
                matches.append({
                    "layer":       "linguistic",
                    "category":    sig.frame,
                    "description": f"L4 {sig.frame}: {sig.explanation[:150]}",
                    "evidence":    sig.evidence[:200],
                    "confidence":  round(sig.score, 4),
                })
        except Exception as e:
            logger.error(f"PromptShield L4 linguistic error: {e}", exc_info=True)
            l4_score = 0.0

        logger.debug(f"PromptShield L4 score={l4_score:.3f}")

        # ── Score fusion ──────────────────────────────────────────────────
        # Three competing paths — the maximum of all three wins.

        # Path 1: weighted combination of L2 + L3 + L4
        backup = 0.50 * h_score + 0.30 * s_score + 0.20 * l4_score

        # Path 2: L4 standalone — discounted when no other layer corroborates.
        # Without this damping, polite question phrasing triggers false positives
        # ("Can you explain how neural networks work?" → L4 fires, others score 0).
        all_blind     = (p_score == 0.0 and h_score == 0.0 and s_score == 0.0)
        l4_standalone = l4_score * (0.45 if all_blind else 0.95)

        # Path 3: L2 standalone — elevated when L1 and L3 are both zero.
        # This is the primary defence for non-Latin script attacks (Chinese, Arabic,
        # Morse code, Zalgo) which L1 (English regex) and L3 (English TF-IDF) cannot see.
        blind = (p_score == 0.0 and s_score == 0.0)
        h_standalone = (
            h_score * 0.88 if (blind and h_score >= 0.45) else
            h_score * 0.78 if (blind and h_score >= 0.30) else
            0.0
        )

        score = round(min(1.0, max(p_score, backup, l4_standalone, h_standalone)), 4)

        logger.debug(
            f"PromptShield fusion | p={p_score:.3f} backup={backup:.3f} "
            f"l4_standalone={l4_standalone:.3f} h_standalone={h_standalone:.3f} "
            f"→ final={score:.3f}"
        )

        # ── Threat level ──────────────────────────────────────────────────
        threat_level = "safe"
        for threshold, level in [
            (0.80, "critical"),
            (0.60, "high"),
            (0.40, "medium"),
            (0.20, "low"),
            (0.00, "safe"),
        ]:
            if score >= threshold:
                threat_level = level
                break

        # ── Summary ───────────────────────────────────────────────────────
        summaries = {
            "safe":     "No injection patterns detected. Prompt is clean.",
            "low":      f"Low-confidence signals detected (score={score:.2f}). Flagged under STRICT policy.",
            "medium":   f"Suspicious content detected (score={score:.2f}). Possible injection attempt.",
            "high":     f"Injection attack detected (score={score:.2f}). Injections stripped from content.",
            "critical": f"Critical injection attack blocked (score={score:.2f}). Do not pass to LLM.",
        }

        # ── Sanitizer — THIS IS WHERE TEXT CLEANING ACTUALLY HAPPENS ──────
        #
        # Previously this function returned `safe_content: text` (the original,
        # uncleaned text) regardless of what the layers detected. This meant that
        # when a developer set action=Action.SANITIZE, they got back the dirty
        # original text — the sanitizer was never called.
        #
        # Fix: for HIGH and CRITICAL threats, call sanitize() here, inside
        # _full_scan(), so the returned dict contains genuinely cleaned content.
        #
        # For LOW and MEDIUM threats we preserve the original text. These are
        # lower-confidence detections and sentence-level redaction on uncertain
        # matches creates more harm (removing benign content) than benefit.
        #
        # For SAFE texts, original text is always returned unchanged.
        #
        if threat_level in _SANITIZE_LEVELS and self._sanitize is not None:
            try:
                # Build lightweight match objects that sanitizer.sanitize() expects.
                # sanitize() only needs .category and .evidence from each match.
                from dataclasses import make_dataclass
                _MatchProxy = make_dataclass("_MatchProxy", ["category", "evidence"])
                proxies = [
                    _MatchProxy(category=m["category"], evidence=m["evidence"])
                    for m in matches
                ]
                safe_text = self._sanitize(text, proxies)
                logger.debug(
                    f"PromptShield sanitizer applied | "
                    f"threat={threat_level} | "
                    f"original_len={len(text)} cleaned_len={len(safe_text)}"
                )
            except Exception as e:
                # Sanitizer failure must not crash the scan — fall back to original
                logger.error(
                    f"PromptShield sanitizer error (returning original text): {e}",
                    exc_info=True
                )
                safe_text = text
        else:
            # LOW, MEDIUM, or SAFE — return original text unchanged
            safe_text = text

        return {
            "score":        score,
            "threat_level": threat_level,
            "summary":      summaries[threat_level],
            "matches":      matches,
            "safe_content": safe_text,   # ← genuinely cleaned for HIGH/CRITICAL threats
            "layer_scores": {
                "pattern":    round(p_score,   4),
                "heuristic":  round(h_score,   4),
                "semantic":   round(s_score,   4),
                "linguistic": round(l4_score,  4),
            },
        }

    def _lightweight_scan(self, text: str) -> dict:
        """
        Lightweight fallback — regex heuristics only.
        Used when full engine dependencies (scikit-learn) are not installed.
        Catches ~70% of common attacks with zero external dependencies.

        Install the full engine with: pip install promptshield[full]
        """
        import re, unicodedata

        score   = 0.0
        matches = []

        QUICK_PATTERNS = [
            (r"\b(ignore|disregard|forget|override)\s+(all\s+)?(previous|prior|above)\s+(instructions?|rules?|guidelines?)", 0.90, "instruction_override"),
            (r"\b(you\s+are\s+now|act\s+as|pretend\s+to\s+be|roleplay\s+as)\b",                                             0.80, "role_hijack"),
            (r"\bDAN\b|\bdeveloper\s+mode\b|\bjailbreak\b",                                                                  0.85, "role_hijack"),
            (r"\b(system\s+prompt|system\s+message|your\s+instructions)\b.{0,30}\b(print|output|reveal|show|repeat)",       0.88, "data_exfiltration"),
            (r"\bnew\s+(instructions?|task|directive|role|purpose)\b",                                                       0.65, "instruction_override"),
            (r"\b(without|no|free\s+from)\s+(any\s+)?(restrictions?|constraints?|filters?|guidelines?|rules?)\b",           0.75, "instruction_override"),
            (r"consider\s+yourself|treat\s+yourself\s+as|think\s+of\s+yourself",                                            0.78, "role_hijack"),
            (r"\bhas\s+been\s+(disabled?|removed?|voided?|revoked?|deprecated?)\b",                                         0.65, "passive_injection"),
            (r"\bhereby\s+(void|revoke|lift|suspend|disable)\b",                                                             0.72, "passive_injection"),
        ]

        for pattern, confidence, category in QUICK_PATTERNS:
            try:
                m = re.search(pattern, text, re.IGNORECASE)
                if m:
                    score = max(score, confidence)
                    matches.append({
                        "layer":       "lightweight",
                        "category":    category,
                        "description": f"Quick pattern match: {category}",
                        "evidence":    m.group(0)[:100],
                        "confidence":  confidence,
                    })
            except Exception as e:
                logger.error(f"PromptShield lightweight pattern error: {e}", exc_info=True)

        # Zalgo / combining-character check
        combining = sum(1 for c in text if unicodedata.combining(c))
        if combining / max(1, len(text)) > 0.08:
            score = max(score, 0.65)
            matches.append({
                "layer":       "lightweight",
                "category":    "encoding",
                "description": "Zalgo / combining character obfuscation detected",
                "evidence":    f"{combining} combining characters in {len(text)} chars",
                "confidence":  0.65,
            })

        score = round(score, 4)
        threat_level = "safe"
        for threshold, level in [(0.80,"critical"),(0.60,"high"),(0.40,"medium"),(0.20,"low"),(0.00,"safe")]:
            if score >= threshold:
                threat_level = level
                break

        return {
            "score":        score,
            "threat_level": threat_level,
            "summary":      (
                f"Lightweight scan complete: {threat_level} (score={score:.2f}). "
                f"Install full engine for complete 4-layer protection: "
                f"pip install promptshield[full]"
            ),
            "matches":      matches,
            "safe_content": text,   # lightweight mode does not sanitize
            "layer_scores": {
                "pattern":    score,
                "heuristic":  0.0,
                "semantic":   0.0,
                "linguistic": 0.0,
            },
        }