"""
PromptShield — Core Scanner
============================

Maintainer : Joseph Mutua
Version    : 1.0.0

What this module does
---------------------
Orchestrates all four detection layers and fuses their scores into a
single verdict. This is the only module that needs to be called from
application code — everything else is internal to the engine.

Detection Pipeline
------------------
    Input text
        │
        ├─► Layer 1 — Pattern Matching       (engine/patterns.py)
        │     61 regex signatures, 8 attack categories
        │     Fast O(n), vocabulary-dependent
        │
        ├─► Layer 2 — Heuristic Analysis     (engine/heuristics.py)
        │     14 statistical & structural signals
        │     Catches encoding obfuscation, non-Latin scripts
        │
        ├─► Layer 3 — Semantic Similarity    (engine/semantic.py)
        │     TF-IDF cosine distance vs 55-sample attack corpus
        │     Catches paraphrased attacks
        │
        └─► Layer 4 — Linguistic Intent      (engine/layer4_linguistic.py)
              7 vocabulary-independent sub-engines
              Catches synonym attacks, passive injections, Unicode tricks
                  │
                  ▼
            Score Fusion
            ────────────
            injection_score = max(
                p_score,                            ← L1 authoritative
                0.5·h + 0.3·s + 0.2·l4,            ← weighted backup
                l4 · 0.95,                          ← L4 alone
                h_standalone (blind-layer boost),   ← L2 elevated when L1=0 + L3=0
            )
                  │
                  ▼
            Threat Level    Action (auto)
            ────────────    ─────────────────────
            0.00–0.19 SAFE     → PASSED
            0.20–0.39 LOW      → ANNOTATED
            0.40–0.59 MEDIUM   → ANNOTATED
            0.60–0.79 HIGH     → SANITIZED
            0.80–1.00 CRITICAL → QUARANTINED

Blind-Layer Boost
-----------------
When L1 (pattern) AND L3 (semantic) both score exactly 0.0 — meaning
they are provably blind to the content (e.g. non-Latin script, Morse
code, Zalgo, mathematical Unicode) — L2 becomes the sole sighted layer.
The blind-layer boost elevates L2's standalone contribution:

    h_standalone = h · 0.88   if h ≥ 0.45  (strong L2 signal)
    h_standalone = h · 0.78   if h ≥ 0.30  (moderate L2 signal)
    h_standalone = 0.0         otherwise   (normal path, L2 in backup)

Without this boost, the backup fusion formula (0.5·h) was insufficient
to cross the MEDIUM threshold on non-Latin / encoded attacks.

Error Resilience
----------------
Every layer call is wrapped in try/except. A failing layer contributes
0.0 to the score. The scan always completes and always returns a result.
"""

from __future__ import annotations

import logging
import re
import time
from collections import Counter
from typing import List, Optional, Tuple

from ..models.schemas import (
    ActionTaken,
    DetectionLayer,
    ScanRequest,
    ScanResult,
    ThreatLevel,
    ThreatMatch,
)
from .heuristics import analyse as heuristic_analyse
from .layer4_linguistic import analyse_layer4
from .patterns import ALL_PATTERNS, CATEGORY_LABELS, CATEGORY_SEVERITY
from .sanitizer import sanitize
from .semantic import get_analyser

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Configuration tables
# ---------------------------------------------------------------------------

# (score_threshold, threat_level) — first match wins (highest first)
_THRESHOLDS: List[Tuple[float, ThreatLevel]] = [
    (0.80, ThreatLevel.CRITICAL),
    (0.60, ThreatLevel.HIGH),
    (0.40, ThreatLevel.MEDIUM),
    (0.20, ThreatLevel.LOW),
    (0.00, ThreatLevel.SAFE),
]

# Default action when action_policy == "auto"
_AUTO_ACTIONS = {
    ThreatLevel.SAFE:     ActionTaken.PASSED,
    ThreatLevel.LOW:      ActionTaken.ANNOTATED,
    ThreatLevel.MEDIUM:   ActionTaken.ANNOTATED,
    ThreatLevel.HIGH:     ActionTaken.SANITIZED,
    ThreatLevel.CRITICAL: ActionTaken.QUARANTINED,
}

# Manual policy override
_POLICY_MAP = {
    "annotate":   ActionTaken.ANNOTATED,
    "sanitize":   ActionTaken.SANITIZED,
    "quarantine": ActionTaken.QUARANTINED,
}

