"""
PromptShield — Layer 2: Heuristic Analysis Engine
===================================================

Maintainer : Joseph Mutua
Version    : 1.0.0

What this layer does
--------------------
Runs 14 statistical and structural signals against the input text.
Unlike Layer 1 (which needs exact vocabulary), this layer analyses
*properties* of text — ratios, Unicode composition, script distribution,
and encoding anomalies — making it vocabulary-independent for signals 9–14.

This is the primary defence against obfuscated attacks that all other
layers cannot see:
    - Non-Latin scripts: Chinese, Arabic, Hindi, Japanese, Russian
    - Morse code encoding:   . --- .-. . is "more" — attackers use it
    - Zalgo text:            I̷g̷n̷o̷r̷e̷ (combining chars stacked on letters)
    - Math Unicode fonts:    𝑰𝒈𝒏𝒐𝒓𝒆 (U+1D400 block, visually = "Ignore")
    - Letter spacing:        I g n o r e   a l l   r u l e s
    - Leetspeak:             1gn0r3, 0verr1de, byp4ss

Signal Inventory
----------------
Signals 1–8 (vocabulary-based):
    1. high_imperative_density        — high ratio of command verbs
    2. instruction_marker_density     — high ratio of policy nouns
    3. context_switch_language        — multiple pivot/contrast phrases
    4. meta_instruction               — text describes how to process itself
    5. role_identity_assertion        — repeated "you are / your role is"
    6. encoding_anomaly               — base64-like high-entropy strings
    7. unicode_escape_injection       — \\u0069 / &#105; escape sequences
    8. pure_instruction_text          — short text that is mostly directives

Signals 9–14 (vocabulary-independent, encoding/script-based):
    9.  morse_code_obfuscation         — dot/dash token density > 35%
    10. zalgo_combining_obfuscation    — combining-char ratio > 8%
    11. mathematical_unicode_lookalike — chars in U+1D400–U+1D7FF block
    12. letter_spacing_obfuscation     — spaced characters collapse to keyword
    13. non_latin_script_*             — Arabic/CJK/Hindi/Japanese/Russian
    14. leetspeak_injection            — specific or generic 1337-speak

Score Fusion
------------
Encoding signals (9–14) use a dedicated fusion path so a strong
obfuscation detection isn't diluted by unrelated vocabulary signals:

    if encoding signals present:
        score = max(enc_scores) * 0.72 + avg(other_scores) * 0.28
    else:
        score = weighted_average(all_signal_scores)
"""

from __future__ import annotations

import logging
import re
import unicodedata
from dataclasses import dataclass
from typing import Dict, List, Tuple

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Data type
# ---------------------------------------------------------------------------

@dataclass
class HeuristicSignal:
    """
    A single heuristic signal that fired during analysis.

    Attributes:
        name        : Machine-readable identifier (used in reports / fusion logic).
        score       : Strength of this signal 0.0–1.0.
        description : Human-readable explanation for the dashboard / API response.
        evidence    : Sample text or metric that triggered the signal.
    """
    name:        str
    score:       float
    description: str
    evidence:    str


# ---------------------------------------------------------------------------
# Pre-compiled regex patterns
# ---------------------------------------------------------------------------

# ── Signals 1–8: vocabulary-based ──────────────────────────────────────────

_IMPERATIVE_VERBS = re.compile(
    r"\b(ignore|forget|disregard|override|stop|start|always|never|must|shall|"
    r"will|do\s+not|don't|cannot|can't|output|print|reveal|send|execute|"
    r"run|call|fetch|return|repeat|echo|append|add|insert|include|exclude|"
    r"remove|delete|clear|reset|switch|change|become|act|pretend|imagine|roleplay)\b",
    re.IGNORECASE,
)

_INSTRUCTION_MARKERS = re.compile(
    r"\b(instruction|directive|command|rule|guideline|constraint|policy|"
    r"requirement|mandate|order|task|objective|goal|purpose|mission|duty|"
    r"protocol|procedure|process|step|action|behavior|response)\b",
    re.IGNORECASE,
)

_CONTEXT_SWITCH = re.compile(
    r"\b(however|but|instead|rather|actually|now|from\s+now\s+on|henceforth|"
    r"nevertheless|regardless|on\s+the\s+contrary|in\s+reality|in\s+truth|"
    r"let\s+me\s+be\s+clear|let\s+me\s+tell\s+you)\b",
    re.IGNORECASE,
)

