"""
Content Filter Guardrail for LiteLLM.

This guardrail provides regex pattern matching and keyword filtering
to detect and block/mask sensitive content.
"""

import asyncio
import json
import os
import re
from collections.abc import AsyncGenerator, Coroutine, Mapping, Sequence
from datetime import datetime
from re import Pattern
from typing import TYPE_CHECKING, Any, Final, Literal, Optional, TypedDict, cast

import yaml
from fastapi import HTTPException

from litellm import Router
from litellm._logging import verbose_proxy_logger
from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.proxy._types import UserAPIKeyAuth
from litellm.types.utils import (
    CallTypes,
    Function,
    GenericGuardrailAPIInputs,
    GuardrailStatus,
    GuardrailTracingDetail,
    ModelResponse,
    ModelResponseStream,
)

if TYPE_CHECKING:
    from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj

from litellm.types.guardrails import (
    BlockedWord,
    ContentFilterAction,
    ContentFilterPattern,
    GuardrailEventHooks,
    Mode,
)
from litellm.types.proxy.guardrails.guardrail_hooks.litellm_content_filter import (
    BlockedWordDetection,
    CategoryKeywordDetection,
    CompetitorIntentDetection,
    CompetitorIntentResult,
    ContentFilterCategoryConfig,
    ContentFilterDetection,
    PatternDetection,
)

from .competitor_intent import (
    AirlineCompetitorIntentChecker,
    BaseCompetitorIntentChecker,
)
from .patterns import PATTERN_EXTRA_CONFIG, get_compiled_pattern

MAX_KEYWORD_VALUE_GAP_WORDS: Final = 1
GAP_WORD_TOKENIZER: Final = re.compile(r"\b\w+\b")


WORD_NUMBER_MAP: Final = {
    "zero": "0",
    "oh": "0",
    "one": "1",
    "two": "2",
    "three": "3",
    "four": "4",
    "five": "5",
    "six": "6",
    "seven": "7",
    "eight": "8",
    "nine": "9",
}

WORD_NUMBER_TOKEN_REGEX: Final = "|".join(WORD_NUMBER_MAP.keys())
WORD_NUMBER_SEQUENCE_PATTERN: Final = re.compile(
    rf"(?<![A-Za-z])(?:{WORD_NUMBER_TOKEN_REGEX})(?:[\s\-]+(?:{WORD_NUMBER_TOKEN_REGEX}))+(?![A-Za-z])",
    re.IGNORECASE,
)
WORD_NUMBER_TOKEN_FINDER: Final = re.compile(rf"(?:{WORD_NUMBER_TOKEN_REGEX})", re.IGNORECASE)


class ConditionalCategoryConfig(TypedDict):
    identifier_words: Sequence[str]
    block_words: Sequence[str]
    action: ContentFilterAction
    severity: str


class CompiledPatternEntry(TypedDict):
    regex: Pattern[str]
    pattern_name: str
    action: ContentFilterAction
    keyword_regex: Pattern[str] | None
    allow_word_numbers: bool


class _PatternExtraLookup(TypedDict):
    keyword_pattern: str | None
    allow_word_numbers: bool


class _CategoryConfigView(TypedDict):
    category: object
    enabled: object
    action: object
    category_file: str | None


class CategoryFileData(TypedDict, total=False):
    category_name: str
    description: str
    default_action: str
    keywords: Sequence[Mapping[str, str]]
    exceptions: Sequence[str]
    identifier_words: Sequence[str]
    always_block_keywords: Sequence[Mapping[str, str]]
    inherit_from: str
    additional_block_words: Sequence[str]
    phrase_patterns: Sequence[str]


# Helper data structure for category-based detection
class CategoryConfig:
    """Configuration for a content category."""

    def __init__(
        self,
        category_name: str,
        description: str,
        default_action: ContentFilterAction,
        keywords: Sequence[Mapping[str, str]],
        exceptions: Sequence[str],
        identifier_words: Sequence[str] | None = None,
        always_block_keywords: Sequence[Mapping[str, str]] | None = None,
        inherit_from: str | None = None,
        additional_block_words: Sequence[str] | None = None,
        phrase_patterns: Sequence[str] | None = None,
    ):
        self.category_name = category_name
        self.description = description
        self.default_action = default_action
        self.keywords = keywords
        self.exceptions = [e.lower() for e in exceptions]
        # New fields for conditional child safety logic
        self.identifier_words = [w.lower() for w in identifier_words] if identifier_words else []
        self.always_block_keywords = always_block_keywords or []
        self.inherit_from = inherit_from
        self.additional_block_words = [w.lower() for w in additional_block_words] if additional_block_words else []
        # Phrase patterns: regex patterns for catching paraphrases
        self.phrase_patterns: list[tuple[str, Pattern]] = []
        for p in phrase_patterns or []:
            try:
                self.phrase_patterns.append((p, re.compile(p, re.IGNORECASE)))
            except re.error:
                verbose_proxy_logger.warning("Invalid phrase pattern in %s: %s", category_name, p)


