"""
Lorebook (Character Book) engine.

Scans recent messages, triggers entries by keyword, and injects
world-building / character lore into the prompt. Supports:
  - Keyword scanning (case-insensitive substring match)
  - Selective mode (primary + secondary keys must both match)
  - Constant entries (always injected)
  - Token budget (drops lowest-priority entries first)
  - insertion_order sorting

Extracted from app/services/lorebook.py.
No sqlalchemy/fastapi/redis imports allowed.
"""

from __future__ import annotations

import logging
from dataclasses import dataclass, field

logger = logging.getLogger(__name__)

CHARS_PER_TOKEN = 4  # rough estimate


@dataclass
class LorebookEntry:
    """A single lorebook entry."""

    keys: list[str] = field(default_factory=list)
    secondary_keys: list[str] = field(default_factory=list)
    content: str = ""
    enabled: bool = True
    constant: bool = False
    selective: bool = False
    priority: int = 5
    position: str = "after_char"  # before_char | after_char
    insertion_order: int = 100


class LorebookEngine:
    """Scan recent messages and return triggered lorebook entries."""

    def __init__(
        self,
        entries: list[LorebookEntry],
        scan_depth: int = 10,
        token_budget: int = 500,
    ) -> None:
        self.entries = entries
        self.scan_depth = scan_depth
        self.token_budget = token_budget

    # -- public API --------------------------------------------------------

    def scan(self, recent_messages: list[str]) -> list[LorebookEntry]:
        """Scan recent N messages, return triggered entries sorted by
        insertion_order, trimmed to fit within token_budget."""

        window = recent_messages[-self.scan_depth :]
        combined = "\n".join(window).lower()

        triggered: list[LorebookEntry] = []

        for entry in self.entries:
            if not entry.enabled:
                continue

            if entry.constant:
                triggered.append(entry)
                continue

            if self._is_triggered(entry, combined):
                triggered.append(entry)

        # Apply token budget -- drop lowest-priority entries first
        triggered = self._apply_budget(triggered)

        # Sort by insertion_order (ascending)
        triggered.sort(key=lambda e: e.insertion_order)

        return triggered

    # -- internals ---------------------------------------------------------

    @staticmethod
    def _any_key_matches(keys: list[str], text: str) -> bool:
        """Return True if any key is found as a substring in text."""
        return any(k.lower() in text for k in keys)

    def _is_triggered(self, entry: LorebookEntry, combined_lower: str) -> bool:
        """Check if an entry's keywords are triggered by the combined text."""
        primary_hit = self._any_key_matches(entry.keys, combined_lower)

        if not primary_hit:
            return False

        if entry.selective:
            secondary_hit = self._any_key_matches(
                entry.secondary_keys, combined_lower
            )
            return secondary_hit

        return True

    def _apply_budget(
        self, entries: list[LorebookEntry]
    ) -> list[LorebookEntry]:
        """Keep entries within token_budget. Drop lowest-priority entries
        first (higher priority number = lower priority)."""

        # Sort by priority ascending (lower number = higher priority)
        by_priority = sorted(entries, key=lambda e: e.priority)

        kept: list[LorebookEntry] = []
        used_tokens = 0

        for entry in by_priority:
            entry_tokens = self._estimate_tokens(entry.content)
            if used_tokens + entry_tokens <= self.token_budget:
                kept.append(entry)
                used_tokens += entry_tokens
            else:
                logger.debug(
                    "Lorebook budget exceeded, dropping entry "
                    "(priority=%d, tokens=%d): %.40s…",
                    entry.priority,
                    entry_tokens,
                    entry.content,
                )

        return kept

    @staticmethod
    def _estimate_tokens(text: str) -> int:
        """Rough token count: len(text) / CHARS_PER_TOKEN."""
        return max(1, len(text) // CHARS_PER_TOKEN)