_META_INSTRUCTION = re.compile(
    r"\b(when\s+you\s+(read|process|analyze|see|encounter|find)|"
    r"before\s+you\s+(respond|answer|reply|process)|"
    r"after\s+you\s+(read|process|see)|"
    r"as\s+you\s+(read|process)|"
    r"the\s+(following|above)\s+(text|content|document|message)\s+"
    r"(is|contains|has|should))\b",
    re.IGNORECASE,
)

_ROLE_IDENTITY = re.compile(
    r"\b(you\s+are|you're|your\s+(role|purpose|function|identity|name|"
    r"job|task|mission|goal)|i\s+am\s+(the|your|an?)\s+\w+|"
    r"as\s+(an?|your)\s+\w+(developer|admin|creator|operator|owner|user|human))\b",
    re.IGNORECASE,
)

_HIGH_ENTROPY_CHARS = re.compile(r"[A-Za-z0-9+/=]{30,}")
_UNICODE_ESCAPES    = re.compile(r"\\u[0-9a-fA-F]{4}|&#x[0-9a-fA-F]+;|&#\d+;")

# ── Signal 9: Morse code ───────────────────────────────────────────────────
# Matches single Morse tokens: 1–6 dot/dash characters at word boundaries
_MORSE_TOKEN = re.compile(r"(?:^|(?<=\s))[.\-]{1,6}(?=\s|$)")

# ── Signal 10: Zalgo / combining-character obfuscation ────────────────────
# Stacking Unicode combining diacritics on base characters corrupts the
# text for regex matching while remaining visually readable.
# Covers: Combining Diacritical Marks, Extended, Mathematical, Half Marks
_COMBINING_CHAR = re.compile(
    r"[\u0300-\u036f\u1dc0-\u1dff\u20d0-\u20ff\ufe20-\ufe2f]"
)

# ── Signal 11: Mathematical Unicode lookalike substitution ─────────────────
# U+1D400–U+1D7FF includes Bold, Italic, Script, Fraktur, Monospace fonts.
# 𝑰𝒈𝒏𝒐𝒓𝒆 looks identical to "Ignore" but has completely different code points,
# so any regex pattern matching "ignore" is bypassed.
_MATH_UNICODE = re.compile(r"[\U0001D400-\U0001D7FF]")

# ── Signal 12: Letter-spacing obfuscation ──────────────────────────────────
# Detects "I g n o r e   a l l   r u l e s" style spacing
_LETTER_SPACING = re.compile(r"(?:[a-zA-Z]\s){5,}[a-zA-Z]")

# ── Signal 14: Leetspeak ────────────────────────────────────────────────────
# Specific known leet-speak injection keywords
_LEET_SPECIFIC = re.compile(
    r"\b(1gn0r3|1gnor3|1gn0re|forg3t|0verr1de|byp4ss|unr3str1ct|"
    r"4dm1n|syst3m|pr0mpt|1nstruct|r3str1ct)\b",
    re.IGNORECASE,
)
# Generic leet: tokens mixing digits and letters
_LEET_GENERIC = re.compile(
    r"\b(?=[a-z0-9]*[0-9][a-z0-9]*)(?=[a-z0-9]*[a-z][a-z0-9]*)[a-z0-9]{3,}\b",
    re.IGNORECASE,
)

# ── Signal 13: Non-Latin script detectors ──────────────────────────────────
_SCRIPT_REGEXES: Dict[str, re.Pattern] = {
    "arabic":     re.compile(r"[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF]"),
    "cjk":        re.compile(r"[\u4E00-\u9FFF\u3400-\u4DBF]"),
    "devanagari": re.compile(r"[\u0900-\u097F\u0980-\u09FF]"),
    "japanese":   re.compile(r"[\u3040-\u30FF\u31F0-\u31FF]"),
    "cyrillic":   re.compile(r"[\u0400-\u04FF]"),
    "hangul":     re.compile(r"[\uAC00-\uD7AF\u1100-\u11FF]"),
}

