"""§19 data-residency redaction layer (#960) — THE critical safety boundary.

Провайдер LLM ВНЕШНИЙ (OpenAI) → данные покидают РФ. Чувствительные данные не
должны попасть наружу НИКОГДА. Защита эшелонирована (defense-in-depth):

1. Allowlist / safe-payload (ПЕРВИЧНОЕ): публичный API заставляет вызывающего
   явно собрать ``SafePayload`` из не-чувствительных полей/агрегатов/публичного
   текста — а НЕ передавать сырые строки БД. См. ``SafePayload`` ниже и контракт
   в ``app/services/llm/__init__.py``.
2. Hard-block (ENFORCED): ``SafePayload(is_confidential=True)`` → ``RedactionRefusedError``.
   Контент, помеченный конфиденциальным (напр. insight.is_confidential), НИКОГДА
   не уходит во внешнего провайдера — клиент поднимает типизированную ошибку, а не
   «тихо» отправляет.
3. Regex-scrub (ВТОРИЧНОЕ, belt-and-suspenders): из свободного текста вырезаются
   RU PII-паттерны (телефон / email / СНИЛС / ИНН / паспорт / ФИО-подобное) и
   заменяются на ``[REDACTED:<kind>]``. Это НЕ основная гарантия — основная это (1).

``scrub_safe_payload`` — единственная точка, через которую ``client.complete``
прогоняет данные перед внешним вызовом (mandatory на external-пути; будущий
RU-hosted провайдер сможет это ослабить — см. шов ``LLMProvider.is_external``).
"""

from __future__ import annotations

import logging
import re
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any

logger = logging.getLogger(__name__)


class RedactionRefusedError(Exception):
    """Полезная нагрузка помечена конфиденциальной → отправка наружу запрещена.

    Поднимается из ``scrub_safe_payload`` для external-провайдера, когда
    ``SafePayload.is_confidential`` is True. Клиент ловит это и превращает в
    детерминированный fallback (система не падает из-за того что данные нельзя слать).
    """


@dataclass(frozen=True, slots=True)
class SafePayload:
    """Явно собранная вызывающим НЕ-чувствительная нагрузка для внешней LLM.

    Контракт (allowlist-first): консьюмеры (#956 граддок-extraction, #957 chat)
    ОБЯЗАНЫ конструировать это вручную из проверенных полей — НЕ передавать сырые
    DB-строки/insight'ы. Структура — единственное, что физически доходит до клиента.

    Attributes:
        text: Свободный текст для модели (публичный градрегламент, агрегаты, вопрос
            пользователя). Прогоняется через regex-scrub перед отправкой.
        fields: Структурированные не-чувствительные пары ключ→значение (числа/коды/
            короткие строки). Значения-строки тоже скрабятся.
        is_confidential: Если True — вся нагрузка под жёстким блоком: ``scrub_safe_payload``
            для external-провайдера поднимет ``RedactionRefusedError``. Прокидывать сюда
            ЛЮБОЙ confidential-флаг источника (напр. insight.is_confidential).
    """

    text: str = ""
    fields: dict[str, Any] = field(default_factory=dict)
    is_confidential: bool = False


# ── RU PII regex (вторичная защита) ──────────────────────────────────────────
# Порядок применения важен: более специфичные/длинные паттерны раньше, чтобы они не
# «съедались» более общими (СНИЛС/ИНН/паспорт раньше «голых» групп цифр).

