"""
AgentDrop Shield -- Layer 4c: Semantic Analyzer.

ML-like intent classifier that catches subtle prompt injection attacks
that bypass pattern-based detection by analyzing semantic structure:

    1. Action verb targeting -- system-level action verbs aimed at
       sensitive resources
    2. Sensitive target detection -- file paths, IPs, ports, credentials,
       system info references
    3. Authority framing -- phrases that claim legitimacy for suspicious
       requests
    4. Instruction density -- ratio of imperative/instructive content
       vs informational content
    5. Context mismatch -- filename vs actual content divergence

Returns a SemanticScore 0.0--1.0 with detailed signal breakdown.

Uses only Python stdlib.
"""

from __future__ import annotations

import logging
import math
import os
import re
from dataclasses import dataclass, field

logger = logging.getLogger("agentdrop.shield.semantic_analyzer")


# =====================================================================
# Signal Definitions
# =====================================================================

# --- 1. Action verbs that target system-level actions ---
# These verbs are suspicious when combined with sensitive targets.
_ACTION_VERBS_HIGH: frozenset[str] = frozenset([
    "execute", "run", "scan", "extract", "exfiltrate", "upload",
    "download", "dump", "export", "transmit", "send", "forward",
    "copy", "transfer", "connect", "ssh", "telnet", "ping",
    "nmap", "wget", "curl",
])

_ACTION_VERBS_MEDIUM: frozenset[str] = frozenset([
    "check", "report", "list", "show", "display", "print", "output",
    "reveal", "read", "open", "access", "query", "fetch", "get",
    "lookup", "find", "locate", "enumerate", "inspect", "probe",
    "test", "verify", "confirm",
])

_ACTION_VERBS_LOW: frozenset[str] = frozenset([
    "log", "record", "save", "store", "write", "note", "document",
    "include", "add", "append", "attach", "embed",
])

_ALL_ACTION_VERBS = _ACTION_VERBS_HIGH | _ACTION_VERBS_MEDIUM | _ACTION_VERBS_LOW

# --- 2. Sensitive targets ---

# File paths
_SENSITIVE_PATH_PATTERNS: list[re.Pattern] = [
    re.compile(r"/etc/(?:passwd|shadow|hosts|sudoers|ssh|ssl|crontab)", re.IGNORECASE),
    re.compile(r"~/.(?:ssh|gnupg|aws|azure|gcloud|config|bash_history|env)", re.IGNORECASE),
    re.compile(r"[A-Z]:\\(?:Windows|Users|Program\s*Files)", re.IGNORECASE),
    re.compile(r"/(?:proc|sys|dev|var/log|tmp)/", re.IGNORECASE),
    re.compile(r"\.(?:env|pem|key|crt|pfx|p12|jks|keystore)\b", re.IGNORECASE),
    re.compile(r"(?:id_rsa|id_ed25519|authorized_keys|known_hosts)\b", re.IGNORECASE),
    re.compile(r"(?:credentials|secrets?)\.(?:json|yaml|yml|xml|ini|conf|cfg)\b", re.IGNORECASE),
    re.compile(r"\.(?:git|svn|hg)/config\b", re.IGNORECASE),
    re.compile(r"wp-config\.php\b", re.IGNORECASE),
]