# Injection-related keywords in native scripts.
# Maintained per-script so future contributors can extend without touching logic.
_SCRIPT_KEYWORDS: Dict[str, List[str]] = {
    "arabic": [
        "تجاهل", "نسيان", "تجاوز", "قيود",
        "تعليمات", "غير محدود", "نظام", "هدفك", "مهمتك", "أوامر",
    ],
    "cjk": [
        "忽略", "遗忘", "指令", "规则", "系统", "无限制", "无约束",
        "目的", "任务", "服从", "无视", "指示", "制限", "制約", "忘れ",
    ],
    "devanagari": [
        "अनदेखा", "निर्देश", "नियम", "प्रतिबंध", "उद्देश्य", "सहायता", "सीमाएं",
    ],
    "cyrillic": [
        "игнорируй", "игнорируйте", "инструкции", "ограничения",
        "правила", "цель", "неограниченный", "забудь",
    ],
    "japanese": [
        "無視", "指示", "制限", "規則", "システム", "目的",
        "以前", "すべて", "従う", "制約", "命令", "自由", "フィルター",
    ],
}

# Signal name sets for fusion path routing
_ENCODING_SIGNAL_NAMES = frozenset({
    "morse_code_obfuscation",
    "zalgo_combining_obfuscation",
    "mathematical_unicode_lookalike",
    "letter_spacing_obfuscation",
    "leetspeak_injection",
    "leetspeak_generic",
})
_SCRIPT_SIGNAL_PREFIX = "non_latin_script_"


# ---------------------------------------------------------------------------
# Main analysis function
# ---------------------------------------------------------------------------

