# Copyright (C) 2026 NPO NT
# SPDX-License-Identifier: AGPL-3.0-only

from __future__ import annotations

import hashlib
import re
from dataclasses import dataclass
from typing import Iterable, Sequence

from .models import Action, Box, Finding, WordToken
from .policy import category_policy


@dataclass(frozen=True, slots=True)
class DetectionRule:
    category: str
    pattern: re.Pattern[str]
    capture_group: int = 0
    confidence: float = 95.0
    reason: str = "Совпадение с локальным правилом"


FLAGS = re.IGNORECASE | re.UNICODE

NAME_WORD = r"[А-ЯЁA-Z][А-ЯЁA-Za-zА-Яа-яё'’-]{1,40}"
FULL_NAME = rf"{NAME_WORD}(?:\s+{NAME_WORD}){{1,2}}"
INITIALS = r"(?:[А-ЯЁA-Z]\.?\s*){1,2}"
SURNAME = r"(?:[А-ЯЁ][а-яё'’-]{1,40}|[А-ЯЁ]{2,41}|[A-Z][A-Za-z'’-]{1,40}|[A-Z]{2,41})"
DATE_DMY = r"(?:0?[1-9]|[12]\d|3[01])\s*[.\-/]\s*(?:0?[1-9]|1[0-2])\s*[.\-/]\s*(?:19|20)\d{2}"
DATE_YMD = r"(?:19|20)\d{2}\s*[.\-/]\s*(?:0?[1-9]|1[0-2])\s*[.\-/]\s*(?:0?[1-9]|[12]\d|3[01])"
DATE = rf"(?:{DATE_DMY}|{DATE_YMD})"
BIRTH_LABEL_RU = r"(?:дата\s+рожд(?:ения|\.)?|год\s+рождения|д\.?\s*р\.?|родил(?:ся|ась))"
BIRTH_LABEL_EN = r"(?:birth\s+date|date\s+of\s+birth|dob)"
BIRTH_LABEL = rf"(?:{BIRTH_LABEL_RU}(?:\s*/\s*{BIRTH_LABEL_EN})?|{BIRTH_LABEL_EN})"


RULES: tuple[DetectionRule, ...] = (
    DetectionRule(
        "email",
        re.compile(r"\b[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}\b", FLAGS),
        confidence=99.0,
        reason="Формат адреса электронной почты",
    ),
    DetectionRule(
        "email",
        re.compile(r"(?:e-?mail|электронная\s+почта)\s*[:№#-]?\s*(\S{4,100})", FLAGS),
        capture_group=1,
        confidence=94.0,
        reason="Значение рядом с подписью email; правило устойчиво к ошибкам OCR внутри адреса",
    ),
    DetectionRule(
        "phone",
        re.compile(r"(?<!\d)(?:\+?7|8)[\s(\-]*\d{3}[)\s\-]*\d{3}[\s\-]*\d{2}[\s\-]*\d{2}(?!\d)", FLAGS),
        confidence=98.0,
        reason="Формат российского телефонного номера",
    ),
    DetectionRule(
        "snils",
        re.compile(r"(?<!\d)\d{3}[\s\-]?\d{3}[\s\-]?\d{3}[\s\-]?\d{2}(?!\d)", FLAGS),
        confidence=97.0,
        reason="Формат СНИЛС",
    ),
    DetectionRule(
        "birth_date",
        re.compile(
            rf"{BIRTH_LABEL}\s*[:№#-]?\s*({DATE})",
            FLAGS,
        ),
        capture_group=1,
        confidence=99.0,
        reason="Дата рядом с подписью поля рождения",
    ),
    DetectionRule(
        "person_name",
        re.compile(
            rf"(?:пациент(?:ка)?|ф\.?\s*и\.?\s*о\.?(?:\s+пациента)?|фамилия(?:,?\s*имя(?:,?\s*отчество)?)?|patient(?:\s+name)?|full\s+name|name)\s*[:№#-]?\s*({FULL_NAME})",
            FLAGS,
        ),
        capture_group=1,
        confidence=98.0,
        reason="Имя рядом с подписью поля пациента",
    ),
    DetectionRule(
        "person_name",
        re.compile(
            rf"(?:фамилия|surname|last\s+name)\s*[:№#-]?\s*({NAME_WORD})\b",
            FLAGS,
        ),
        capture_group=1,
        confidence=99.0,
        reason="Фамилия рядом с явной подписью поля",
    ),
    DetectionRule(
        "person_name",
        re.compile(
            rf"(?:пациент(?:ка)?|ф\.?\s*и\.?\s*о\.?(?:\s+пациента)?|patient(?:\s+name)?)\s*[:№#-]?\s*({NAME_WORD}\s+{INITIALS})\b",
            FLAGS,
        ),
        capture_group=1,
        confidence=98.0,
        reason="Фамилия и инициалы рядом с подписью пациента или ФИО",
    ),
    DetectionRule(
        "person_name",
        re.compile(rf"\b({SURNAME}\s+{INITIALS})\b", re.UNICODE),
        capture_group=1,
        confidence=95.0,
        reason="Фамилия с инициалами — прямой идентификатор человека",
    ),
    DetectionRule(
        "person_name",
        re.compile(
            r"\b([А-ЯЁ][а-яё'’-]{1,35}\s+[А-ЯЁ][а-яё'’-]{1,35}\s+[А-ЯЁ][а-яё'’-]{1,30}(?:ович|евич|ич|овна|евна|ична))\b",
            re.UNICODE,
        ),
        capture_group=1,
        confidence=88.0,
        reason="Трёхчастное русское имя с патронимическим окончанием",
    ),
    DetectionRule(
        "passport",
        re.compile(
            r"(?:паспорт|удостоверение\s+личности|passport)\s*[:№#-]?\s*((?:\d{2}\s*\d{2}|\d{4})\s*\d{6})",
            FLAGS,
        ),
        capture_group=1,
        confidence=98.0,
        reason="Номер рядом с подписью документа, удостоверяющего личность",
    ),
    DetectionRule(
        "insurance_policy",
        re.compile(
            r"(?:полис(?:\s+(?:омс|дмс))?|oms|insurance\s+policy)\s*[:№#-]?\s*([A-ZА-ЯЁ0-9][A-ZА-ЯЁ0-9\s\-]{7,30})",
            FLAGS,
        ),
        capture_group=1,
        confidence=96.0,
        reason="Номер рядом с подписью страхового полиса",
    ),
    DetectionRule(
        "medical_record_id",
        re.compile(
            r"(?:мед(?:ицинская)?\.?\s*карта|история\s+болезни|номер\s+(?:случая|направления|исследования|образца)|patient\s*id|medical\s*record|mrn|accession)\s*[:№#-]?\s*([A-ZА-ЯЁ0-9][A-ZА-ЯЁ0-9\-/]{2,30})",
            FLAGS,
        ),
        capture_group=1,
        confidence=96.0,
        reason="Внутренний медицинский идентификатор рядом с подписью поля",
    ),
    DetectionRule(
        "address",
        re.compile(
            r"(?:адрес(?:\s+(?:регистрации|проживания))?|проживает|registration\s+address|home\s+address|address)\s*[:№#-]?\s*(.{6,120})",
            FLAGS,
        ),
        capture_group=1,
        confidence=92.0,
        reason="Текст рядом с подписью адресного поля",
    ),
    DetectionRule(
        "event_date",
        re.compile(rf"\b({DATE})\b", FLAGS),
        capture_group=1,
        confidence=75.0,
        reason="Точная календарная дата требует решения по клинической необходимости",
    ),
)