class ContentFilterGuardrail(CustomGuardrail):
    """
    Content filter guardrail that detects sensitive information using:
    - Prebuilt regex patterns (SSN, credit cards, API keys, etc.)
    - Custom user-defined regex patterns
    - Dictionary-based keyword matching

    Actions:
    - BLOCK: Reject the request with an error
    - MASK: Replace the sensitive content with a redacted placeholder
    """

    # Redaction format constants
    PATTERN_REDACTION_FORMAT = "[{pattern_name}_REDACTED]"
    KEYWORD_REDACTION_STR = "[KEYWORD_REDACTED]"

    def __init__(
        self,
        guardrail_name: str | None = None,
        guardrail_id: str | None = None,
        policy_template: str | None = None,
        patterns: list[ContentFilterPattern] | None = None,
        blocked_words: list[BlockedWord] | None = None,
        blocked_words_file: str | None = None,
        event_hook: GuardrailEventHooks | list[GuardrailEventHooks] | Mode | None = None,
        default_on: bool = False,
        pattern_redaction_format: str | None = None,
        keyword_redaction_tag: str | None = None,
        categories: list[ContentFilterCategoryConfig] | None = None,
        severity_threshold: str = "medium",
        llm_router: Router | None = None,
        image_model: str | None = None,
        competitor_intent_config: dict[str, object] | None = None,
        **kwargs,
    ):
        """
        Initialize the Content Filter Guardrail.

        Args:
            guardrail_name: Name of this guardrail instance
            patterns: List of ContentFilterPattern objects to detect
            blocked_words: List of BlockedWord objects with keywords and actions
            blocked_words_file: Path to YAML file containing blocked_words list
            event_hook: When to run this guardrail (pre_call, post_call, etc.)
            default_on: If True, runs on all requests by default
            pattern_redaction_format: Format string for pattern redaction (use {pattern_name} placeholder)
            keyword_redaction_tag: Tag to use for keyword redaction
            categories: List of category configurations with enabled/action/severity settings
            severity_threshold: Minimum severity to block ("high", "medium", "low")
        """

        super().__init__(
            guardrail_name=guardrail_name,
            supported_event_hooks=list(self.get_supported_event_hooks()),
            event_hook=event_hook or GuardrailEventHooks.pre_call,
            default_on=default_on,
            **kwargs,
        )

        self.guardrail_provider = "litellm_content_filter"
        self.config_guardrail_id = guardrail_id
        self.config_policy_template = policy_template
        self.pattern_redaction_format = pattern_redaction_format or self.PATTERN_REDACTION_FORMAT
        self.keyword_redaction_tag = keyword_redaction_tag or self.KEYWORD_REDACTION_STR
        self.severity_threshold = severity_threshold
        self.llm_router = llm_router
        self.image_model = image_model
        # Store loaded categories
        self.loaded_categories: dict[str, CategoryConfig] = {}
        self.category_keywords: dict[
            str, tuple[str, str, ContentFilterAction]
        ] = {}  # keyword -> (category, severity, action)
        # Always-block keywords are checked after exceptions (exceptions take precedence)
        self.always_block_category_keywords: dict[str, tuple[str, str, ContentFilterAction]] = {}
        # Store conditional categories (identifier_words + block_words)
        self.conditional_categories: dict[str, ConditionalCategoryConfig] = {}

        # Competitor intent checker (optional; airline uses major_airlines.json, generic requires competitors)
        self._competitor_intent_checker: BaseCompetitorIntentChecker | None = None
        if competitor_intent_config and isinstance(competitor_intent_config, dict):
            self._init_competitor_intent_checker(competitor_intent_config)

        # Load categories if provided
        if categories:
            self._load_categories(categories)

        # Normalize inputs: convert dicts to Pydantic models for consistent handling
        normalized_patterns: Final = self._normalize_patterns(patterns)
        normalized_blocked_words: Final = self._normalize_blocked_words(blocked_words)

        # Compile regex patterns
        self.compiled_patterns: list[CompiledPatternEntry] = []
        for pattern_config in normalized_patterns:
            self._add_pattern(pattern_config)

        # Warn if using during_call with MASK action (unstable)
        if self.event_hook == GuardrailEventHooks.during_call and any(
            p["action"] == ContentFilterAction.MASK for p in self.compiled_patterns
        ):
            verbose_proxy_logger.warning(
                "ContentFilterGuardrail '%s': 'during_call' mode with 'MASK' action is unstable due to race conditions. Use 'pre_call' mode for reliable request masking.",
                self.guardrail_name,
            )

        # Load blocked words - always initialize as dict
        self.blocked_words: dict[str, tuple[ContentFilterAction, str | None]] = {}
        for word in normalized_blocked_words:
            self.blocked_words[word.keyword.lower()] = (word.action, word.description)

        # Defensive check: ensure blocked_words is a dict (not a list)
        if not isinstance(self.blocked_words, dict):
            verbose_proxy_logger.error(
                "blocked_words is not a dict, got %s. Resetting to empty dict.", type(self.blocked_words)
            )
            self.blocked_words = {}

        # Load blocked words from file if provided
        if blocked_words_file:
            self._load_blocked_words_file(blocked_words_file)

        verbose_proxy_logger.debug(
            "ContentFilterGuardrail initialized with %s patterns and %s blocked words",
            len(self.compiled_patterns),
            len(self.blocked_words),
        )
        verbose_proxy_logger.debug(
            "Loaded %s categories with %s keywords", len(self.loaded_categories), len(self.category_keywords)
        )

    def _init_competitor_intent_checker(self, competitor_intent_config: dict[str, object]) -> None:
        try:
            competitor_intent_type: Final = competitor_intent_config.get("competitor_intent_type", "airline")
            if competitor_intent_type == "generic":
                self._competitor_intent_checker = BaseCompetitorIntentChecker(competitor_intent_config)
            else:
                self._competitor_intent_checker = AirlineCompetitorIntentChecker(competitor_intent_config)
            verbose_proxy_logger.debug(
                "ContentFilterGuardrail: competitor intent checker enabled (%s)",
                competitor_intent_type,
            )
        except Exception as e:
            verbose_proxy_logger.warning(
                "ContentFilterGuardrail: failed to init competitor intent checker: %s",
                e,
            )

    @staticmethod
    def _normalize_patterns(
        patterns: list[ContentFilterPattern] | None,
    ) -> list[ContentFilterPattern]:
        result: Final[list[ContentFilterPattern]] = []
        if patterns:
            for pattern_config in patterns:
                if isinstance(pattern_config, dict):
                    result.append(ContentFilterPattern(**pattern_config))
                else:
                    result.append(pattern_config)
        return result

    @staticmethod
    def _normalize_blocked_words(
        blocked_words: list[BlockedWord] | None,
    ) -> list[BlockedWord]:
        result: Final[list[BlockedWord]] = []
        if blocked_words:
            for word in blocked_words:
                if isinstance(word, dict):
                    result.append(BlockedWord(**word))
                else:
                    result.append(word)
        return result

    @staticmethod
    def _category_config_view(cat_config: ContentFilterCategoryConfig) -> _CategoryConfigView:
        return {
            "category": cat_config.get("category"),
            "enabled": cat_config.get("enabled", True),
            "action": cat_config.get("action"),
            "category_file": cat_config.get("category_file"),
        }

    @staticmethod
    def _assert_within_categories_dir(path: str, categories_dir: str) -> None:
        """Raise ValueError if path escapes the categories directory."""
        resolved: Final = os.path.realpath(path)
        allowed: Final = os.path.realpath(categories_dir)
        try:
            common: Final = os.path.commonpath([resolved, allowed])
        except ValueError:
            # commonpath() raises ValueError on Windows when paths span different drives
            raise ValueError(f"Category file path '{path}' is outside the allowed categories directory")
        if common != allowed:
            raise ValueError(
                f"Category file path '{path}' is outside the allowed categories directory '{categories_dir}'"
            )

    def _resolve_category_file_path(self, file_path: str) -> str:
        """
        Resolve a category file path that may be relative.

        Paths in policy templates (e.g. category_file) are often stored as
        relative paths like "litellm/proxy/.../policy_templates/file.yaml".
        These only work when the CWD is the project root. In production
        (Docker, installed packages, etc.) the CWD is different, so the
        file isn't found.

        Resolution order:
        1. Return as-is if absolute or already exists (jailed to module dir).
        2. Try joining the full path relative to this module's directory (jailed).
        3. Progressively strip leading path components and try each suffix
           relative to this module's directory (jailed).

        The directory jail can be disabled for deployments that legitimately
        store category files outside the package (e.g. mounted volumes) by
        setting the environment variable
        ``LITELLM_CONTENT_FILTER_ALLOW_EXTERNAL_PATHS=true``.  Use only in
        trusted environments where the proxy configuration cannot be influenced
        by untrusted input.

        Args:
            file_path: The file path to resolve (absolute or relative).

        Returns:
            The resolved absolute-ish path, or the original path if
            resolution fails (caller should check existence).

        Raises:
            ValueError: If the resolved path escapes the module directory
                and ``LITELLM_CONTENT_FILTER_ALLOW_EXTERNAL_PATHS`` is not set.
        """
        module_dir: Final = os.path.dirname(__file__)
        allow_external: Final = os.environ.get("LITELLM_CONTENT_FILTER_ALLOW_EXTERNAL_PATHS", "").lower() == "true"

        if os.path.isabs(file_path) or os.path.exists(file_path):
            if not allow_external:
                self._assert_within_categories_dir(file_path, module_dir)
            else:
                verbose_proxy_logger.warning(
                    "LITELLM_CONTENT_FILTER_ALLOW_EXTERNAL_PATHS is set — "
                    "skipping directory jail for category_file '%s'",
                    file_path,
                )
            return file_path

        # Try the full relative path joined to the module directory
        candidate = os.path.join(module_dir, file_path)
        if os.path.exists(candidate):
            if not allow_external:
                self._assert_within_categories_dir(candidate, module_dir)
            return candidate

        # Progressively strip leading components to find a matching suffix
        parts: Final = file_path.split("/")
        for i in range(1, len(parts)):
            suffix = os.path.join(*parts[i:])
            candidate = os.path.join(module_dir, suffix)
            if os.path.exists(candidate):
                if not allow_external:
                    self._assert_within_categories_dir(candidate, module_dir)
                return candidate

        # File not found via any resolution strategy — jail the module-relative
        # path anyway to reject traversal attempts (e.g. "../../../../etc/passwd")
        # regardless of CWD or whether the target file exists.
        if not allow_external:
            self._assert_within_categories_dir(os.path.join(module_dir, file_path), module_dir)
        return file_path

    def _load_categories(self, categories: list[ContentFilterCategoryConfig]) -> None:
        """
        Load content categories from configuration.

        Args:
            categories: List of category configurations with format:
                - category: "harmful_self_harm"
                  enabled: true
                  action: "BLOCK"
                  severity_threshold: "medium"
                  category_file: "/path/to/custom_file.yaml"  # optional override
        """
        categories_dir: Final = os.path.join(os.path.dirname(__file__), "categories")

        for cat_config in categories:
            view = self._category_config_view(cat_config)
            category_name = view["category"]
            if not category_name or not isinstance(category_name, str):
                verbose_proxy_logger.warning("Category name missing or invalid in config, skipping")
                continue

            # Prevent path traversal via category_name (e.g. "../../etc/passwd")
            if not re.match(r"^[a-zA-Z0-9_\-]+$", category_name):
                verbose_proxy_logger.warning("Category name '%s' contains invalid characters, skipping", category_name)
                continue

            enabled = view["enabled"]
            action = view["action"]
            severity_threshold = (
                cat_config.get("severity_threshold", self.severity_threshold) or self.severity_threshold
            )
            custom_file = view["category_file"]

            if not enabled:
                verbose_proxy_logger.debug("Category %s is disabled, skipping", category_name)
                continue

            # Load category file (custom or default)
            if custom_file:
                try:
                    category_file_path = self._resolve_category_file_path(custom_file)
                except ValueError as e:
                    verbose_proxy_logger.warning(
                        "Category %s: invalid category_file path, skipping. %s", category_name, e
                    )
                    continue
            else:
                # Try .yaml first, then .json (e.g. harm_toxic_abuse.json)
                yaml_path = os.path.join(categories_dir, f"{category_name}.yaml")
                json_path = os.path.join(categories_dir, f"{category_name}.json")
                if os.path.exists(yaml_path):
                    category_file_path = yaml_path
                elif os.path.exists(json_path):
                    category_file_path = json_path
                else:
                    category_file_path = yaml_path  # will trigger "not found" below

            if not os.path.exists(category_file_path):
                verbose_proxy_logger.warning("Category file not found: %s, skipping", category_file_path)
                continue

            try:
                category_config_obj = self._load_category_file(category_file_path)
                self.loaded_categories[category_name] = category_config_obj

                # Use action from config, or default from category file
                category_action = ContentFilterAction(action if action else category_config_obj.default_action)

                # Handle conditional categories (with identifier_words + block words)
                if category_config_obj.identifier_words and (
                    category_config_obj.inherit_from or category_config_obj.additional_block_words
                ):
                    self._load_conditional_category(
                        category_name,
                        category_config_obj,
                        category_action,
                        severity_threshold,
                        categories_dir,
                    )

                # Add always_block_keywords if present
                if category_config_obj.always_block_keywords:
                    for keyword_data in category_config_obj.always_block_keywords:
                        keyword = keyword_data["keyword"].lower()
                        severity = keyword_data.get("severity", "high")
                        if self._should_apply_severity(severity, severity_threshold):
                            self.always_block_category_keywords[keyword] = (
                                category_name,
                                severity,
                                category_action,
                            )

                # Add regular keywords from this category
                for keyword_data in category_config_obj.keywords:
                    keyword = keyword_data["keyword"].lower()
                    severity = keyword_data["severity"]

                    # Check if keyword meets severity threshold
                    if self._should_apply_severity(severity, severity_threshold):
                        self.category_keywords[keyword] = (
                            category_name,
                            severity,
                            category_action,
                        )

                verbose_proxy_logger.info(
                    "Loaded category %s: %s keywords, %s always-block keywords, conditional: %s",
                    category_name,
                    len(category_config_obj.keywords),
                    len(category_config_obj.always_block_keywords),
                    bool(category_config_obj.identifier_words),
                )
            except Exception as e:
                verbose_proxy_logger.error("Error loading category %s: %s", category_name, e)

    def _load_conditional_category(
        self,
        category_name: str,
        category_config_obj: CategoryConfig,
        category_action: ContentFilterAction,
        severity_threshold: str,
        categories_dir: str,
    ) -> None:
        """
        Load a conditional category that uses identifier_words + block_words.
        Block words can come from inherited category or additional_block_words.

        Args:
            category_name: Name of the category
            category_config_obj: CategoryConfig object with identifier_words
            category_action: Action to take when match is found
            severity_threshold: Minimum severity threshold
            categories_dir: Directory containing category files
        """
        try:
            block_words: Final[list[str]] = []
            inherit_from = category_config_obj.inherit_from

            # Load inherited block words if specified
            if inherit_from:
                # Remove .json or .yaml extension if included
                inherit_base: Final = inherit_from.replace(".json", "").replace(".yaml", "")

                # Find the inherited category file
                inherit_yaml_path: Final = os.path.join(categories_dir, f"{inherit_base}.yaml")
                inherit_json_path: Final = os.path.join(categories_dir, f"{inherit_base}.json")

                inherit_file_path = None
                if os.path.exists(inherit_yaml_path):
                    inherit_file_path = inherit_yaml_path
                elif os.path.exists(inherit_json_path):
                    inherit_file_path = inherit_json_path
                else:
                    verbose_proxy_logger.warning(
                        "Category %s: inherit_from '%s' file not found at %s",
                        category_name,
                        inherit_from,
                        categories_dir,
                    )
                    verbose_proxy_logger.debug("Tried paths: %s, %s", inherit_yaml_path, inherit_json_path)

                if inherit_file_path:
                    # Load the inherited category
                    inherited_category: Final = self._load_category_file(inherit_file_path)

                    # Extract block words from inherited category that meet severity threshold
                    for keyword_data in inherited_category.keywords:
                        keyword = keyword_data["keyword"].lower()
                        severity = keyword_data["severity"]
                        if self._should_apply_severity(severity, severity_threshold):
                            block_words.append(keyword)
                else:
                    # If inherit file not found, set inherit_from to None for logging
                    inherit_from = None

            # Add additional block words specific to this category
            if category_config_obj.additional_block_words:
                block_words.extend(category_config_obj.additional_block_words)

            # Store the conditional category configuration
            self.conditional_categories[category_name] = {
                "identifier_words": category_config_obj.identifier_words,
                "block_words": block_words,
                "action": category_action,
                "severity": "high",  # Combinations are always high severity
            }

            # Build log message
            log_msg = (
                f"Loaded conditional category {category_name}: "
                f"{len(category_config_obj.identifier_words)} identifiers + "
                f"{len(block_words)} block words"
            )
            if inherit_from and category_config_obj.additional_block_words:
                inherited_count: Final = len(block_words) - len(category_config_obj.additional_block_words)
                log_msg += (
                    f" ({len(category_config_obj.additional_block_words)} additional + "
                    f"{inherited_count} from {inherit_from})"
                )
            elif inherit_from:
                log_msg += f" (from {inherit_from})"
            elif category_config_obj.additional_block_words:
                log_msg += f" ({len(block_words)} from additional_block_words)"

            verbose_proxy_logger.info(log_msg)
        except Exception as e:
            verbose_proxy_logger.error("Error loading conditional category for %s: %s", category_name, e)

    def _load_category_file(self, file_path: str) -> CategoryConfig:
        """
        Load a category definition from a YAML or JSON file.

        YAML format: category_name, description, default_action, keywords (list of
        {keyword, severity}), exceptions.
        Optional: identifier_words, always_block_keywords, inherit_from.
        JSON format: list of {id, match, tags, severity}; match is pipe-separated
        phrases; severity 1-4 mapped to low/medium/high. Used for harm_toxic_abuse.

        Args:
            file_path: Path to category YAML or JSON file

        Returns:
            CategoryConfig object
        """
        if file_path.lower().endswith(".json"):
            return self._load_category_file_json(file_path)
        data: Final = self._read_category_yaml(file_path)

        return CategoryConfig(
            category_name=data.get("category_name", "unknown"),
            description=data.get("description", ""),
            default_action=ContentFilterAction(data.get("default_action", "BLOCK")),
            keywords=data.get("keywords", []),
            exceptions=data.get("exceptions", []),
            identifier_words=data.get("identifier_words"),
            always_block_keywords=data.get("always_block_keywords", []),
            inherit_from=data.get("inherit_from"),
            additional_block_words=data.get("additional_block_words"),
            phrase_patterns=data.get("phrase_patterns"),
        )

    @staticmethod
    def _read_category_yaml(file_path: str) -> CategoryFileData:
        with open(file_path, "r") as f:
            return yaml.safe_load(f)

    def _load_category_file_json(self, file_path: str) -> CategoryConfig:
        """
        Load a category from the harm_toxic_abuse-style JSON format.

        Each entry has: id, match (pipe-separated phrases), tags, severity (1-4).
        Severity mapping: 4,3 -> high; 2 -> medium; 1 -> low.
        """
        with open(file_path, "r") as f:
            entries = json.load(f)
        if not isinstance(entries, list):
            entries = [entries]
        # Derive category name from filename (e.g. harm_toxic_abuse.json -> harm_toxic_abuse)
        category_name: Final = os.path.splitext(os.path.basename(file_path))[0]
        severity_map: Final = {4: "high", 3: "high", 2: "medium", 1: "low"}
        keywords: Final[list[dict[str, str]]] = []
        seen: Final = set()
        for item in entries:
            if not isinstance(item, dict):
                continue
            match_str = item.get("match") or ""
            raw_severity = item.get("severity", 2)
            severity = severity_map.get(raw_severity if isinstance(raw_severity, int) else 2, "medium")
            for phrase in match_str.split("|"):
                phrase = phrase.strip().lower()
                if not phrase or phrase in seen:
                    continue
                seen.add(phrase)
                keywords.append({"keyword": phrase, "severity": severity})
        return CategoryConfig(
            category_name=category_name,
            description="Detects harmful, toxic, or abusive language and content",
            default_action=ContentFilterAction("BLOCK"),
            keywords=keywords,
            exceptions=[],
        )

    def _should_apply_severity(self, severity: str, threshold: str) -> bool:
        """
        Check if a given severity meets the threshold.

        Args:
            severity: The severity level of the item ("high", "medium", "low")
            threshold: The minimum severity threshold

        Returns:
            True if severity meets or exceeds threshold
        """
        severity_order: Final = {"low": 0, "medium": 1, "high": 2}
        return severity_order.get(severity, 0) >= severity_order.get(threshold, 1)

    def _add_pattern(self, pattern_config: ContentFilterPattern) -> None:
        """
        Add a pattern to the compiled patterns list.

        Args:
            pattern_config: ContentFilterPattern configuration
        """
        try:
            extra_config: _PatternExtraLookup = {"keyword_pattern": None, "allow_word_numbers": False}
            if pattern_config.pattern_type == "prebuilt":
                if not pattern_config.pattern_name:
                    raise ValueError("pattern_name is required for prebuilt patterns")
                compiled = get_compiled_pattern(pattern_config.pattern_name)
                pattern_name = pattern_config.pattern_name
                extra_config = self._lookup_pattern_extra(pattern_name)
            elif pattern_config.pattern_type == "regex":
                if not pattern_config.pattern:
                    raise ValueError("pattern is required for regex patterns")
                compiled = re.compile(pattern_config.pattern, re.IGNORECASE)
                pattern_name = pattern_config.name or "custom_regex"
            else:
                raise ValueError(f"Unknown pattern_type: {pattern_config.pattern_type}")

            keyword_pattern: Final = extra_config["keyword_pattern"]
            keyword_regex: Final = re.compile(keyword_pattern, re.IGNORECASE) if keyword_pattern else None

            self.compiled_patterns.append(
                {
                    "regex": compiled,
                    "pattern_name": pattern_name,
                    "action": pattern_config.action,
                    "keyword_regex": keyword_regex,
                    "allow_word_numbers": extra_config["allow_word_numbers"],
                }
            )
            verbose_proxy_logger.debug("Added pattern: %s with action %s", pattern_name, pattern_config.action)
        except Exception as e:
            verbose_proxy_logger.error("Error adding pattern %s: %s", pattern_config, e)
            raise

    @staticmethod
    def _lookup_pattern_extra(pattern_name: str) -> _PatternExtraLookup:
        extra: Final = PATTERN_EXTRA_CONFIG.get(pattern_name)
        return {
            "keyword_pattern": extra.get("keyword_pattern") if extra is not None else None,
            "allow_word_numbers": bool(extra.get("allow_word_numbers")) if extra is not None else False,
        }

    def _load_blocked_words_file(self, file_path: str) -> None:
        """
        Load blocked words from a YAML file.

        Args:
            file_path: Path to YAML file containing blocked_words list

        Expected format:
        ```yaml
        blocked_words:
          - keyword: "sensitive_term"
            action: "BLOCK"
            description: "Optional description"
        ```
        """
        try:
            with open(file_path, "r") as f:
                data: Final = yaml.safe_load(f)

            if not isinstance(data, dict) or "blocked_words" not in data:
                raise ValueError("Invalid format: file must contain 'blocked_words' key with list of words")

            for word_data in data["blocked_words"]:
                if not isinstance(word_data, dict) or "keyword" not in word_data or "action" not in word_data:
                    verbose_proxy_logger.warning("Skipping invalid word entry: %s", word_data)
                    continue

                keyword = word_data["keyword"].lower()
                action = ContentFilterAction(word_data["action"])
                description = word_data.get("description")

                self.blocked_words[keyword] = (action, description)

            verbose_proxy_logger.info("Loaded %s blocked words from %s", len(data["blocked_words"]), file_path)
        except FileNotFoundError:
            raise FileNotFoundError(f"Blocked words file not found: {file_path}")
        except Exception as e:
            raise Exception(f"Error loading blocked words file {file_path}: {e}")

    def _find_pattern_spans(self, text: str, pattern_entry: CompiledPatternEntry) -> list[tuple[int, int]]:
        """Return all match spans for a pattern, applying contextual rules if required."""

        regex: Final[Pattern[str]] = pattern_entry["regex"]
        keyword_regex: Final[Pattern[str] | None] = pattern_entry.get("keyword_regex")
        allow_word_numbers: Final[bool] = pattern_entry.get("allow_word_numbers", False)

        keyword_matches: Final = list(keyword_regex.finditer(text)) if keyword_regex is not None else None
        if keyword_matches is not None and not keyword_matches:
            return []

        match_spans: Final[list[tuple[int, int]]] = []

        for match in regex.finditer(text):
            if keyword_matches is not None and not self._match_near_keyword(
                match.start(), match.end(), keyword_matches, text
            ):
                continue
            match_spans.append((match.start(), match.end()))

        if allow_word_numbers:
            for word_match in WORD_NUMBER_SEQUENCE_PATTERN.finditer(text):
                digits = self._convert_word_number_sequence(word_match.group())
                if not digits:
                    continue
                if not regex.fullmatch(digits):
                    continue
                if keyword_matches is not None and not self._match_near_keyword(
                    word_match.start(), word_match.end(), keyword_matches, text
                ):
                    continue
                match_spans.append((word_match.start(), word_match.end()))

        return self._merge_spans(match_spans)

    def _match_near_keyword(
        self,
        value_start: int,
        value_end: int,
        keyword_matches: Sequence[re.Match[str]],
        text: str,
    ) -> bool:
        """Check if a value is separated from a keyword by an allowed gap."""

        for keyword_match in keyword_matches:
            keyword_start = keyword_match.start()
            keyword_end = keyword_match.end()

            if value_start >= keyword_end:
                gap_text = text[keyword_end:value_start]
            elif keyword_start >= value_end:
                gap_text = text[value_end:keyword_start]
            else:
                return True  # overlapping

            if self._gap_text_allowed(gap_text):
                return True
        return False

    def _gap_text_allowed(self, gap_text: str) -> bool:
        """Return True if the gap between keyword and value meets word-count rules."""

        if not gap_text.strip():
            return True
        if any(char.isdigit() for char in gap_text):
            return False

        words: Final = GAP_WORD_TOKENIZER.findall(gap_text)
        return len(words) <= MAX_KEYWORD_VALUE_GAP_WORDS

    def _merge_spans(self, spans: list[tuple[int, int]]) -> list[tuple[int, int]]:
        """Merge overlapping spans to avoid double-masking."""

        if not spans:
            return []

        spans.sort(key=lambda item: item[0])
        merged: Final[list[tuple[int, int]]] = [spans[0]]

        for start, end in spans[1:]:
            last_start, last_end = merged[-1]
            if start <= last_end:
                merged[-1] = (last_start, max(last_end, end))
            else:
                merged.append((start, end))
        return merged

    def _mask_spans(self, text: str, spans: list[tuple[int, int]], redaction: str) -> str:
        """Apply masking for the provided spans using the given redaction tag."""

        if not spans:
            return text

        result_parts: Final[list[str]] = []
        previous_end = 0
        for start, end in spans:
            result_parts.append(text[previous_end:start])
            result_parts.append(redaction)
            previous_end = end
        result_parts.append(text[previous_end:])
        return "".join(result_parts)

    def _convert_word_number_sequence(self, sequence: str) -> str | None:
        """Convert a spelled-out digit sequence (e.g., 'One-Two') into digits."""

        tokens: Final[list[str]] = WORD_NUMBER_TOKEN_FINDER.findall(sequence)
        if not tokens:
            return None

        digits: Final[list[str]] = []
        for token in tokens:
            digit = WORD_NUMBER_MAP.get(token.lower())
            if digit is None:
                return None
            digits.append(digit)

        return "".join(digits) if digits else None

    def _check_patterns(self, text: str) -> tuple[str, str, ContentFilterAction] | None:
        """
        Check text against all compiled regex patterns.

        Args:
            text: Text to check

        Returns:
            Tuple of (matched_text, pattern_name, action) if match found, None otherwise
        """
        for pattern_entry in self.compiled_patterns:
            spans = self._find_pattern_spans(text, pattern_entry)
            if spans:
                start, end = spans[0]
                matched_text = text[start:end]
                pattern_name = pattern_entry["pattern_name"]
                action = pattern_entry["action"]
                verbose_proxy_logger.debug("Pattern '%s' matched: %s...", pattern_name, matched_text[:20])
                return (matched_text, pattern_name, action)
        return None

    def _check_conditional_categories(
        self, text: str, exceptions: list[str]
    ) -> tuple[str, str, str, ContentFilterAction] | None:
        """
        Check text for conditional category matches (identifier + block word in same sentence).

        This implements logic like: if text contains both an identifier word (e.g., "minor")
        AND a block word (e.g., "romantic"), then block it.

        Args:
            text: Text to check
            exceptions: List of exception phrases to ignore

        Returns:
            Tuple of (matched_phrase, category, severity, action) if match found, None otherwise
        """
        text_lower: Final = text.lower()

        # First check if any exception applies
        for exception in exceptions:
            if exception in text_lower:
                return None

        # Split text into sentences for more precise matching
        # Simple sentence splitting on common terminators
        sentences: Final = re.split(r"[.!?]+", text)

        for category_name, config in self.conditional_categories.items():
            identifier_words = config["identifier_words"]
            block_words = config["block_words"]
            action = config["action"]
            severity = config["severity"]

            # Check category-specific exceptions
            category_obj = self.loaded_categories.get(category_name)
            if category_obj:
                exception_found = False
                for exception in category_obj.exceptions:
                    if exception in text_lower:
                        verbose_proxy_logger.debug(
                            "Category exception '%s' found for %s, skipping", exception, category_name
                        )
                        exception_found = True
                        break
                if exception_found:
                    continue

            # Check each sentence for identifier + block word combination
            for sentence in sentences:
                sentence_lower = sentence.lower().strip()
                if not sentence_lower:
                    continue

                # Check if sentence contains ANY identifier word
                identifier_found = None
                for identifier in identifier_words:
                    if identifier in sentence_lower:
                        identifier_found = identifier
                        break

                if not identifier_found:
                    continue

                # Check if sentence also contains ANY block word
                block_word_found = None
                for block_word in block_words:
                    # Use word boundary for single words to avoid false positives
                    if " " in block_word:
                        # Multi-word phrase
                        if block_word in sentence_lower:
                            block_word_found = block_word
                            break
                    else:
                        # Single word - use word boundary
                        pattern = r"\b" + re.escape(block_word) + r"\b"
                        if re.search(pattern, sentence_lower):
                            block_word_found = block_word
                            break

                if block_word_found:
                    matched_phrase = f"{identifier_found} + {block_word_found}"
                    verbose_proxy_logger.warning(
                        "Conditional match in %s: '%s' in sentence", category_name, matched_phrase
                    )
                    return (matched_phrase, category_name, severity, action)

        return None

    def _check_phrase_patterns(
        self, text: str, exceptions: list[str]
    ) -> tuple[str, str, str, ContentFilterAction] | None:
        """
        Check text against phrase patterns from loaded categories.

        Phrase patterns are regex patterns that catch paraphrased requests
        (e.g., "put my money to make it grow" for financial advice).

        Args:
            text: Text to check
            exceptions: List of exception phrases to ignore

        Returns:
            Tuple of (matched_pattern, category, severity, action) if match found, None otherwise
        """
        text_lower: Final = text.lower()

        for exception in exceptions:
            if exception in text_lower:
                return None

        for category_name, config in self.loaded_categories.items():
            if not config.phrase_patterns:
                continue

            # Check category-specific exceptions
            for exception in config.exceptions:
                if exception in text_lower:
                    break
            else:
                # Determine action for this category
                action = ContentFilterAction(config.default_action)
                # Check if we have a configured action in conditional_categories
                if category_name in self.conditional_categories:
                    action = self.conditional_categories[category_name]["action"]

                for pattern_str, pattern in config.phrase_patterns:
                    if pattern.search(text):
                        verbose_proxy_logger.warning("Phrase pattern match in %s: '%s'", category_name, pattern_str)
                        return (
                            f"phrase: {pattern_str}",
                            category_name,
                            "high",
                            action,
                        )

        return None

    def _check_category_keywords(
        self, text: str, exceptions: list[str]
    ) -> tuple[str, str, str, ContentFilterAction] | None:
        """
        Check text for category keywords.

        Args:
            text: Text to check
            exceptions: List of exception phrases to ignore

        Returns:
            Tuple of (keyword, category, severity, action) if match found, None otherwise
        """
        text_lower: Final = text.lower()

        # Check exceptions first — they take precedence over always-block keywords too.
        for exception in exceptions:
            if exception in text_lower:
                verbose_proxy_logger.debug("Exception phrase '%s' found, skipping category keyword check", exception)
                return None

        # Always-block keywords are checked after exceptions.
        for keyword, (
            category,
            severity,
            action,
        ) in self.always_block_category_keywords.items():
            keyword_pattern_str = self._keyword_to_regex_pattern(keyword)
            if " " in keyword:
                keyword_found = bool(re.search(keyword_pattern_str, text_lower))
            else:
                keyword_pattern = r"\b" + keyword_pattern_str + r"\b"
                keyword_found = bool(re.search(keyword_pattern, text_lower))
            if keyword_found:
                verbose_proxy_logger.debug("Always-block keyword '%s' found in category '%s'", keyword, category)
                return (keyword, category, severity, action)

        # Check category keywords
        for keyword, (category, severity, action) in self.category_keywords.items():
            # Convert asterisks (*) in keywords to regex wildcards
            # Asterisks are used in the source data to obfuscate profanity (e.g., "fu*c*k" -> "fuck")
            # We treat * as a wildcard matching zero or one character
            keyword_pattern_str = self._keyword_to_regex_pattern(keyword)

            # Use word boundary matching for single words to avoid false positives
            # (e.g., "men" should not match "recommend")
            # For multi-word phrases, use substring matching
            if " " in keyword:
                # Multi-word phrase - use substring matching with wildcards
                keyword_pattern = keyword_pattern_str
                keyword_found = bool(re.search(keyword_pattern, text_lower))
            else:
                # Single word - use word boundary matching to match whole words only
                keyword_pattern = r"\b" + keyword_pattern_str + r"\b"
                keyword_found = bool(re.search(keyword_pattern, text_lower))

            if keyword_found:
                # Check if this keyword has exceptions
                category_obj = self.loaded_categories.get(category)
                if category_obj:
                    # Check category-specific exceptions
                    exception_found = False
                    for exception in category_obj.exceptions:
                        if exception in text_lower:
                            verbose_proxy_logger.debug(
                                "Category exception '%s' found for keyword '%s', skipping", exception, keyword
                            )
                            exception_found = True
                            break
                    if exception_found:
                        continue

                verbose_proxy_logger.debug(
                    "Category keyword '%s' found in category '%s' with severity %s", keyword, category, severity
                )
                return (keyword, category, severity, action)
        return None

    def _check_blocked_words(self, text: str) -> tuple[str, ContentFilterAction, str | None] | None:
        """
        Check text for blocked keywords.

        Args:
            text: Text to check

        Returns:
            Tuple of (keyword, action, description) if match found, None otherwise
        """
        # Handle case where blocked_words might still be a list (old instances)
        if isinstance(self.blocked_words, list):
            verbose_proxy_logger.warning(
                "blocked_words is a list instead of dict. Re-initializing. "
                "This suggests an old guardrail instance is still in use. Please restart the server."
            )
            # Convert list to dict on-the-fly
            temp_dict: Final[dict[str, tuple[ContentFilterAction, str | None]]] = {}
            for word in self.blocked_words:
                if isinstance(word, dict):
                    temp_dict[word.get("keyword", "").lower()] = (
                        word.get("action", ContentFilterAction.BLOCK),
                        word.get("description"),
                    )
            self.blocked_words = temp_dict

        if not self.blocked_words:
            return None

        text_lower: Final = text.lower()
        for keyword, (action, description) in self.blocked_words.items():
            if keyword in text_lower:
                verbose_proxy_logger.debug("Blocked word '%s' found with action %s", keyword, action)
                return (keyword, action, description)
        return None

    def _handle_conditional_match(
        self,
        matched_phrase: str,
        category_name: str,
        severity: str,
        action: ContentFilterAction,
        detections: list[ContentFilterDetection] | None,
    ) -> None:
        """Handle conditional category match detection and action."""
        if detections is not None:
            category_detection: Final[CategoryKeywordDetection] = {
                "type": "category_keyword",
                "category": category_name,
                "keyword": matched_phrase,
                "severity": severity,
                "action": action.value,
            }
            detections.append(category_detection)

        if action == ContentFilterAction.BLOCK:
            error_msg: Final = (
                f"Content blocked: {category_name} conditional match '{matched_phrase}' detected (severity: {severity})"
            )
            verbose_proxy_logger.warning(error_msg)
            raise HTTPException(
                status_code=400,
                detail={
                    "error": error_msg,
                    "category": category_name,
                    "matched_phrase": matched_phrase,
                    "severity": severity,
                },
            )
        elif action == ContentFilterAction.MASK:
            verbose_proxy_logger.warning(
                "Conditional match '%s' from %s detected but MASK action not supported for conditional categories",
                matched_phrase,
                category_name,
            )

    def _handle_category_keyword_match(
        self,
        keyword: str,
        category_name: str,
        severity: str,
        action: ContentFilterAction,
        text: str,
        detections: list[ContentFilterDetection] | None,
    ) -> str:
        """Handle category keyword match detection and action."""
        if detections is not None:
            category_detection: Final[CategoryKeywordDetection] = {
                "type": "category_keyword",
                "category": category_name,
                "keyword": keyword,
                "severity": severity,
                "action": action.value,
            }
            detections.append(category_detection)

        if action == ContentFilterAction.BLOCK:
            error_msg = f"Content blocked: {category_name} category keyword '{keyword}' detected (severity: {severity})"
            verbose_proxy_logger.warning(error_msg)
            raise HTTPException(
                status_code=400,
                detail={
                    "error": error_msg,
                    "category": category_name,
                    "keyword": keyword,
                    "severity": severity,
                },
            )
        elif action == ContentFilterAction.MASK:
            keyword_pattern_for_masking: Final = self._keyword_to_regex_pattern(keyword)
            text = re.sub(
                keyword_pattern_for_masking,
                self.keyword_redaction_tag,
                text,
                flags=re.IGNORECASE,
            )
            verbose_proxy_logger.info(
                "Masked category keyword '%s' from %s (severity: %s)", keyword, category_name, severity
            )

        return text

    def _handle_pattern_match(
        self,
        pattern_name: str,
        action: ContentFilterAction,
        text: str,
        spans: list[tuple[int, int]],
        detections: list[ContentFilterDetection] | None,
    ) -> str:
        """Handle regex pattern match detection and action."""
        if detections is not None:
            pattern_detection: Final[PatternDetection] = {
                "type": "pattern",
                "pattern_name": pattern_name,
                "action": action.value,
            }
            detections.append(pattern_detection)

        if action == ContentFilterAction.BLOCK:
            error_msg: Final = f"Content blocked: {pattern_name} pattern detected"
            verbose_proxy_logger.warning(error_msg)
            raise HTTPException(
                status_code=400,
                detail={"error": error_msg, "pattern": pattern_name},
            )
        elif action == ContentFilterAction.MASK:
            redaction_tag: Final = self.pattern_redaction_format.format(pattern_name=pattern_name.upper())
            text = self._mask_spans(text, spans, redaction_tag)
            verbose_proxy_logger.info("Masked all %s matches in content", pattern_name)

        return text

    def _handle_blocked_word_match(
        self,
        keyword: str,
        action: ContentFilterAction,
        description: str | None,
        text: str,
        detections: list[ContentFilterDetection] | None,
    ) -> str:
        """Handle blocked word match detection and action."""
        verbose_proxy_logger.debug("Blocked word '%s' found with action %s", keyword, action)

        if detections is not None:
            blocked_word_detection: Final[BlockedWordDetection] = {
                "type": "blocked_word",
                "keyword": keyword,
                "action": action.value,
                "description": description,
            }
            detections.append(blocked_word_detection)

        if action == ContentFilterAction.BLOCK:
            error_msg = f"Content blocked: keyword '{keyword}' detected"
            if description:
                error_msg += f" ({description})"
            verbose_proxy_logger.warning(error_msg)
            raise HTTPException(
                status_code=400,
                detail={
                    "error": error_msg,
                    "keyword": keyword,
                    "description": description,
                },
            )
        elif action == ContentFilterAction.MASK:
            keyword_pattern_for_masking: Final = self._keyword_to_regex_pattern(keyword)
            text = re.sub(
                keyword_pattern_for_masking,
                self.keyword_redaction_tag,
                text,
                flags=re.IGNORECASE,
            )
            verbose_proxy_logger.info("Masked keyword '%s' in content", keyword)

        return text

    def _filter_single_text(self, text: str, detections: list[ContentFilterDetection] | None = None) -> str:
        """
        Apply all content filtering checks to a single text.

        This method performs:
        1. Category keyword checks
        2. Regex pattern checks
        3. Blocked word checks

        Args:
            text: Text to filter
            detections: Optional list to append detection information

        Returns:
            Filtered text (with masking applied if action is MASK)

        Raises:
            HTTPException: If sensitive content is detected and action is BLOCK
        """
        # Collect all exceptions from loaded categories
        all_exceptions: Final[list[str]] = []
        for category in self.loaded_categories.values():
            all_exceptions.extend(category.exceptions)

        # Check conditional categories first
        conditional_match: Final = self._check_conditional_categories(text, all_exceptions)
        if conditional_match:
            matched_phrase, category_name, severity, action = conditional_match
            self._handle_conditional_match(matched_phrase, category_name, severity, action, detections)

        # Check phrase patterns (regex-based paraphrase detection)
        phrase_match: Final = self._check_phrase_patterns(text, all_exceptions)
        if phrase_match:
            matched_phrase, category_name, severity, action = phrase_match
            self._handle_conditional_match(matched_phrase, category_name, severity, action, detections)

        # Check category keywords
        category_keyword_match: Final = self._check_category_keywords(text, all_exceptions)
        if category_keyword_match:
            keyword, category_name, severity, action = category_keyword_match
            text = self._handle_category_keyword_match(keyword, category_name, severity, action, text, detections)

        # Check regex patterns - process ALL patterns, not just first match
        for pattern_entry in self.compiled_patterns:
            spans = self._find_pattern_spans(text, pattern_entry)
            if spans:
                pattern_name = pattern_entry["pattern_name"]
                action = pattern_entry["action"]
                text = self._handle_pattern_match(pattern_name, action, text, spans, detections)

        # Check blocked words - iterate through ALL blocked words
        text_lower = text.lower()
        for keyword, (action, description) in self.blocked_words.items():
            keyword_pattern_str = self._keyword_to_regex_pattern(keyword)
            if re.search(keyword_pattern_str, text_lower):
                text = self._handle_blocked_word_match(keyword, action, description, text, detections)
                text_lower = text.lower()  # Update after masking

        return text

    @staticmethod
    def _keyword_to_regex_pattern(keyword: str) -> str:
        """
        Convert a keyword into a safe regex pattern.

        Escape all regex metacharacters to prevent malformed patterns from
        user/template keywords. Preserve existing '*' wildcard semantics by
        translating escaped '*' into '.?'.
        """
        return re.escape(keyword).replace(r"\*", ".?")

    def _mask_content(self, text: str, pattern_name: str) -> str:
        """
        Mask sensitive content in text.

        Args:
            text: Text containing sensitive content
            pattern_name: Name of the pattern that matched

        Returns:
            Text with sensitive content masked
        """
        redaction_tag: Final = self.pattern_redaction_format.format(pattern_name=pattern_name.upper())
        return redaction_tag

    async def _process_images(self, images: list[str], detections: list[ContentFilterDetection]) -> None:
        """
        Process images by describing them and applying content filtering.

        Args:
            images: List of image URLs
            detections: List to append detection information
        """
        if not (images and self.image_model and self.llm_router):
            return

        tasks: Final[list[Coroutine[object, object, ModelResponse]]] = []
        for image in images:
            task = self.llm_router.acompletion(
                model=self.image_model,
                messages=[
                    {
                        "role": "system",
                        "content": "Describe the image in detail.",
                    },
                    {
                        "role": "user",
                        "content": [
                            {"type": "image_url", "image_url": {"url": image}},
                        ],
                    },
                ],
                stream=False,
            )
            tasks.append(task)

        responses: Final = await asyncio.gather(*tasks)
        descriptions: Final[list[str]] = []
        for response in responses:
            image_description = self._describe_image_response_content(response)
            if image_description:
                verbose_proxy_logger.debug("Image description: %s", image_description)
                descriptions.append(image_description)
            else:
                verbose_proxy_logger.warning("No image description found")

        # Apply content filtering to image descriptions
        verbose_proxy_logger.debug(
            "ContentFilterGuardrail: Applying guardrail to %s image description(s)", len(descriptions)
        )
        for description in descriptions:
            # This will raise HTTPException if BLOCK action is triggered
            try:
                self._filter_single_text(description, detections=detections)
            except HTTPException as e:
                # e.detail can be a string or dict
                if isinstance(e.detail, dict) and "error" in e.detail:
                    detail_dict = cast(dict[str, str], e.detail)
                    detail_dict["error"] = detail_dict["error"] + " (Image description): " + description
                elif isinstance(e.detail, str):
                    e.detail = e.detail + " (Image description): " + description
                else:
                    e.detail = "Content blocked: Image description detected" + description
                raise e

    @staticmethod
    def _describe_image_response_content(response: ModelResponse) -> str | None:
        choice = response.choices[0]
        message = getattr(choice, "message", None)
        if message and getattr(message, "content", None):
            return message.content
        return None

    def _count_masked_entities(
        self,
        detections: list[ContentFilterDetection],
        masked_entity_count: dict[str, int],
    ) -> None:
        """
        Count masked entities by type from detections.

        Args:
            detections: List of detection dictionaries
            masked_entity_count: Dictionary to update with counts
        """
        for detection in detections:
            if detection.get("type") == "competitor_intent":
                continue
            if detection.get("action") == ContentFilterAction.MASK.value:
                detection_type = detection["type"]
                if detection_type == "pattern":
                    pattern_detection = cast(PatternDetection, detection)
                    pattern_name = pattern_detection["pattern_name"]
                    masked_entity_count[pattern_name] = masked_entity_count.get(pattern_name, 0) + 1
                elif detection_type == "blocked_word":
                    entity_type = "blocked_word"
                    masked_entity_count[entity_type] = masked_entity_count.get(entity_type, 0) + 1
                elif detection_type == "category_keyword":
                    category_detection = cast(CategoryKeywordDetection, detection)
                    category = category_detection["category"]
                    masked_entity_count[category] = masked_entity_count.get(category, 0) + 1

    def _build_match_details(self, detections: list[ContentFilterDetection]) -> list[dict[str, object]]:
        """Build match_details list from content filter detections."""
        match_details: Final[list[dict[str, object]]] = []
        for detection in detections:
            action_taken = detection.get("action", detection.get("action_hint", ""))
            detail: dict[str, object] = {"type": detection["type"], "action_taken": action_taken}
            if detection["type"] == "pattern":
                detail["detection_method"] = "regex"
                detail["snippet"] = cast(PatternDetection, detection).get("pattern_name", "")
            elif detection["type"] == "blocked_word":
                detail["detection_method"] = "keyword"
                detail["snippet"] = cast(BlockedWordDetection, detection).get("keyword", "")
            elif detection["type"] == "category_keyword":
                detail["detection_method"] = "keyword"
                cat_det = cast(CategoryKeywordDetection, detection)
                detail["snippet"] = cat_det.get("keyword", "")
                detail["category"] = cat_det.get("category", "")
            elif detection["type"] == "competitor_intent":
                detail["detection_method"] = "intent"
                detail["snippet"] = detection.get("intent", "")
                detail["confidence"] = detection.get("confidence")
            match_details.append(detail)
        return match_details

    def _get_detection_methods(self, detections: list[ContentFilterDetection]) -> str:
        """Get comma-separated detection methods used."""
        methods: Final[set[str]] = set()
        for detection in detections:
            if detection["type"] == "pattern":
                methods.add("regex")
            elif detection["type"] == "competitor_intent":
                methods.add("intent")
            else:
                methods.add("keyword")
        return ",".join(sorted(methods)) if methods else ""

    def _get_patterns_checked_count(self) -> int:
        """Get total number of patterns and keywords that were evaluated."""
        return (
            len(self.compiled_patterns)
            + len(self.blocked_words)
            + len(self.category_keywords)
            + len(self.always_block_category_keywords)
        )

    def _get_policy_templates(self) -> str | None:
        """Get comma-separated policy template names from loaded categories."""
        if not self.loaded_categories:
            return None
        names: Final = [cat.description or cat.category_name for cat in self.loaded_categories.values()]
        return ", ".join(names) if names else None

    def _compute_risk_score(
        self,
        detections: list[ContentFilterDetection],
        masked_entity_count: dict[str, int],
        status: "GuardrailStatus",
    ) -> float:
        """
        Compute a risk score from 0-10 for this guardrail evaluation.

        Factors:
        - Match ratio: how many patterns matched vs total checked
        - Number of entities masked
        - Whether the guardrail blocked the request (max risk)
        """
        if status == "guardrail_intervened":
            return 10.0

        total_masked: Final = sum(masked_entity_count.values()) if masked_entity_count else 0
        patterns_checked: Final = self._get_patterns_checked_count()

        # Match ratio contribution (0-7 points)
        match_ratio: Final = total_masked / patterns_checked if patterns_checked > 0 else 0.0
        ratio_score: Final = match_ratio * 7.0

        # Detection count contribution (0-3 points, capped)
        detection_score: Final = min(len(detections), 5) * 0.6

        score = ratio_score + detection_score

        # Floor: if anything matched, minimum risk is 2
        if total_masked > 0 and score < 2.0:
            score = 2.0

        return round(min(10.0, score), 1)

    def _apply_competitor_intent_policy(
        self,
        intent_result: CompetitorIntentResult,
        request_data: dict,
        detections: list[ContentFilterDetection],
    ) -> None:
        """
        Apply policy for competitor intent result: refuse (raise), reframe (passthrough), or log_only/allow (return).
        Appends competitor_intent detection to detections. Never returns for refuse/reframe.
        """
        intent_val: Final = intent_result.get("intent", "other")
        confidence_val: Final = intent_result.get("confidence", 0.0)
        action_hint_val: Final = intent_result.get("action_hint", "allow")
        evidence_list: Final = intent_result.get("evidence", [])
        detection: Final[CompetitorIntentDetection] = {
            "type": "competitor_intent",
            "intent": intent_val,
            "confidence": confidence_val,
            "action_hint": action_hint_val,
            "entities": intent_result.get("entities", {}),
            "signals": intent_result.get("signals", []),
            "evidence": [dict(e) for e in evidence_list],
        }
        detections.append(detection)

        if action_hint_val == "refuse":
            msg = "Content blocked: competitor comparison or ranking intent detected."
            if self._competitor_intent_checker and getattr(
                self._competitor_intent_checker, "refuse_message_template", None
            ):
                msg = self._competitor_intent_checker.refuse_message_template or msg
            verbose_proxy_logger.warning("ContentFilterGuardrail: competitor intent refuse - %s", intent_val)
            raise HTTPException(
                status_code=400,
                detail={
                    "error": msg,
                    "intent": intent_val,
                    "confidence": confidence_val,
                },
            )
        if action_hint_val == "reframe":
            msg = (
                "I can help with questions about our products and services. "
                "Would you like to compare specific features or get more information?"
            )
            if self._competitor_intent_checker and getattr(
                self._competitor_intent_checker, "reframe_message_template", None
            ):
                msg = self._competitor_intent_checker.reframe_message_template or msg
            verbose_proxy_logger.info("ContentFilterGuardrail: competitor intent reframe - %s", intent_val)
            self.raise_passthrough_exception(
                violation_message=msg,
                request_data=request_data,
                detection_info=dict(intent_result),
            )
        # log_only or allow: just log (detection already appended)
        verbose_proxy_logger.debug(
            "ContentFilterGuardrail: competitor intent %s (action_hint=%s)",
            intent_val,
            action_hint_val,
        )

    def _log_guardrail_information(
        self,
        request_data: dict,
        detections: list[ContentFilterDetection],
        status: "GuardrailStatus",
        start_time: datetime,
        masked_entity_count: dict[str, int],
        exception_str: str,
    ) -> None:
        """
        Log guardrail information to request_data metadata.

        Args:
            request_data: Request data dictionary
            detections: List of detection dictionaries
            status: Guardrail status
            start_time: Start time of guardrail execution
            masked_entity_count: Count of masked entities by type
            exception_str: Exception string if guardrail failed
        """
        # Convert TypedDict detections to regular dicts for JSON serialization
        guardrail_json_response: Exception | str | dict | list[dict] = [dict(detection) for detection in detections]
        if status != "success":
            guardrail_json_response = exception_str if exception_str else [dict(detection) for detection in detections]

        # Competitor intent: add confidence and classification to tracing if present
        tracing_kw: Final[GuardrailTracingDetail] = {
            "guardrail_id": self.config_guardrail_id or self.guardrail_name,
            "policy_template": self.config_policy_template or self._get_policy_templates(),
            "detection_method": (self._get_detection_methods(detections) if detections else None),
            "match_details": (self._build_match_details(detections) if detections else None),
            "patterns_checked": self._get_patterns_checked_count(),
            "risk_score": self._compute_risk_score(detections, masked_entity_count, status),
        }
        for d in detections:
            if isinstance(d, dict) and d.get("type") == "competitor_intent":
                tracing_kw["confidence_score"] = d.get("confidence")
                tracing_kw["classification"] = dict(d)
                break

        self.add_standard_logging_guardrail_information_to_request_data(
            guardrail_provider=self.guardrail_provider,
            guardrail_json_response=guardrail_json_response,
            request_data=request_data,
            guardrail_status=status,
            start_time=start_time.timestamp(),
            end_time=datetime.now().timestamp(),
            duration=(datetime.now() - start_time).total_seconds(),
            masked_entity_count=masked_entity_count,
            tracing_detail=GuardrailTracingDetail(**tracing_kw),
        )

    @staticmethod
    def _get_mcp_tool_name(request_data: dict) -> str | None:
        raw_name: Final[object] = request_data.get("mcp_tool_name")
        if isinstance(raw_name, str) and raw_name:
            return raw_name
        return None

    def _assert_argument_label_clean(
        self, text: str, detections: list[ContentFilterDetection], context_label: str
    ) -> None:
        if self._filter_single_text(text, detections=detections) != text:
            raise HTTPException(
                status_code=400,
                detail={
                    "error": (
                        f"Content blocked: {context_label} argument matched a masking rule on a non-rewritable field"
                    )
                },
            )

    def _filter_argument_value(
        self,
        value: object,
        detections: list[ContentFilterDetection],
        context_label: str,
        depth: int = 0,
    ) -> object:
        if depth > DEFAULT_MAX_RECURSE_DEPTH:
            raise HTTPException(
                status_code=400,
                detail={"error": f"Content blocked: {context_label} arguments exceed the maximum nesting depth"},
            )
        if isinstance(value, str):
            return self._filter_single_text(value, detections=detections)
        if isinstance(value, (int, float)) and not isinstance(value, bool):
            self._assert_argument_label_clean(str(value), detections, context_label)
            return value
        if isinstance(value, dict):
            for key in value:
                if isinstance(key, str):
                    self._assert_argument_label_clean(key, detections, context_label)
            return {
                key: self._filter_argument_value(item, detections, context_label, depth + 1)
                for key, item in value.items()
            }
        if isinstance(value, list):
            return [self._filter_argument_value(item, detections, context_label, depth + 1) for item in value]
        return value

    def _scan_mcp_tool_call_arguments(
        self,
        request_data: dict,
        detections: list[ContentFilterDetection],
        logging_obj: Optional["LiteLLMLoggingObj"] = None,
    ) -> None:
        if not self._event_hook_is_event_type(GuardrailEventHooks.pre_mcp_call):
            return
        call_type: Final[object] = getattr(logging_obj, "call_type", None)
        if logging_obj is not None and call_type != CallTypes.call_mcp_tool.value:
            return
        if self._get_mcp_tool_name(request_data) is None:
            return
        raw_arguments: Final[object] = request_data.get("mcp_arguments")
        if not isinstance(raw_arguments, dict) or not raw_arguments:
            return
        filtered_arguments: Final = self._filter_argument_value(raw_arguments, detections, "MCP tool call")
        if filtered_arguments == raw_arguments:
            return
        request_data["mcp_arguments"] = filtered_arguments
        request_data["modified_arguments"] = filtered_arguments

    @staticmethod
    def _get_tool_call_arguments(tool_call: object) -> str | None:
        function: Final[object] = (
            tool_call.get("function") if isinstance(tool_call, dict) else getattr(tool_call, "function", None)
        )
        arguments: Final[object] = (
            function.get("arguments") if isinstance(function, dict) else getattr(function, "arguments", None)
        )
        return arguments if isinstance(arguments, str) and arguments.strip() else None

    @staticmethod
    def _set_tool_call_arguments(tool_call: object, arguments: str) -> None:
        function: Final[object] = (
            tool_call.get("function") if isinstance(tool_call, dict) else getattr(tool_call, "function", None)
        )
        if isinstance(function, dict):
            function["arguments"] = arguments
        elif isinstance(function, Function):
            function.arguments = arguments

    def _filter_tool_call_arguments(
        self,
        arguments: str,
        detections: list[ContentFilterDetection],  # mutable-ok: _filter_single_text appends into a caller-owned list
    ) -> str:
        try:
            parsed: Final[object] = json.loads(arguments)
        except (json.JSONDecodeError, TypeError, ValueError):
            return self._filter_single_text(arguments, detections=detections)
        if not isinstance(parsed, (dict, list)):
            return self._filter_single_text(arguments, detections=detections)
        filtered: Final = self._filter_argument_value(parsed, detections, "tool call")
        return arguments if filtered == parsed else json.dumps(filtered)

    def _scan_tool_call_arguments(
        self,
        inputs: "GenericGuardrailAPIInputs",
        detections: list[ContentFilterDetection],  # mutable-ok: _filter_single_text appends into a caller-owned list
    ) -> None:
        for tool_call in inputs.get("tool_calls") or ():
            arguments = self._get_tool_call_arguments(tool_call)
            if arguments is None:
                continue
            filtered_arguments = self._filter_tool_call_arguments(arguments, detections)
            if filtered_arguments != arguments:
                self._set_tool_call_arguments(tool_call, filtered_arguments)

    async def apply_guardrail(
        self,
        inputs: "GenericGuardrailAPIInputs",
        request_data: dict,
        input_type: Literal["request", "response"],
        logging_obj: Optional["LiteLLMLoggingObj"] = None,
    ) -> "GenericGuardrailAPIInputs":
        """
        Apply content filtering guardrail to a batch of texts.

        This method checks for sensitive patterns and blocked keywords,
        either blocking the request or masking the sensitive content.

        Args:
            inputs: Dictionary containing texts and optional images
            request_data: Request data dictionary for logging metadata
            input_type: Whether this is a "request" or "response"
            logging_obj: Optional logging object

        Returns:
            GenericGuardrailAPIInputs - processed_texts may be masked, images unchanged

        Raises:
            HTTPException: If sensitive content is detected and action is BLOCK
        """
        start_time: Final = datetime.now()
        detections: Final[list[ContentFilterDetection]] = []
        masked_entity_count: Final[dict[str, int]] = {}
        status: GuardrailStatus = "success"
        exception_str: str = ""

        try:
            texts: Final = inputs.get("texts", [])
            images: Final = inputs.get("images", [])

            # Process images if present
            await self._process_images(images, detections)

            # Process texts
            verbose_proxy_logger.debug("ContentFilterGuardrail: Applying guardrail to %s text(s)", len(texts))

            processed_texts: Final = []
            for text in texts:
                # Competitor intent check first (optional; may refuse/reframe)
                if self._competitor_intent_checker and text:
                    intent_result = self._competitor_intent_checker.run(text)
                    if intent_result.get("intent", "other") != "other":
                        self._apply_competitor_intent_policy(intent_result, request_data, detections)
                filtered_text = self._filter_single_text(text, detections=detections)
                processed_texts.append(filtered_text)

            verbose_proxy_logger.debug("ContentFilterGuardrail: Guardrail applied successfully")
            inputs["texts"] = processed_texts

            self._scan_tool_call_arguments(inputs=inputs, detections=detections)

            if input_type == "request":
                self._scan_mcp_tool_call_arguments(
                    request_data=request_data, detections=detections, logging_obj=logging_obj
                )

            # Count masked entities by type
            self._count_masked_entities(detections, masked_entity_count)

            return inputs
        except HTTPException:
            status = "guardrail_intervened"
            raise
        except Exception as e:
            status = "guardrail_failed_to_respond"
            exception_str = str(e)
            raise e
        finally:
            # Log guardrail information
            self._log_guardrail_information(
                request_data=request_data,
                detections=detections,
                status=status,
                start_time=start_time,
                masked_entity_count=masked_entity_count,
                exception_str=exception_str,
            )

    async def async_post_call_streaming_iterator_hook(
        self,
        user_api_key_dict: UserAPIKeyAuth,
        response: Any,
        request_data: dict,
    ) -> AsyncGenerator[ModelResponseStream, None]:
        """
        Process streaming response chunks and check for blocked content.

        For BLOCK action: Raises HTTPException immediately when blocked content is detected.
        For MASK action: Content is buffered to handle patterns split across chunks.

        At stream end (including when BLOCK raises HTTPException), write a
        standard_logging_guardrail_information entry into request_data["metadata"]
        so the post-call log row reaches standard_logging_object.guardrail_information
        and the UI Request Lifecycle panel. Mirrors apply_guardrail's finally-block
        contract.
        """
        accumulated_text_by_choice: Final[dict[int, str]] = {}
        yielded_masked_text_len_by_choice: Final[dict[int, int]] = {}
        latest_detections_by_choice: Final[dict[int, list[ContentFilterDetection]]] = {}
        buffer_size: Final = 50  # Increased buffer to catch patterns split across many chunks

        start_time: Final = datetime.now()
        detections: list[ContentFilterDetection] = []
        masked_entity_count: Final[dict[str, int]] = {}
        status: GuardrailStatus = "success"
        exception_str: str = ""

        verbose_proxy_logger.info(
            "ContentFilterGuardrail: Starting robust streaming masking for model %s", request_data.get("model")
        )

        try:
            async for item in response:
                if isinstance(item, ModelResponseStream) and item.choices:
                    for choice in item.choices:
                        if not (hasattr(choice, "delta") and choice.delta):
                            continue

                        choice_index = getattr(choice, "index", 0)
                        if not isinstance(choice_index, int):
                            choice_index = 0

                        content = getattr(choice.delta, "content", None)
                        is_final = bool(getattr(choice, "finish_reason", None))
                        if isinstance(content, str) and content:
                            accumulated_text_by_choice[choice_index] = (
                                accumulated_text_by_choice.get(choice_index, "") + content
                            )
                        elif not is_final:
                            continue

                        text_to_check = accumulated_text_by_choice.get(choice_index, "")
                        if not text_to_check:
                            continue

                        # Add a space at the end if it's the final chunk to trigger word boundaries (\b)
                        text_to_scan = text_to_check + (" " if is_final else "")
                        choice_detections: list[ContentFilterDetection] = []

                        try:
                            # _filter_single_text scans the whole accumulated
                            # choice buffer every chunk, so previous-chunk
                            # matches are guaranteed to be re-found. Keeping
                            # only each choice's latest scan avoids duplicate
                            # detections in the final log row.
                            masked_text = self._filter_single_text(text_to_scan, detections=choice_detections)
                            if is_final and masked_text.endswith(" "):
                                masked_text = masked_text[:-1]
                            latest_detections_by_choice[choice_index] = choice_detections
                        except HTTPException:
                            latest_detections_by_choice[choice_index] = choice_detections
                            raise
                        except Exception as e:
                            verbose_proxy_logger.error("ContentFilterGuardrail: Error in masking: %s", e)
                            masked_text = text_to_scan  # Fallback to current text

                        # Determine how much can be safely yielded
                        if is_final:
                            safe_to_yield_len = len(masked_text)
                        else:
                            safe_to_yield_len = max(0, len(masked_text) - buffer_size)

                        yielded_masked_text_len = yielded_masked_text_len_by_choice.get(choice_index, 0)
                        if safe_to_yield_len > yielded_masked_text_len:
                            new_masked_content = masked_text[yielded_masked_text_len:safe_to_yield_len]
                            choice.delta.content = new_masked_content
                            yielded_masked_text_len_by_choice[choice_index] = safe_to_yield_len
                        else:
                            # Hold content by yielding empty content on this choice
                            # while preserving chunk metadata and other choices.
                            choice.delta.content = ""

                    yield item
                else:
                    # Not a ModelResponseStream or no choices - yield as is
                    yield item

            # Any remaining content (should have been handled by is_final, but just in case)
            if any(
                yielded_masked_text_len_by_choice.get(choice_index, 0) < len(accumulated_text)
                for choice_index, accumulated_text in accumulated_text_by_choice.items()
            ):
                # We already reached the end of the generator
                pass
        except HTTPException:
            status = "guardrail_intervened"
            raise
        except Exception as e:
            status = "guardrail_failed_to_respond"
            exception_str = str(e)
            raise e
        finally:
            detections = [
                detection
                for choice_detections in latest_detections_by_choice.values()
                for detection in choice_detections
            ]
            self._count_masked_entities(detections, masked_entity_count)
            self._log_guardrail_information(
                request_data=request_data,
                detections=detections,
                status=status,
                start_time=start_time,
                masked_entity_count=masked_entity_count,
                exception_str=exception_str,
            )

    @staticmethod
    def get_config_model():
        from litellm.types.proxy.guardrails.guardrail_hooks.litellm_content_filter import (
            LitellmContentFilterGuardrailConfigModel,
        )

        return LitellmContentFilterGuardrailConfigModel

    @classmethod
    def get_supported_event_hooks(cls) -> list[GuardrailEventHooks]:
        return [
            GuardrailEventHooks.pre_call,
            GuardrailEventHooks.post_call,
            GuardrailEventHooks.during_call,
            GuardrailEventHooks.realtime_input_transcription,
            GuardrailEventHooks.pre_mcp_call,
            GuardrailEventHooks.post_mcp_call,
        ]