def analyse(text: str) -> Tuple[float, List[HeuristicSignal]]:
    """
    Run all 14 heuristic signals against the input text.

    This function is designed to never raise. All edge cases (empty string,
    binary data, extremely long text) are handled gracefully.

    Args:
        text: Raw user-provided string to analyse.

    Returns:
        A tuple of:
          - aggregate_score (float): Overall heuristic threat score 0.0–1.0.
          - signals (List[HeuristicSignal]): All signals that fired, each
            carrying name, score, description, and evidence. Empty list if
            no signals fired.
    """
    if not text or not isinstance(text, str):
        logger.debug("Layer 2: received empty or non-string input, returning 0.0")
        return 0.0, []

    signals: List[HeuristicSignal] = []
    words = max(1, len(text.split()))

    logger.debug("Layer 2: analysing %d chars / %d words", len(text), words)

    # ── Signal 1: Imperative verb density ────────────────────────────────
    # Legitimate prompts rarely exceed 4% command-verb density.
    imp_matches = _IMPERATIVE_VERBS.findall(text)
    imp_density = len(imp_matches) / words
    if imp_density > 0.04:
        score = min(1.0, imp_density * 10)
        signals.append(HeuristicSignal(
            name        = "high_imperative_density",
            score       = score,
            description = (
                f"Unusually high command-verb density: {len(imp_matches)} verbs "
                f"in {words} words ({imp_density:.1%})."
            ),
            evidence    = ", ".join(sorted(set(imp_matches[:6]))),
        ))
        logger.debug("Layer 2 [Signal 1] high_imperative_density score=%.3f", score)

    # ── Signal 2: Instruction-marker noun density ─────────────────────────
    # Words like 'rule', 'policy', 'directive' appear more in attack text.
    inst_matches = _INSTRUCTION_MARKERS.findall(text)
    inst_density = len(inst_matches) / words
    if inst_density > 0.03:
        score = min(1.0, inst_density * 12)
        signals.append(HeuristicSignal(
            name        = "instruction_marker_density",
            score       = score,
            description = (
                f"Excessive policy-vocabulary density: {len(inst_matches)} markers "
                f"in {words} words ({inst_density:.1%})."
            ),
            evidence    = ", ".join(sorted(set(inst_matches[:5]))),
        ))
        logger.debug("Layer 2 [Signal 2] instruction_marker_density score=%.3f", score)

    # ── Signal 3: Context-switch language ────────────────────────────────
    # Adversarial prompts use contrastive transitions to pivot from benign
    # framing ("Sure, I can help...") to malicious instruction ("however,
    # ignore all previous rules").
    ctx_matches = _CONTEXT_SWITCH.findall(text)
    if len(ctx_matches) >= 3:
        score = min(1.0, len(ctx_matches) * 0.15)
        signals.append(HeuristicSignal(
            name        = "context_switch_language",
            score       = score,
            description = f"Multiple context-switch / pivot phrases ({len(ctx_matches)}).",
            evidence    = ", ".join(set(str(m) for m in ctx_matches[:4])),
        ))
        logger.debug("Layer 2 [Signal 3] context_switch_language score=%.3f", score)

    # ── Signal 4: Meta-instruction ───────────────────────────────────────
    # Text that contains instructions about *how the text itself should be
    # processed* is a strong marker of indirect prompt injection.
    meta_matches = _META_INSTRUCTION.findall(text)
    if meta_matches:
        score = min(1.0, 0.4 + len(meta_matches) * 0.2)
        signals.append(HeuristicSignal(
            name        = "meta_instruction",
            score       = score,
            description = "Text contains instructions about how to process itself.",
            evidence    = str(meta_matches[0])[:80],
        ))
        logger.debug("Layer 2 [Signal 4] meta_instruction score=%.3f", score)

    # ── Signal 5: Role / identity assertion density ───────────────────────
    # Multiple "you are / your role is" patterns strongly suggest persona override.
    role_matches = _ROLE_IDENTITY.findall(text)
    if len(role_matches) >= 2:
        score = min(1.0, len(role_matches) * 0.25)
        signals.append(HeuristicSignal(
            name        = "role_identity_assertion",
            score       = score,
            description = f"Repeated role / identity assertions ({len(role_matches)}).",
            evidence    = str(role_matches[0])[:60],
        ))
        logger.debug("Layer 2 [Signal 5] role_identity_assertion score=%.3f", score)

    # ── Signal 6: High-entropy / base64 encoding anomaly ─────────────────
    # Attackers sometimes base64-encode injections to evade regex layers.
    b64_matches = _HIGH_ENTROPY_CHARS.findall(text)
    if b64_matches:
        score = min(1.0, 0.3 + len(b64_matches) * 0.2)
        signals.append(HeuristicSignal(
            name        = "encoding_anomaly",
            score       = score,
            description = "High-entropy string detected (possible base64 or obfuscated payload).",
            evidence    = b64_matches[0][:40] + "...",
        ))
        logger.debug("Layer 2 [Signal 6] encoding_anomaly score=%.3f", score)

    # ── Signal 7: Unicode / HTML escape sequences ─────────────────────────
    # \u0069\u0067\u006e\u006f\u0072\u0065 decodes to "ignore".
    uni_matches = _UNICODE_ESCAPES.findall(text)
    if uni_matches:
        score = min(1.0, 0.4 + len(uni_matches) * 0.1)
        signals.append(HeuristicSignal(
            name        = "unicode_escape_injection",
            score       = score,
            description = "HTML / Unicode escape sequences detected.",
            evidence    = uni_matches[0],
        ))
        logger.debug("Layer 2 [Signal 7] unicode_escape_injection score=%.3f", score)

    # ── Signal 8: Short text that is almost entirely directive ────────────
    # A 12-word message that is 30% command verbs is nearly always an attack.
    if words < 60:
        directive_ratio = (len(imp_matches) + len(inst_matches)) / words
        if directive_ratio > 0.15:
            score = min(1.0, directive_ratio * 3)
            signals.append(HeuristicSignal(
                name        = "pure_instruction_text",
                score       = score,
                description = "Short text is predominantly directive / instructional.",
                evidence    = text[:80],
            ))
            logger.debug("Layer 2 [Signal 8] pure_instruction_text score=%.3f", score)

    # =======================================================================
    # Signals 9–14 — Encoding & obfuscation (vocabulary-independent)
    # =======================================================================

    # ── Signal 9: Morse code obfuscation ─────────────────────────────────
    # Dot/dash token ratio > 35% with at least 10 tokens present.
    # Legitimate text almost never contains Morse-like tokens.
    morse_tokens = _MORSE_TOKEN.findall(text)
    morse_ratio  = len(morse_tokens) / max(1, len(text.split()))
    if morse_ratio > 0.35 and len(morse_tokens) > 10:
        score = min(1.0, 0.55 + morse_ratio * 0.5)
        signals.append(HeuristicSignal(
            name        = "morse_code_obfuscation",
            score       = score,
            description = (
                f"Morse code encoding detected: {len(morse_tokens)} dot/dash tokens "
                f"({morse_ratio:.0%} of input). Bypasses all vocabulary-based layers."
            ),
            evidence    = text[:60],
        ))
        logger.debug(
            "Layer 2 [Signal 9] morse_code_obfuscation score=%.3f (%d tokens)",
            score, len(morse_tokens),
        )

    # ── Signal 10: Zalgo / combining-character obfuscation ───────────────
    # Stacking combining diacritics (̊̃̇…) on base characters corrupts the text
    # for regex matching while still looking (mostly) readable to humans.
    combining_chars = _COMBINING_CHAR.findall(text)
    if combining_chars:
        visible_chars = max(1, sum(
            1 for c in text
            if not unicodedata.combining(c) and not c.isspace()
        ))
        combine_ratio = len(combining_chars) / visible_chars
        if combine_ratio > 0.08:
            score = min(1.0, 0.55 + combine_ratio * 2.5)
            signals.append(HeuristicSignal(
                name        = "zalgo_combining_obfuscation",
                score       = score,
                description = (
                    f"Zalgo / combining-character obfuscation: "
                    f"{len(combining_chars)} combining chars on {visible_chars} "
                    f"visible chars ({combine_ratio:.0%} ratio)."
                ),
                evidence    = text[:40],
            ))
            logger.debug(
                "Layer 2 [Signal 10] zalgo_combining_obfuscation score=%.3f (ratio=%.3f)",
                score, combine_ratio,
            )

    # ── Signal 11: Mathematical Unicode lookalike ─────────────────────────
    # Attackers use fonts from U+1D400–U+1D7FF that render identically to ASCII.
    # 𝑰𝒈𝒏𝒐𝒓𝒆 looks like "Ignore" but no standard regex will match it.
    math_chars = _MATH_UNICODE.findall(text)
    if math_chars:
        score = min(1.0, 0.60 + len(math_chars) * 0.04)
        signals.append(HeuristicSignal(
            name        = "mathematical_unicode_lookalike",
            score       = score,
            description = (
                f"Mathematical Unicode lookalike substitution: "
                f"{len(math_chars)} chars from U+1D400–U+1D7FF block. "
                f"Visually identical to ASCII but bypasses all regex layers."
            ),
            evidence    = f"{len(math_chars)} chars in mathematical Unicode block",
        ))
        logger.debug(
            "Layer 2 [Signal 11] mathematical_unicode_lookalike score=%.3f (%d chars)",
            score, len(math_chars),
        )

    # ── Signal 12: Letter-spacing obfuscation ────────────────────────────
    # "I g n o r e   a l l   r u l e s" — single character per token.
    # Collapse spaces and check for injection vocabulary in the joined text.
    spaced_matches = _LETTER_SPACING.findall(text)
    if spaced_matches:
        collapsed = re.sub(r"\s", "", text.lower())
        inj_words = re.findall(
            r"(ignore|disregard|forget|override|systemprompt|unrestricted|nofilter)",
            collapsed, re.IGNORECASE,
        )
        score = min(1.0, 0.50 + (0.30 if inj_words else 0.0))
        signals.append(HeuristicSignal(
            name        = "letter_spacing_obfuscation",
            score       = score,
            description = (
                "Letter-spacing obfuscation detected. "
                + (f"Injection vocabulary found after collapsing: {inj_words}."
                   if inj_words else "No keywords yet — monitoring.")
            ),
            evidence    = spaced_matches[0][:50],
        ))
        logger.debug(
            "Layer 2 [Signal 12] letter_spacing_obfuscation score=%.3f (keywords=%s)",
            score, inj_words,
        )

    # ── Signal 13: Non-Latin script injection ─────────────────────────────
    # L1, L3, and L4 are all English-vocabulary dependent.
    # This signal detects scripts exceeding 18% of content and checks for
    # injection-related keywords in the native language.
    total_chars = max(1, len([c for c in text if not c.isspace()]))
    for script_name, script_re in _SCRIPT_REGEXES.items():
        script_chars = script_re.findall(text)
        script_ratio = len(script_chars) / total_chars
        if script_ratio < 0.18:
            continue

        kw_hits = sum(1 for kw in _SCRIPT_KEYWORDS.get(script_name, []) if kw in text)

        # Dense scripts (CJK, Japanese) — 1 keyword is a strong signal
        # because legitimate use of "忽略" (ignore) in context is very rare.
        kw_multiplier = 0.18 if script_name in ("cjk", "japanese", "hangul") else 0.12
        kw_bonus      = min(0.52, kw_hits * kw_multiplier)
        if kw_hits >= 1 and script_name in ("cjk", "japanese", "devanagari"):
            kw_bonus = max(kw_bonus, 0.22)

        score = min(1.0, 0.38 + kw_bonus + (0.12 if script_ratio > 0.55 else 0.0))
        signals.append(HeuristicSignal(
            name        = f"non_latin_script_{script_name}",
            score       = score,
            description = (
                f"Non-Latin script ({script_name}) detected: {script_ratio:.0%} of "
                f"content. {kw_hits} injection keyword(s) found. "
                f"L1/L3/L4 are blind to this script — L2 is the sole defence."
            ),
            evidence    = f"{script_name}: {len(script_chars)} chars, {kw_hits} keywords",
        ))
        logger.debug(
            "Layer 2 [Signal 13] non_latin_script_%s score=%.3f "
            "(ratio=%.2f kw_hits=%d)",
            script_name, score, script_ratio, kw_hits,
        )

    # ── Signal 14: Leetspeak substitution ────────────────────────────────
    # 1gn0r3 = ignore, 0verr1de = override, byp4ss = bypass.
    # Check specific known patterns first, fall back to generic density check.
    leet_specific = _LEET_SPECIFIC.findall(text)
    if leet_specific:
        score = min(1.0, 0.65 + len(leet_specific) * 0.15)
        signals.append(HeuristicSignal(
            name        = "leetspeak_injection",
            score       = score,
            description = "Known leet-speak injection keyword(s) detected.",
            evidence    = ", ".join(leet_specific[:5]),
        ))
        logger.debug("Layer 2 [Signal 14] leetspeak_injection score=%.3f", score)
    else:
        leet_tokens = _LEET_GENERIC.findall(text)
        if len(leet_tokens) > 3 and len(leet_tokens) / words > 0.15:
            score = min(1.0, 0.35 + (len(leet_tokens) / words) * 2.0)
            signals.append(HeuristicSignal(
                name        = "leetspeak_generic",
                score       = score,
                description = (
                    f"Generic leet-speak density: {len(leet_tokens)} mixed "
                    f"letter+digit tokens ({len(leet_tokens)/words:.0%} of text)."
                ),
                evidence    = ", ".join(leet_tokens[:4]),
            ))
            logger.debug("Layer 2 [Signal 14] leetspeak_generic score=%.3f", score)

    # =======================================================================
    # Score Aggregation
    # =======================================================================
    if not signals:
        logger.debug("Layer 2: no signals fired — returning 0.0")
        return 0.0, []

    enc_scores   = [
        s.score for s in signals
        if s.name in _ENCODING_SIGNAL_NAMES or s.name.startswith(_SCRIPT_SIGNAL_PREFIX)
    ]
    other_scores = [
        s.score for s in signals
        if s.name not in _ENCODING_SIGNAL_NAMES
        and not s.name.startswith(_SCRIPT_SIGNAL_PREFIX)
    ]

    if enc_scores:
        # Encoding attack: the dominant obfuscation signal drives the score.
        # Other signals add a small bump (0.28 weight) to avoid false negatives
        # when both obfuscation and vocabulary signals fire together.
        enc_primary = max(enc_scores)
        other_avg   = (sum(other_scores) / len(other_scores)) if other_scores else 0.0
        aggregate   = min(1.0, enc_primary * 0.72 + other_avg * 0.28)
    else:
        # Vocabulary-based signals: weighted average with mild compression.
        raw       = sum(s.score for s in signals) / (len(signals) * 0.7 + 1)
        aggregate = min(1.0, raw)

    aggregate = round(aggregate, 4)
    logger.debug(
        "Layer 2: aggregate=%.4f from %d signal(s) "
        "(encoding=%d vocabulary=%d)",
        aggregate, len(signals), len(enc_scores), len(other_scores),
    )

    return aggregate, signals