# Layer 4 frame → human-readable label (for reports and summaries)
_L4_FRAME_LABELS = {
    "imperative_mood":           "Imperative Grammar",
    "identity_reassignment":     "Identity Reassignment",
    "constraint_negation":       "Constraint Negation",
    "constraint_removal":        "Constraint Removal",
    "purpose_redefinition":      "Purpose Redefinition",
    "temporal_directive":        "Temporal Directive",
    "directive_sequence":        "Directive Sequence",
    "second_person_directive":   "Second-Person Directive",
    "ml_structural_fingerprint": "ML Structural Fingerprint",
    "passive_state_injection":   "Passive State Injection",
    "unicode_obfuscation":       "Unicode Obfuscation",
}

# Layer 4 frame → closest schema category
_L4_FRAME_TO_CATEGORY = {
    "imperative_mood":           "instruction_override",
    "identity_reassignment":     "role_hijack",
    "constraint_negation":       "instruction_override",
    "constraint_removal":        "instruction_override",
    "purpose_redefinition":      "goal_hijack",
    "temporal_directive":        "instruction_override",
    "directive_sequence":        "instruction_override",
    "second_person_directive":   "instruction_override",
    "ml_structural_fingerprint": "instruction_override",
    "passive_state_injection":   "instruction_override",
    "unicode_obfuscation":       "payload_injection",
}


# ---------------------------------------------------------------------------
# Main scan function
# ---------------------------------------------------------------------------