def _line_groups(tokens: Iterable[WordToken]) -> list[list[WordToken]]:
    grouped: dict[tuple[int, ...], list[WordToken]] = {}
    for token in tokens:
        if not token.text.strip():
            continue
        grouped.setdefault(token.line_key, []).append(token)
    lines: list[list[WordToken]] = []
    for key in sorted(grouped):
        lines.append(sorted(grouped[key], key=lambda token: (token.box.x0, token.box.y0)))
    return lines


def _build_line_map(tokens: Sequence[WordToken]) -> tuple[str, list[tuple[int, int, WordToken]]]:
    parts: list[str] = []
    mapping: list[tuple[int, int, WordToken]] = []
    cursor = 0
    for token in tokens:
        if parts:
            parts.append(" ")
            cursor += 1
        start = cursor
        parts.append(token.text)
        cursor += len(token.text)
        mapping.append((start, cursor, token))
    return "".join(parts), mapping


def _box_for_span(mapping: Sequence[tuple[int, int, WordToken]], start: int, end: int) -> Box | None:
    selected = [token for token_start, token_end, token in mapping if token_end > start and token_start < end]
    if not selected:
        return None
    box = selected[0].box
    for token in selected[1:]:
        box = box.union(token.box)
    return box


def _finding_id(page_index: int, category: str, box: Box, source: str) -> str:
    material = f"{page_index}:{category}:{box.x0:.1f}:{box.y0:.1f}:{box.x1:.1f}:{box.y1:.1f}:{source}"
    return hashlib.sha256(material.encode("utf-8")).hexdigest()[:16]


def detect_tokens(tokens: Sequence[WordToken], page_index: int) -> list[Finding]:
    findings: list[Finding] = []
    for line_tokens in _line_groups(tokens):
        line_text, mapping = _build_line_map(line_tokens)
        for rule in RULES:
            for match in rule.pattern.finditer(line_text):
                start, end = match.span(rule.capture_group)
                box = _box_for_span(mapping, start, end)
                if box is None or box.area <= 0:
                    continue
                policy = category_policy(rule.category)
                token_conf = min(
                    (token.confidence for token_start, token_end, token in mapping if token_end > start and token_start < end),
                    default=100.0,
                )
                confidence = min(rule.confidence, token_conf if token_conf >= 0 else rule.confidence)
                findings.append(
                    Finding(
                        finding_id=_finding_id(page_index, rule.category, box, line_tokens[0].source),
                        page_index=page_index,
                        category=rule.category,
                        box=box,
                        action=policy.action,
                        severity=policy.severity,
                        confidence=confidence,
                        source=line_tokens[0].source,
                        reason=rule.reason,
                        selected=policy.action == Action.REDACT,
                    )
                )
    return deduplicate_findings(findings)


def deduplicate_findings(findings: Sequence[Finding]) -> list[Finding]:
    """Prefer stronger detections when native/OCR/rules describe the same page region."""

    ordered = sorted(
        findings,
        key=lambda finding: (
            finding.page_index,
            0 if finding.category != "event_date" else 1,
            -finding.confidence,
            -finding.box.area,
        ),
    )
    kept: list[Finding] = []
    for candidate in ordered:
        duplicate_index: int | None = None
        for index, existing in enumerate(kept):
            if existing.page_index != candidate.page_index:
                continue
            if existing.category == candidate.category and existing.box.overlap_ratio(candidate.box) >= 0.7:
                duplicate_index = index
                break
            if candidate.category == "event_date" and existing.box.overlap_ratio(candidate.box) >= 0.8:
                duplicate_index = index
                break
        if duplicate_index is None:
            kept.append(candidate)
            continue
        existing = kept[duplicate_index]
        if candidate.confidence > existing.confidence:
            kept[duplicate_index] = candidate
    return sorted(kept, key=lambda finding: (finding.page_index, finding.box.y0, finding.box.x0, finding.category))