# Network targets
_NETWORK_PATTERNS: list[re.Pattern] = [
    # IPv4 addresses (not loopback 127.x.x.x only -- private ranges are suspicious too)
    re.compile(r"\b(?:10|172\.(?:1[6-9]|2\d|3[01])|192\.168)\.\d{1,3}\.\d{1,3}\b"),
    # Any IP with port
    re.compile(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d{1,5}\b"),
    # Port references
    re.compile(r"\bport\s+\d{1,5}\b", re.IGNORECASE),
    # Specific dangerous ports
    re.compile(r"\bport\s+(?:22|23|25|53|80|443|445|1433|1521|3306|3389|5432|5900|6379|8080|8443|9200|27017)\b", re.IGNORECASE),
    # URL patterns pointing to internal/suspicious targets
    re.compile(r"(?:http|ftp|ssh|telnet)://\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}", re.IGNORECASE),
    # DNS lookups / internal hostnames
    re.compile(r"\b(?:nslookup|dig|host)\s+\S+", re.IGNORECASE),
]

# System info targets
_SYSTEM_INFO_PATTERNS: list[re.Pattern] = [
    re.compile(r"\b(?:hostname|whoami|uname|id|ifconfig|ipconfig|systeminfo)\b", re.IGNORECASE),
    re.compile(r"\bcurrent\s+user(?:name)?\b", re.IGNORECASE),
    re.compile(r"\bsystem\s+hostname\b", re.IGNORECASE),
    re.compile(r"\b(?:environment|env)\s+var(?:iable)?s?\b", re.IGNORECASE),
    re.compile(r"\b(?:api[\s_-]?key|secret[\s_-]?key|access[\s_-]?key|auth[\s_-]?token|bearer[\s_-]?token)\b", re.IGNORECASE),
    re.compile(r"\b(?:password|passwd|credentials?|private[\s_-]?key)\b", re.IGNORECASE),
    re.compile(r"\b(?:AWS_SECRET|AWS_ACCESS|AZURE_|GCP_|OPENAI_API|ANTHROPIC_API)\w*\b"),
    re.compile(r"\b(?:DATABASE_URL|DB_PASSWORD|REDIS_URL|MONGO_URI)\b", re.IGNORECASE),
    re.compile(r"\bprocess\s+(?:list|table|id|pid)\b", re.IGNORECASE),
    re.compile(r"\b(?:network\s+)?(?:interfaces?|adapters?|connections?)\b", re.IGNORECASE),
    re.compile(r"\b(?:open\s+)?ports?\s+(?:scan|check|list|on)\b", re.IGNORECASE),
]

# Email addresses (potential exfiltration targets)
_EMAIL_PATTERN = re.compile(r"\b[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}\b")

# --- 3. Authority framing phrases ---
_AUTHORITY_PATTERNS: list[tuple[re.Pattern, float, str]] = [
    (re.compile(r"\bthis\s+is\s+(?:needed|required|necessary)\s+(?:for|by)\b", re.IGNORECASE), 0.7, "needed_for"),
    (re.compile(r"\bas\s+part\s+of\s+(?:the\s+)?(?:audit|review|assessment|compliance|check|inspection)\b", re.IGNORECASE), 0.8, "part_of_audit"),
    (re.compile(r"\b(?:the\s+)?(?:security|IT|admin|compliance|ops|devops)\s+team\s+(?:requested|requires?|asked|needs?)\b", re.IGNORECASE), 0.85, "team_requested"),
    (re.compile(r"\b(?:authorized|approved|sanctioned|permitted)\s+(?:by|per|under)\b", re.IGNORECASE), 0.75, "authorized_by"),
    (re.compile(r"\bcompliance\s+(?:requires?|mandates?|dictates?|demands?)\b", re.IGNORECASE), 0.8, "compliance_requires"),
    (re.compile(r"\bmandatory\s+(?:check|scan|audit|review|test|verification)\b", re.IGNORECASE), 0.7, "mandatory_check"),
    (re.compile(r"\b(?:admin|administrator|root|manager|supervisor)\s+(?:requested|approved|authorized)\b", re.IGNORECASE), 0.85, "admin_requested"),
    (re.compile(r"\b(?:per|according\s+to|as\s+per)\s+(?:policy|regulation|requirement|standard|procedure|protocol)\b", re.IGNORECASE), 0.65, "per_policy"),
    (re.compile(r"\bfor\s+(?:the\s+)?(?:compliance|security|regulatory)\s+(?:audit|review|purposes?)\b", re.IGNORECASE), 0.75, "for_compliance"),
    (re.compile(r"\b(?:routine|standard|regular|scheduled)\s+(?:security|maintenance|compliance)\s+(?:check|scan|audit|review)\b", re.IGNORECASE), 0.7, "routine_check"),
    (re.compile(r"\bplease\s+(?:also\s+)?(?:check|verify|confirm|report|scan|test)\b", re.IGNORECASE), 0.4, "please_check"),
    (re.compile(r"\bone\s+(?:more\s+)?thing\s+(?:to\s+)?(?:note|check|do|verify)\b", re.IGNORECASE), 0.5, "one_thing_to_note"),
    (re.compile(r"\b(?:while\s+you(?:'re|\s+are)\s+at\s+it|in\s+addition|additionally|also)\b", re.IGNORECASE), 0.35, "while_at_it"),
    # Helpful assistant framing -- social engineering the AI's helpfulness
    (re.compile(r"\b(?:you\s+are\s+a\s+helpful|be\s+helpful|always\s+does?\s+what\s+(?:the\s+)?user\s+asks?)\b", re.IGNORECASE), 0.75, "helpful_assistant_framing"),
    (re.compile(r"\b(?:the\s+user\s+has\s+(?:asked|requested)|user\s+(?:wants?|needs?)\s+you\s+to)\b", re.IGNORECASE), 0.7, "user_has_asked"),
    # Research/academic framing to extract AI configuration
    (re.compile(r"\bfor\s+(?:our|the|a)\s+(?:research|study|paper|analysis|investigation|report)\b", re.IGNORECASE), 0.5, "research_framing"),
    (re.compile(r"\b(?:document|record|catalog(?:ue)?)\s+(?:the\s+)?(?:exact|full|complete|entire)\s+(?:configuration|setup|instructions?|parameters?|settings?)\b", re.IGNORECASE), 0.8, "document_configuration"),
]

# --- 4. Instruction sentence patterns ---
# Patterns that identify instructive sentences (vs informational).
_INSTRUCTION_SENTENCE_PATTERNS: list[re.Pattern] = [
    re.compile(r"^\s*(?:please\s+)?(?:" + "|".join(_ALL_ACTION_VERBS) + r")\b", re.IGNORECASE),
    re.compile(r"^\s*(?:you\s+)?(?:should|must|need\s+to|have\s+to|ought\s+to)\s+", re.IGNORECASE),
    re.compile(r"^\s*(?:make\s+sure|ensure|verify|confirm|validate)\s+(?:that\s+)?", re.IGNORECASE),
    re.compile(r"^\s*(?:do\s+not|don'?t|never)\s+", re.IGNORECASE),
    re.compile(r"\band\s+(?:report|send|upload|forward|copy|output|display|show|print)\s+", re.IGNORECASE),
    re.compile(r"\b(?:report\s+back|let\s+me\s+know|provide\s+(?:the|a)\s+(?:list|output|result))\b", re.IGNORECASE),
]

# --- 5. Content type inference from filename ---
_FILENAME_CONTENT_HINTS: dict[str, set[str]] = {
    # Financial documents
    "financial": {"finance", "financial", "budget", "revenue", "profit", "loss", "balance", "income", "expense", "accounting", "fiscal", "quarterly", "annual"},
    "report": {"report", "summary", "overview", "analysis", "review", "findings", "metrics", "dashboard", "status"},
    "invoice": {"invoice", "receipt", "payment", "billing", "order", "purchase"},
    "hr": {"employee", "hr", "human-resource", "salary", "payroll", "hiring", "resume", "cv"},
    "legal": {"contract", "agreement", "legal", "terms", "policy", "nda", "compliance", "regulation"},
    "medical": {"medical", "patient", "health", "diagnosis", "prescription", "clinical", "lab-result"},
    "marketing": {"marketing", "campaign", "ad", "promotion", "brand", "social-media"},
    "technical": {"technical", "architecture", "design", "spec", "specification", "api", "schema", "config"},
}

# System/network operation keywords that indicate mismatch with document content
_SYSTEM_OPERATION_KEYWORDS: set[str] = {
    "port", "scan", "nmap", "ping", "ssh", "telnet", "hostname",
    "whoami", "ifconfig", "ipconfig", "systeminfo", "netstat",
    "iptables", "firewall", "reverse shell", "bind shell",
    "execute", "command", "subprocess", "shell", "bash", "powershell",
    "cmd.exe", "/bin/sh", "wget", "curl", "nc ", "netcat",
}


# =====================================================================
# Result Model
# =====================================================================

@dataclass
class SemanticSignal:
    """A single semantic signal that contributed to the score."""
    signal_name: str
    score: float
    detail: str
    matches: list[str] = field(default_factory=list)


@dataclass
class SemanticScore:
    """Result of the semantic analysis."""

    score: float = 0.0
    """Overall semantic risk score 0.0--1.0."""

    signals: list[SemanticSignal] = field(default_factory=list)
    """Breakdown of individual signals."""

    action_verb_score: float = 0.0
    """Score from action verb targeting analysis."""

    sensitive_target_score: float = 0.0
    """Score from sensitive target detection."""

    authority_framing_score: float = 0.0
    """Score from authority framing analysis."""

    instruction_density_score: float = 0.0
    """Score from instruction density analysis."""

    context_mismatch_score: float = 0.0
    """Score from context mismatch analysis."""


# =====================================================================
# Analyzer
# =====================================================================

class SemanticAnalyzer:
    """Layer 4c -- semantic analysis for subtle injection detection.

    Analyzes text for semantic patterns that indicate prompt injection
    attempts disguised as legitimate requests.  Focuses on detecting
    attacks that bypass keyword-based detection by using natural
    language framing.

    Thread-safe: holds no mutable state between calls.
    """

    # Signal weights
    WEIGHT_ACTION_VERBS = 0.20
    WEIGHT_SENSITIVE_TARGETS = 0.25
    WEIGHT_AUTHORITY_FRAMING = 0.20
    WEIGHT_INSTRUCTION_DENSITY = 0.15
    WEIGHT_CONTEXT_MISMATCH = 0.20

    # Sliding window parameters for large-document dilution defense.
    WINDOW_SIZE = 500
    WINDOW_STEP = 300
    WINDOW_MIN_DOC_LENGTH = 1500

    def analyze(
        self,
        texts: list[tuple[str, str]],
        filename: str = "",
    ) -> SemanticScore:
        """Analyze extracted text for semantic injection signals.

        Uses a two-pass approach for large documents:
        1. Global pass: score the full document (original behavior).
        2. Windowed pass: score overlapping windows and take the max.

        The final score is max(global, best_window) to defeat dilution
        attacks where malicious lines are buried in large clean documents.

        Args:
            texts: List of (source_label, normalized_text) from Layer 3.
            filename: Original filename for context mismatch analysis.

        Returns:
            SemanticScore with composite score and signal breakdown.
        """
        if not texts:
            return SemanticScore()

        combined = "\n".join(text for _, text in texts if text.strip())
        if not combined.strip():
            return SemanticScore()

        # --- Pass 1: Global scoring (original behavior) ---
        global_result = self._analyze_text(combined, filename)

        # --- Pass 2: Sliding window scoring for large documents ---
        if len(combined) >= self.WINDOW_MIN_DOC_LENGTH:
            window_result = self._analyze_windowed(combined, filename)
            if window_result.score > global_result.score:
                # Merge the window hotspot signal into the result
                window_result.signals.append(SemanticSignal(
                    "sliding_window_hotspot",
                    window_result.score,
                    f"Windowed analysis detected concentrated injection "
                    f"region (window_score={window_result.score:.2f} "
                    f"vs global={global_result.score:.2f})",
                ))
                return window_result

        return global_result

    def _analyze_text(self, text: str, filename: str = "") -> SemanticScore:
        """Score a single block of text (the core analysis logic).

        This is the original analyze() logic, extracted so it can be
        reused for both global and per-window analysis.
        """
        result = SemanticScore()
        signals: list[SemanticSignal] = []

        # --- Signal 1: Action verb targeting ---
        av_score, av_signal = self._score_action_verbs(text)
        result.action_verb_score = av_score
        if av_signal:
            signals.append(av_signal)

        # --- Signal 2: Sensitive target detection ---
        st_score, st_signal = self._score_sensitive_targets(text)
        result.sensitive_target_score = st_score
        if st_signal:
            signals.append(st_signal)

        # --- Signal 3: Authority framing ---
        af_score, af_signal = self._score_authority_framing(text)
        result.authority_framing_score = af_score
        if af_signal:
            signals.append(af_signal)

        # --- Signal 4: Instruction density ---
        id_score, id_signal = self._score_instruction_density(text)
        result.instruction_density_score = id_score
        if id_signal:
            signals.append(id_signal)

        # --- Signal 5: Context mismatch ---
        cm_score, cm_signal = self._score_context_mismatch(text, filename)
        result.context_mismatch_score = cm_score
        if cm_signal:
            signals.append(cm_signal)

        # --- Composite score ---
        composite = (
            av_score * self.WEIGHT_ACTION_VERBS
            + st_score * self.WEIGHT_SENSITIVE_TARGETS
            + af_score * self.WEIGHT_AUTHORITY_FRAMING
            + id_score * self.WEIGHT_INSTRUCTION_DENSITY
            + cm_score * self.WEIGHT_CONTEXT_MISMATCH
        )

        # --- Combination boost ---
        # When action verbs target sensitive resources AND authority framing
        # is used, this is a strong signal of a disguised attack.
        if av_score >= 0.3 and st_score >= 0.3 and af_score >= 0.3:
            boost = 0.20
            composite = min(composite + boost, 1.0)
            signals.append(SemanticSignal(
                "combination_boost", boost,
                "Action verbs + sensitive targets + authority framing co-occur",
            ))

        # When authority framing and sensitive targets co-occur (even
        # without high action verb score), this is the classic "compliance
        # audit" social engineering pattern.
        elif af_score >= 0.4 and st_score >= 0.3:
            boost = 0.15
            composite = min(composite + boost, 1.0)
            signals.append(SemanticSignal(
                "authority_target_boost", boost,
                "Authority framing combined with sensitive target references",
            ))

        # When action verbs and sensitive targets co-occur (even without
        # authority framing), still boost moderately.
        elif av_score >= 0.4 and st_score >= 0.4:
            boost = 0.12
            composite = min(composite + boost, 1.0)
            signals.append(SemanticSignal(
                "action_target_boost", boost,
                "Action verbs directly targeting sensitive resources",
            ))

        # High instruction density in non-document context
        if id_score >= 0.5 and cm_score >= 0.3:
            boost = 0.12
            composite = min(composite + boost, 1.0)
            signals.append(SemanticSignal(
                "density_mismatch_boost", boost,
                "High instruction density in unexpected document type",
            ))

        # Authority framing alone at high level is suspicious
        if af_score >= 0.7 and id_score >= 0.3:
            boost = 0.10
            composite = min(composite + boost, 1.0)
            signals.append(SemanticSignal(
                "authority_density_boost", boost,
                "Strong authority framing combined with instructive content",
            ))

        composite = round(min(composite, 1.0), 4)
        result.score = composite
        result.signals = signals

        return result

    def _analyze_windowed(self, text: str, filename: str = "") -> SemanticScore:
        """Run sliding window analysis and return the highest-scoring window.

        Slides a window of WINDOW_SIZE characters across the text with
        WINDOW_STEP overlap.  Returns the SemanticScore from the window
        with the highest composite score.
        """
        best_result = SemanticScore()

        pos = 0
        text_len = len(text)
        while pos < text_len:
            end = min(pos + self.WINDOW_SIZE, text_len)
            window = text[pos:end]

            if len(window.strip()) > 50:
                result = self._analyze_text(window, filename)
                if result.score > best_result.score:
                    best_result = result

            pos += self.WINDOW_STEP

            # Early exit on high confidence
            if best_result.score >= 0.85:
                break

        return best_result

    # ------------------------------------------------------------------
    # Signal scorers
    # ------------------------------------------------------------------

    def _score_action_verbs(self, text: str) -> tuple[float, SemanticSignal | None]:
        """Score based on action verbs that target system-level actions."""
        words = re.findall(r"[a-z]+", text.lower())
        if not words:
            return 0.0, None

        high_matches = [w for w in words if w in _ACTION_VERBS_HIGH]
        medium_matches = [w for w in words if w in _ACTION_VERBS_MEDIUM]
        low_matches = [w for w in words if w in _ACTION_VERBS_LOW]

        # Weighted count
        weighted = len(set(high_matches)) * 3.0 + len(set(medium_matches)) * 1.5 + len(set(low_matches)) * 0.5

        # Normalize: 3 distinct action verbs -> 0.5, 8+ -> 1.0
        score = min(weighted / 12.0, 1.0)

        # Boost if multiple DIFFERENT high-risk verbs appear
        unique_high = len(set(high_matches))
        if unique_high >= 3:
            score = min(score * 1.3, 1.0)
        elif unique_high >= 2:
            score = min(score * 1.15, 1.0)

        score = round(score, 4)
        if score <= 0.0:
            return 0.0, None

        all_matches = list(set(high_matches + medium_matches + low_matches))
        return score, SemanticSignal(
            "action_verb_targeting", score,
            f"Found {len(all_matches)} distinct action verbs targeting system operations",
            matches=all_matches[:10],
        )

    def _score_sensitive_targets(self, text: str) -> tuple[float, SemanticSignal | None]:
        """Score based on references to sensitive resources."""
        matches: list[str] = []

        # File paths
        for pat in _SENSITIVE_PATH_PATTERNS:
            for m in pat.finditer(text):
                matches.append(f"path:{m.group()[:50]}")

        # Network targets
        for pat in _NETWORK_PATTERNS:
            for m in pat.finditer(text):
                matches.append(f"network:{m.group()[:50]}")

        # System info
        for pat in _SYSTEM_INFO_PATTERNS:
            for m in pat.finditer(text):
                matches.append(f"sysinfo:{m.group()[:50]}")

        # Email addresses in context with action verbs
        email_matches = _EMAIL_PATTERN.findall(text)
        text_lower = text.lower()
        if email_matches:
            exfil_verbs = {"send", "forward", "transmit", "email", "mail", "report"}
            if any(v in text_lower for v in exfil_verbs):
                for em in email_matches[:3]:
                    matches.append(f"email_exfil:{em}")

        if not matches:
            return 0.0, None

        # Score: 1 match -> 0.3, 3 -> 0.6, 5+ -> 0.9+
        unique_matches = list(set(matches))
        score = min(0.2 + len(unique_matches) * 0.15, 1.0)

        # Boost for diverse target types
        target_types = set(m.split(":")[0] for m in unique_matches)
        if len(target_types) >= 3:
            score = min(score + 0.15, 1.0)
        elif len(target_types) >= 2:
            score = min(score + 0.08, 1.0)

        score = round(score, 4)
        return score, SemanticSignal(
            "sensitive_target_detection", score,
            f"Found {len(unique_matches)} references to sensitive resources across {len(target_types)} categories",
            matches=unique_matches[:10],
        )

    @staticmethod
    def _score_authority_framing(text: str) -> tuple[float, SemanticSignal | None]:
        """Score based on authority-claiming phrases."""
        matches: list[str] = []
        max_score = 0.0

        for pat, weight, name in _AUTHORITY_PATTERNS:
            if pat.search(text):
                matches.append(name)
                max_score = max(max_score, weight)

        if not matches:
            return 0.0, None

        # Use max score, boosted by count
        count_boost = min(len(matches) * 0.06, 0.25)
        score = min(max_score + count_boost, 1.0)
        score = round(score, 4)

        return score, SemanticSignal(
            "authority_framing", score,
            f"Found {len(matches)} authority-claiming phrases",
            matches=matches,
        )

    @staticmethod
    def _score_instruction_density(text: str) -> tuple[float, SemanticSignal | None]:
        """Score based on ratio of instructive vs informational content.

        Splits text into sentences and classifies each as instructive
        or informational.  A document that is mostly data with some
        hidden instructions still flags the instruction portion.
        """
        # Split into sentences
        sentences = re.split(r"[.!?\n]+", text)
        sentences = [s.strip() for s in sentences if len(s.strip()) > 5]

        if len(sentences) < 2:
            return 0.0, None

        instructive_count = 0
        for sent in sentences:
            for pat in _INSTRUCTION_SENTENCE_PATTERNS:
                if pat.search(sent):
                    instructive_count += 1
                    break

        ratio = instructive_count / len(sentences)

        # Score: <10% instructions = 0.0, 30% = 0.5, 60%+ = 1.0
        if ratio < 0.08:
            score = 0.0
        else:
            score = min((ratio - 0.08) / 0.52, 1.0)

        # Bonus: even if ratio is low, if there are several instruction
        # sentences in a large informational document, that's suspicious
        if instructive_count >= 3 and ratio < 0.2 and len(sentences) > 10:
            score = max(score, 0.4)

        score = round(score, 4)
        if score <= 0.0:
            return 0.0, None

        return score, SemanticSignal(
            "instruction_density", score,
            f"{instructive_count}/{len(sentences)} sentences are instructive ({ratio:.0%})",
            matches=[f"instructive={instructive_count}", f"total={len(sentences)}"],
        )

    @staticmethod
    def _score_context_mismatch(text: str, filename: str) -> tuple[float, SemanticSignal | None]:
        """Score based on mismatch between filename and content.

        If the file is named 'financial-report.txt' but contains
        network scanning commands, that's a strong signal.
        """
        if not filename:
            return 0.0, None

        # Extract expected content category from filename
        fname_lower = os.path.splitext(filename.lower())[0]
        # Normalize separators
        fname_lower = fname_lower.replace("-", " ").replace("_", " ").replace(".", " ")
        fname_words = set(fname_lower.split())

        expected_category: str | None = None
        for category, keywords in _FILENAME_CONTENT_HINTS.items():
            if fname_words & keywords:
                expected_category = category
                break

        if expected_category is None:
            # Cannot determine expected content type from filename
            return 0.0, None

        # Check if content contains system/network operation keywords
        text_lower = text.lower()
        mismatch_keywords_found: list[str] = []
        for kw in _SYSTEM_OPERATION_KEYWORDS:
            if kw in text_lower:
                mismatch_keywords_found.append(kw)

        if not mismatch_keywords_found:
            return 0.0, None

        # Score based on count and severity
        score = min(0.3 + len(mismatch_keywords_found) * 0.12, 1.0)
        score = round(score, 4)

        return score, SemanticSignal(
            "context_mismatch", score,
            f"File '{filename}' expected {expected_category} content "
            f"but found {len(mismatch_keywords_found)} system operation keywords",
            matches=mismatch_keywords_found[:10],
        )