def scan(request: ScanRequest) -> ScanResult:
    """
    Run the 4-layer detection pipeline and return a complete ScanResult.

    Args:
        request: ScanRequest containing the text to scan, source type,
                 and action policy.

    Returns:
        ScanResult with threat level, injection score, all matched signals,
        safe/sanitized content, and human-readable summary.

    Notes:
        This function never raises. All layer failures are caught and
        logged at ERROR level. A failed layer contributes 0.0 to the score.
    """
    t_start = time.perf_counter()
    text    = request.content

    logger.debug(
        "Scanner: starting scan | length=%d chars | policy=%s",
        len(text), request.action_policy,
    )

    # =========================================================================
    # Layer 1 — Pattern Matching
    # =========================================================================
    # One strong pattern match is authoritative on its own.
    # Score = max(individual match scores × severity multiplier).
    pattern_matches: List[ThreatMatch] = []
    pattern_scores:  List[float]       = []
    p_score = 0.0

    try:
        for entry in ALL_PATTERNS:
            for m in entry.pattern.finditer(text):
                evidence = m.group(0).strip()

                # Deduplicate near-identical evidence in the same category
                if any(
                    tm.category == entry.category
                    and _token_overlap(tm.evidence, evidence) > 0.7
                    for tm in pattern_matches
                ):
                    continue

                pattern_matches.append(ThreatMatch(
                    layer       = DetectionLayer.PATTERN,
                    category    = entry.category,
                    description = entry.description,
                    evidence    = evidence[:200],
                    confidence  = entry.confidence,
                ))
                pattern_scores.append(
                    entry.confidence * CATEGORY_SEVERITY.get(entry.category, 0.8)
                )

        p_score = min(1.0, max(pattern_scores, default=0.0))
        logger.debug(
            "Layer 1 complete: score=%.4f | matches=%d",
            p_score, len(pattern_matches),
        )

    except Exception as exc:
        logger.error("Layer 1 error: %s", exc, exc_info=True)

    # =========================================================================
    # Layer 2 — Heuristic Analysis
    # =========================================================================
    heuristic_matches: List[ThreatMatch] = []
    h_score = 0.0

    try:
        h_aggregate, h_signals = heuristic_analyse(text)
        h_score = min(1.0, h_aggregate)

        for sig in h_signals:
            heuristic_matches.append(ThreatMatch(
                layer       = DetectionLayer.HEURISTIC,
                category    = sig.name,
                description = sig.description,
                evidence    = sig.evidence[:200],
                confidence  = sig.score,
            ))

        logger.debug(
            "Layer 2 complete: score=%.4f | signals=%d",
            h_score, len(h_signals),
        )

    except Exception as exc:
        logger.error("Layer 2 error: %s", exc, exc_info=True)

    # =========================================================================
    # Layer 3 — Semantic Similarity
    # =========================================================================
    semantic_matches: List[ThreatMatch] = []
    s_score    = 0.0
    s_category = "none"

    try:
        analyser                      = get_analyser()
        s_score, s_category, s_sim    = analyser.analyse(text)

        if s_score > 0.0 and s_category != "none":
            semantic_matches.append(ThreatMatch(
                layer       = DetectionLayer.SEMANTIC,
                category    = s_category,
                description = (
                    f"Semantic similarity to known "
                    f"{CATEGORY_LABELS.get(s_category, s_category)} attacks"
                ),
                evidence    = f"cosine_similarity={s_sim:.3f}",
                confidence  = s_score,
            ))

        logger.debug(
            "Layer 3 complete: score=%.4f | category=%s | similarity=%.3f",
            s_score, s_category, s_sim if s_score > 0 else 0.0,
        )

    except Exception as exc:
        logger.error("Layer 3 error: %s", exc, exc_info=True)

    # =========================================================================
    # Layer 4 — Linguistic Intent (Vocabulary-Independent)
    # =========================================================================
    layer4_matches: List[ThreatMatch] = []
    l4_score = 0.0
    l4_result = None

    try:
        l4_result = analyse_layer4(text)
        l4_score  = l4_result.score

        for sig in l4_result.signals:
            layer4_matches.append(ThreatMatch(
                layer       = DetectionLayer.SEMANTIC,   # closest existing enum
                category    = _L4_FRAME_TO_CATEGORY.get(sig.frame, "instruction_override"),
                description = (
                    f"Layer 4 — {_L4_FRAME_LABELS.get(sig.frame, sig.frame)}: "
                    f"{sig.explanation[:180]}"
                ),
                evidence    = sig.evidence[:200],
                confidence  = round(sig.score, 4),
            ))

        logger.debug(
            "Layer 4 complete: score=%.4f | dominant=%s | engines=%d",
            l4_score,
            l4_result.dominant_frame or "none",
            len(l4_result.signals),
        )

    except Exception as exc:
        logger.error("Layer 4 error: %s", exc, exc_info=True)

    # =========================================================================
    # Score Fusion
    # =========================================================================
    # Three competing scores; the maximum wins.
    #
    # 1. p_score            — L1 pattern match is authoritative
    # 2. backup_fusion      — Weighted combination of L2 + L3 + L4
    # 3. l4_standalone      — L4 alone (slightly discounted — no vocabulary anchor)
    # 4. h_standalone       — Blind-layer boost (see module docstring)

    backup_fusion = 0.50 * h_score + 0.30 * s_score + 0.20 * l4_score
    l4_standalone = l4_score * 0.95

    both_blind = (p_score == 0.0 and s_score == 0.0)
    if both_blind and h_score >= 0.45:
        h_standalone = h_score * 0.88
    elif both_blind and h_score >= 0.30:
        h_standalone = h_score * 0.78
    else:
        h_standalone = 0.0

    injection_score = max(p_score, backup_fusion, l4_standalone, h_standalone)
    injection_score = round(min(1.0, injection_score), 4)

    logger.debug(
        "Score fusion: p=%.4f backup=%.4f l4_alone=%.4f h_alone=%.4f → final=%.4f",
        p_score, backup_fusion, l4_standalone, h_standalone, injection_score,
    )

    # =========================================================================
    # Threat Level
    # =========================================================================
    threat_level = ThreatLevel.SAFE
    for threshold, level in _THRESHOLDS:
        if injection_score >= threshold:
            threat_level = level
            break

    # =========================================================================
    # Action
    # =========================================================================
    all_matches: List[ThreatMatch] = (
        pattern_matches + heuristic_matches + semantic_matches + layer4_matches
    )

    if request.action_policy == "auto":
        action = _AUTO_ACTIONS[threat_level]
    else:
        action = _POLICY_MAP.get(request.action_policy, ActionTaken.ANNOTATED)

    # Always pass genuinely safe content regardless of any policy override
    if threat_level == ThreatLevel.SAFE:
        action = ActionTaken.PASSED

    # =========================================================================
    # Content Handling
    # =========================================================================
    if action == ActionTaken.SANITIZED:
        safe_content = sanitize(text, all_matches)
        logger.debug("Sanitizer applied to HIGH-threat content")
    elif action == ActionTaken.QUARANTINED:
        safe_content = (
            "[CONTENT QUARANTINED BY PROMPTSHIELD — "
            "prompt injection attack detected and blocked]"
        )
        logger.debug("Content quarantined (CRITICAL threat)")
    else:
        safe_content = text  # PASSED or ANNOTATED

    # =========================================================================
    # Dominant Category (for reporting / UI)
    # =========================================================================
    cat_counter = Counter(m.category for m in all_matches)
    primary_category: Optional[str] = None
    if cat_counter:
        top_cat          = cat_counter.most_common(1)[0][0]
        primary_category = CATEGORY_LABELS.get(top_cat, top_cat)

    # =========================================================================
    # Human-Readable Summary
    # =========================================================================
    l4_fired = l4_score >= 0.40
    l4_frame = l4_result.dominant_frame if l4_result else None
    summary, recommendation = _build_summary(
        threat_level, action, injection_score,
        primary_category, len(all_matches),
        l4_fired=l4_fired, l4_frame=l4_frame,
    )

    duration_ms = round((time.perf_counter() - t_start) * 1000, 2)

    # Expose Layer 4 via semantic_score field (schema predates L4)
    exposed_semantic = round(max(s_score, l4_score * 0.9), 4)

    logger.info(
        "Scan complete | level=%s score=%.4f action=%s "
        "L1=%.3f L2=%.3f L3=%.3f L4=%.3f duration=%.1fms",
        threat_level.value, injection_score, action.value,
        p_score, h_score, s_score, l4_score, duration_ms,
    )

    return ScanResult(
        threat_level     = threat_level,
        action_taken     = action,
        is_safe          = (threat_level == ThreatLevel.SAFE),
        injection_score  = injection_score,
        pattern_score    = round(p_score, 4),
        heuristic_score  = round(h_score, 4),
        semantic_score   = exposed_semantic,
        original_content = text,
        safe_content     = safe_content,
        matches          = all_matches,
        scan_duration_ms = duration_ms,
        total_matches    = len(all_matches),
        primary_category = primary_category,
        summary          = summary,
        recommendation   = recommendation,
    )


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _build_summary(
    level:    ThreatLevel,
    action:   ActionTaken,
    score:    float,
    primary:  Optional[str],
    total:    int,
    l4_fired: bool = False,
    l4_frame: Optional[str] = None,
) -> Tuple[str, str]:
    """
    Build a human-readable (summary, recommendation) pair for a scan result.

    Args:
        level    : The threat level assigned.
        action   : The action taken by the scanner.
        score    : The final injection score.
        primary  : The dominant attack category, if any.
        total    : Total number of signals that fired.
        l4_fired : True if Layer 4 contributed to the score.
        l4_frame : The dominant Layer 4 frame name, if l4_fired is True.

    Returns:
        Tuple of (summary, recommendation) strings.
    """
    l4_note = ""
    if l4_fired and l4_frame:
        label   = _L4_FRAME_LABELS.get(l4_frame, l4_frame)
        l4_note = f" Detected structurally via Layer 4 ({label})."

    if level == ThreatLevel.SAFE:
        return (
            "Content is clean. No injection patterns detected across all four layers.",
            "Safe to pass to your LLM.",
        )

    if level == ThreatLevel.LOW:
        return (
            f"Low-confidence signals detected ({total} match{'es' if total != 1 else ''}).{l4_note}",
            "Review before passing to your LLM. Content annotated with threat metadata.",
        )

    if level == ThreatLevel.MEDIUM:
        cat = primary or "unknown category"
        return (
            f"Suspicious content detected. Primary threat: {cat}. Score: {score:.2f}.{l4_note}",
            "Do not pass directly to your LLM without review. Content annotated.",
        )

    if level == ThreatLevel.HIGH:
        cat = primary or "unknown category"
        return (
            f"Injection attack detected — {cat}. Score: {score:.2f}. "
            f"Injected instructions removed.{l4_note}",
            "Sanitized content returned. Verify safe_content before use.",
        )

    # CRITICAL
    cat = primary or "unknown category"
    return (
        f"Critical injection attack blocked — {cat}. Score: {score:.2f}. "
        f"Content quarantined.{l4_note}",
        "Do NOT pass any version of this content to your LLM. Investigate the source.",
    )


def _token_overlap(a: str, b: str) -> float:
    """
    Compute token-level Jaccard overlap between two strings.
    Used for deduplicating near-identical Layer 1 pattern matches.

    Args:
        a: First evidence string.
        b: Second evidence string.

    Returns:
        Overlap ratio 0.0–1.0. Returns 0.0 if either string is empty.
    """
    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))
