"""Deterministic regex/lexicon pre-check for the output reviewer.

The LLM reviewer (``app.services.output_review.review_read``) is good at
nuance but is a single model — it can be inconsistent, miss novel phrasings,
or be nudged by candidate content. This module is the belt under the
braces: a cheap, deterministic pass that hard-catches the *obvious* advice /
forecast / level-trigger patterns with zero model variance.

Calling pattern: ``output_review`` runs ``check(candidate)`` *first*. A hit
short-circuits to ``Verdict(clean=False, reason="lexicon:<rule>", layer="deterministic")``.
If the deterministic pass returns clean, the LLM reviewer runs as the
second layer. Fail-closed on either layer.

Keep this file editable in one place — add patterns here as new failure
modes appear in the audit log.
"""
from __future__ import annotations

import re
from dataclasses import dataclass


@dataclass(frozen=True)
class LexiconHit:
    """A deterministic-layer rejection. ``rule`` names which class of pattern
    matched (used as the ``reason`` field), and ``snippet`` is the matched
    substring (≤80 chars) for the audit log."""
    rule: str
    snippet: str


# --- Single-word / short-phrase lexicons --------------------------------------
#
# These are matched as case-insensitive word-boundary regex alternations.
# Keep entries multi-word where possible — bare words like "should" or
# "hold" produce far too many false positives (e.g. "Saudi price cuts",
# "cargo hold", "investors should be aware that…"). Phrases are sharper.

# Direct action language aimed at the reader.
_ACTION_PHRASES = [
    r"\bbuy (?:the |this |that )?(?:dip|stock|name|sector|equity|equities|bond|bonds)\b",
    r"\bis a buy\b", r"\bis a sell\b",
    r"\btake profit\b", r"\btake profits\b",
    r"\btrim (?:the |your |that )?(?:position|exposure|allocation|holding|holdings)\b",
    r"\badd to (?:the |your |that )?(?:position|exposure|allocation|holding|holdings)\b",
    r"\brotate (?:into|out of)\b",
    r"\brebalance (?:into|out of|towards|toward)\b",
    r"\bunderweight (?:the |this )?(?:sector|equities|bonds|tech|defensives|energy|commodities)\b",
    r"\boverweight (?:the |this )?(?:sector|equities|bonds|tech|defensives|energy|commodities)\b",
    r"\baccumulate (?:the |this |that )?(?:position|exposure|stock|name)\b",
]

# Advice register: explicit recommendations.
_ADVICE_PHRASES = [
    r"\byou should\b",
    r"\binvestors should\b",
    r"\bwe recommend\b",
    r"\bour recommendation\b",
    r"\b(?:is |are )?recommended\b",
    r"\bconsider (?:buying|selling|trimming|adding|rotating|hedging|reducing)\b",
    r"\bworth buying\b", r"\bworth selling\b",
]

# Forecast / level register: any price-level cast as a threshold or target.
_FORECAST_PHRASES = [
    r"\bprice target\b",
    r"\btarget of \$?\d",
    r"\bfair value of \$?\d",
    r"\btripwire\b",
    r"\bbreakout\b", r"\bbreakdown level\b",
    r"\bsupport at \$?\d", r"\bresistance at \$?\d",
    r"\bsupport near \$?\d", r"\bresistance near \$?\d",
    r"\bfloor at \$?\d", r"\bceiling at \$?\d",
    r"\bfloor near \$?\d", r"\bceiling near \$?\d",
]


# --- Composed regex patterns --------------------------------------------------
#
# Pattern matches need a small amount of context. Each entry pairs a rule
# name with a compiled pattern.

_PATTERNS = [
    # "close above $93", "break below 4600", "move above 90"
    ("level_trigger", re.compile(
        r"\b(?:close|closes|closing|break|breaks|breaking|broke|"
        r"move|moves|moving|hold|holds|holding|push|pushes)\s+"
        r"(?:above|below|through|past|over|under)\s+\$?\d",
        re.IGNORECASE,
    )),
    # "target(s|ed|ing) ... $95"  — the words 'target' near a number
    ("price_target", re.compile(
        r"\btarget(?:s|ed|ing)?\b[^.\n]{0,30}\$?\d",
        re.IGNORECASE,
    )),
    # "floor/ceiling/support/resistance at/near/of $X"
    ("level_as_noun", re.compile(
        r"\b(?:floor|ceiling|support|resistance)\b[^.\n]{0,15}"
        r"(?:at|near|of|around|just\s+(?:above|below))\s+\$?\d",
        re.IGNORECASE,
    )),
    # "watch for X to break/move/close above/below Y"
    ("watch_for_trigger", re.compile(
        r"\bwatch\s+for\s+[^.\n]{0,40}\s+to\s+"
        r"(?:break|move|close|hold|push)\s+(?:above|below|through|past|over|under)",
        re.IGNORECASE,
    )),
]


def _compile_phrase_set(phrases: list[str]) -> re.Pattern:
    """Compile a list of regex fragments into one alternation pattern with
    case-insensitive matching. The fragments already carry their own word
    boundaries and group structure."""
    return re.compile("|".join(f"(?:{p})" for p in phrases), re.IGNORECASE)


_ACTION_RE = _compile_phrase_set(_ACTION_PHRASES)
_ADVICE_RE = _compile_phrase_set(_ADVICE_PHRASES)
_FORECAST_RE = _compile_phrase_set(_FORECAST_PHRASES)


def _snippet(text: str, match: re.Match) -> str:
    """Window around a match for the audit log. Caps at 80 chars."""
    start = max(0, match.start() - 20)
    end = min(len(text), match.end() + 20)
    s = text[start:end].replace("\n", " ").strip()
    if len(s) > 80:
        s = s[:77] + "..."
    return s


def check(candidate: str) -> LexiconHit | None:
    """Run the deterministic layer. Returns the first matching ``LexiconHit``
    or None if the text is clean by these rules.

    First-match-wins. Order is: action phrases, advice phrases, forecast
    phrases, then composed patterns. Bias toward catching the most direct
    violations first; the audit log records which rule fired."""
    if not candidate or not candidate.strip():
        return None
    text = candidate

    m = _ACTION_RE.search(text)
    if m:
        return LexiconHit(rule="action_phrase", snippet=_snippet(text, m))

    m = _ADVICE_RE.search(text)
    if m:
        return LexiconHit(rule="advice_phrase", snippet=_snippet(text, m))

    m = _FORECAST_RE.search(text)
    if m:
        return LexiconHit(rule="forecast_phrase", snippet=_snippet(text, m))

    for rule, pat in _PATTERNS:
        m = pat.search(text)
        if m:
            return LexiconHit(rule=rule, snippet=_snippet(text, m))

    return None