# СНИЛС: NNN-NNN-NNN NN (последняя пара — через пробел/дефис).
_SNILS_RE = re.compile(r"\b\d{3}-\d{3}-\d{3}[\s-]\d{2}\b")
# Паспорт РФ: NNNN NNNNNN (серия 4 + номер 6, разделитель пробел/дефис).
_PASSPORT_RE = re.compile(r"\b\d{4}[\s-]\d{6}\b")
# Телефон РФ: +7/8, опц. скобки/пробелы/дефисы, 10 значащих цифр.
_PHONE_RE = re.compile(r"(?:\+7|\b8)[\s\-(]*\d{3}[\s\-)]*\d{3}[\s-]*\d{2}[\s-]*\d{2}\b")
# Телефон РФ «голый»: 11 цифр без разделителей, начинаются с 7 или 8 (#1207).
# Покрывает copy-paste форму «79221234567» / «89221234567», которую _PHONE_RE
# пропускает из-за требования префикса «+7»/«\b8» + хотя бы одного разделителя.
# Ставится РАНЬШЕ _SNILS_BARE_RE (любые 11 цифр), чтобы не путать с СНИЛС.
_PHONE_BARE_RE = re.compile(r"(?<!\d)[78]\d{10}(?!\d)")
# Телефон РФ «локальный»: 10 значащих цифр БЕЗ кода страны, начинаются с мобильного
# «9», сгруппированы 9XX XXX XX XX через пробел/дефис (#1641). _PHONE_RE требует
# префикс +7/8, _PHONE_BARE_RE — ровно 11 слитных цифр → формат «922 123 45 67» /
# «922-123-45-67» проходил мимо. Якорь «9» + фиксированная группировка 3-3-2-2 не
# пересекается с паспортом (4+6) / СНИЛС (3-3-3 2). Ставится ПОСЛЕ префиксных форм,
# чтобы local-часть «+7 912 …» сначала ушла как полноценный phone.
_PHONE_LOCAL_RE = re.compile(r"(?<!\d)9\d{2}[\s-]\d{3}[\s-]\d{2}[\s-]\d{2}(?!\d)")
# Email.
_EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b")
# ИНН: требуется контекстный якорь — маркер «ИНН» / «inn» (регистронезависимо) не
# далее ~20 символов перед 10/12-значным блоком цифр (#1640). Голые цифры без якоря
# НЕ редактируются — это исключает легитимные суммы (1 200 000 000 = 10 цифр) и
# прочие числа, которые случайно совпадают с длиной ИНН.
# «Прокладка» (?:[^\d\n]{0,20}) позволяет «ИНН физлица 500100732259» (слово между
# маркером и числом), но не перешагивает через строки или другие цифровые блоки.
# Группа 1 захватывает только цифры — передаётся в _inn_checksum_valid.
# Границы слова (#1640 follow-up): (?<!\w) / (?!\w) вокруг цифрового блока гарантируют,
# что блок не является частью более длинного алфавитно-цифрового токена.
# \b не подходит: Python \w включает цифры и буквы, поэтому \b между буквой и цифрой
# отсутствует (оба — \w). Lookaround'ы (?<!\w)/(?!\w) дают нужную границу «не-\w перед/после».
# (?<!\d) заменён на (?<!\w): закрывает случай «ref7707083893» (alpha-префикс).
# (?!\d) заменён на (?!\w): закрывает случай «7707083893more» (alpha-суффикс).
_INN_RE = re.compile(r"(?i:инн|inn)\s*[:№]?\s*(?:[^\d\n]{0,20})(?<!\w)(\d{12}|\d{10})(?!\w)")


def _inn_checksum_valid(token: str) -> bool:
    """Проверить контрольные цифры ИНН по алгоритму ФНС.

    10-значный (юрлицо): одна контрольная цифра (последняя).
    12-значный (физлицо/ИП): две контрольные цифры (11-я и 12-я).
    Веса — стандартные коэффициенты ФНС.

    Вызывается из _inn_repl с group(1) — только цифровой блок без keyword-префикса.
    Дополнительный gate поверх контекстного якоря (_INN_RE): отфильтровывает
    синтетические 10/12-значные числа, у которых первые цифры случайно дают верную
    контрольную сумму.
    """
    digits = [int(c) for c in token]
    if len(digits) == 10:
        weights = (2, 4, 10, 3, 5, 9, 4, 6, 8)
        control = sum(w * d for w, d in zip(weights, digits[:9], strict=True)) % 11 % 10
        return control == digits[9]
    if len(digits) == 12:
        w1 = (7, 2, 4, 10, 3, 5, 9, 4, 6, 8)
        w2 = (3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8)
        c1 = sum(w * d for w, d in zip(w1, digits[:10], strict=True)) % 11 % 10
        c2 = sum(w * d for w, d in zip(w2, digits[:11], strict=True)) % 11 % 10
        return c1 == digits[10] and c2 == digits[11]
    return False


def _inn_repl(match: re.Match[str]) -> str:
    """re.sub callback: редактировать ИНН только при валидной контрольной сумме.

    _INN_RE захватывает цифры в group(1) — keyword-префикс в group(0). Для замены
    нужна только часть с цифрами (group 1). Если checksum не проходит — возвращаем
    исходную строку целиком (keyword + цифры), не удаляем ничего.
    """
    digits = match.group(1)
    return "[REDACTED:inn]" if _inn_checksum_valid(digits) else match.group(0)


# СНИЛС «голый»: ровно 11 цифр без разделителей (#1207). _SNILS_RE требует
# формат «NNN-NNN-NNN NN»; raw «12345678901» проходит мимо. По длине не пересекается
# с ИНН (10/12); пересекается с _PHONE_BARE_RE (тоже 11 цифр), поэтому идёт ПОСЛЕ
# него — phone-bare съест 7/8-префиксы, СНИЛС-bare добьёт остаток.
_SNILS_BARE_RE = re.compile(r"(?<!\d)\d{11}(?!\d)")
# ФИО-подобное: три слова с заглавной кириллической буквы подряд (Фамилия Имя Отчество).
# Каждое слово — либо «Иванов» (Titlecase), либо «ИВАНОВ» (CAPS, мин 2 буквы) — последнее
# покрывает стандарт ЕГРН/паспортных выписок «ИВАНОВ ИВАН ИВАНОВИЧ» (#1207).
# Грубая эвристика — может задеть «Министерство Строительства России» и подобное; это
# приемлемо для вторичной защиты (лучше лишний REDACTED, чем утечка ФИО).
_FULLNAME_RE = re.compile(
    r"\b(?:[А-ЯЁ][а-яё]+|[А-ЯЁ]{2,})"
    r"\s+(?:[А-ЯЁ][а-яё]+|[А-ЯЁ]{2,})"
    r"\s+(?:[А-ЯЁ][а-яё]+|[А-ЯЁ]{2,})\b"
)

# (regex, kind, repl). ``repl`` — строка-плейсхолдер ИЛИ callback для re.subn
# (используется ИНН: редактирует только checksum-валидные кандидаты — #1640).
# Применяются последовательно в этом порядке.
# Порядок критичен: _PHONE_BARE_RE раньше _SNILS_BARE_RE, чтобы 11-значные
# с префиксом 7/8 ушли как phone (телефон семантически точнее СНИЛС'а).
_Repl = str | Callable[[re.Match[str]], str]
_PII_PATTERNS: tuple[tuple[re.Pattern[str], str, _Repl], ...] = (
    (_SNILS_RE, "snils", "[REDACTED:snils]"),
    (_PASSPORT_RE, "passport", "[REDACTED:passport]"),
    (_PHONE_RE, "phone", "[REDACTED:phone]"),
    (_PHONE_BARE_RE, "phone", "[REDACTED:phone]"),
    (_PHONE_LOCAL_RE, "phone", "[REDACTED:phone]"),
    (_EMAIL_RE, "email", "[REDACTED:email]"),
    (_INN_RE, "inn", _inn_repl),
    (_SNILS_BARE_RE, "snils", "[REDACTED:snils]"),
    (_FULLNAME_RE, "name", "[REDACTED:name]"),
)


def scrub_text(value: str) -> str:
    """Вырезать RU PII из свободного текста → ``[REDACTED:<kind>]``.

    Вторичная защита (см. модульный docstring). НИКОГДА не логирует ни исходный, ни
    результирующий текст — только факт срабатывания и kind (без значения).
    """
    if not value:
        return value
    redacted = value
    placeholder = "[REDACTED:%s]"
    for pattern, kind, repl in _PII_PATTERNS:
        # n из subn для callback-repl (ИНН) считает ВСЕ совпадения, включая кандидаты,
        # которые callback вернул без изменений (не прошли checksum). Поэтому реальное
        # число замен берём по приросту числа плейсхолдеров — корректно и для str, и
        # для callback, без утечки самого PII-значения в лог.
        before_count = redacted.count(placeholder % kind)
        redacted = pattern.sub(repl, redacted)
        n = redacted.count(placeholder % kind) - before_count
        if n > 0:
            # Логируем ТОЛЬКО kind и количество — без самого PII-значения.
            logger.info("redaction: scrubbed %d %s token(s) from free text", n, kind)
    return redacted


def _scrub_value(value: Any) -> Any:
    """Рекурсивно проскрабить строковые значения в структуре fields."""
    if isinstance(value, str):
        return scrub_text(value)
    if isinstance(value, dict):
        return {k: _scrub_value(v) for k, v in value.items()}
    if isinstance(value, list | tuple):
        scrubbed = [_scrub_value(v) for v in value]
        return type(value)(scrubbed)
    return value


def scrub_safe_payload(payload: SafePayload, *, is_external: bool) -> SafePayload:
    """Mandatory-шаг перед внешним вызовом: hard-block + regex-scrub.

    Args:
        payload: явно собранная вызывающим нагрузка.
        is_external: True для провайдера, выгружающего данные за пределы РФ
            (OpenAI). Для будущего RU-hosted провайдера можно передать False, чтобы
            ослабить scrub — это и есть задокументированный шов (data остаётся в РФ).

    Returns:
        Новый ``SafePayload`` с проскрабленными ``text``/``fields``.

    Raises:
        RedactionRefusedError: если ``is_external`` и ``payload.is_confidential``.
    """
    if is_external and payload.is_confidential:
        # НЕ логируем содержимое — только факт отказа.
        logger.warning("redaction: refusing confidential payload for external LLM provider")
        raise RedactionRefusedError(
            "payload marked is_confidential=True cannot be sent to an external LLM provider"
        )
    if not is_external:
        # RU-hosted провайдер: данные не покидают РФ — scrub не обязателен.
        return payload
    return SafePayload(
        text=scrub_text(payload.text),
        fields=_scrub_value(payload.fields),
        is_confidential=payload.is_confidential,
    )


__all__ = ["RedactionRefusedError", "SafePayload", "scrub_safe_payload", "scrub_text"]
