"""Представление индикатора полного доменного имени и правила его поиска."""

from __future__ import annotations

import logging
import math
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, FrozenSet, Mapping

import pandas as pd
from catboost import CatBoostClassifier
from publicsuffix2 import get_tld

from ioc_checker.config import settings

import regex

from .analysis import IndicatorAnalysisMixin, IndicatorVerdict
from .patterns import PatternDefinition
from .reason_texts import (
    CDN_SUBSTRING_MISSING_REASON,
    CDN_SUBSTRING_REASON,
    DOMAIN_WHITELIST_REASON,
    MANUAL_TLD_BLOCK_REASON_TEMPLATE,
    MODEL_PROBABILITY_REASON_TEMPLATE,
    NO_TOP1M_REASON,
    TOP1M_RANK_ABOVE_THRESHOLD_REASON_TEMPLATE,
    TOP1M_RANK_MISSING_REASON,
    TOP1M_REASON,
    TOP1M_TOP_100K_REASON_TEMPLATE,
)

OBFUSCATED_DOT_PATTERN = r"""
    (?:
        \.\s*                            # обычная точка и возможные пробелы
      | [\u3002\uFF0E\uFF61]              # Юникод-аналоги точки
      | \[\s*\.\s*\]                    # [.]
      | \(\s*\.\s*\)                    # (.)
      | \[\s*dot\s*\]                    # [dot]
      | \(\s*dot\s*\)                    # (dot)
      | \s+d[o0]t\s+                      # dot между пробелами
    )
"""

_LABEL_RX = r"""
    (?:
        [\p{L}\p{N}\p{M}_-]
        |
        (?<=\p{L})\u200d(?=\p{L})        # zero width joiner между буквами
        |
        (?<=\p{N})\u200d(?=\p{N})        # zero width joiner между цифрами
    )+
"""

DOMAIN_PATTERN = regex.compile(
    rf"""
    (?<![\p{{L}}\p{{N}}_\-])             # граница слева
    {_LABEL_RX}
    (?:{OBFUSCATED_DOT_PATTERN}{_LABEL_RX})+
    """,
    flags=regex.IGNORECASE | regex.VERBOSE,
)

_logger = logging.getLogger(__name__)


_PRUNED_MODEL_PATH = (
    Path(__file__).resolve().parents[2] / "ml" / "models" / "general" / "general_catboost.cbm"
)
_domain_model: CatBoostClassifier | None = None
_domain_feature_names: list[str] = []
_manual_tld_blocklist_path = (
    Path(__file__).resolve().parents[2] / "lists" / "manual_tld_blocklist.txt"
)
_manual_tld_blocklist: set[str] | None = None
_domain_whitelist_path = Path(__file__).resolve().parents[2] / "lists" / "whitelist.txt"
_domain_whitelist: set[str] | None = None


def _load_domain_model() -> CatBoostClassifier:
    """Загрузить модель классификации доменов."""

    global _domain_model, _domain_feature_names

    if _domain_model is None:
        _logger.info(
            "Загружаем pruned-модель доменного классификатора из %s", _PRUNED_MODEL_PATH
        )
        model = CatBoostClassifier()
        model.load_model(_PRUNED_MODEL_PATH)
        _domain_model = model
        _domain_feature_names = list(model.feature_names_)
        _logger.debug("Имена признаков модели: %s", _domain_feature_names)

    return _domain_model


def _build_feature_row(features: Mapping[str, dict[str, Any]]) -> dict[str, Any]:
    """Объединить фичи провайдеров в одну строку для инференса."""

    flattened: dict[str, Any] = {}
    for provider_features in features.values():
        if provider_features:
            flattened.update(provider_features)

    if not _domain_feature_names:
        _load_domain_model()

    return {name: flattened.get(name) for name in _domain_feature_names}


def _load_manual_tld_blocklist() -> set[str]:
    """Загрузить набор TLD из ручного блоклиста."""

    global _manual_tld_blocklist

    if _manual_tld_blocklist is None:
        if not _manual_tld_blocklist_path.exists():
            _logger.warning(
                "Не найден файл ручного блоклиста TLD: %s", _manual_tld_blocklist_path
            )
            _manual_tld_blocklist = set()
        else:
            raw_lines = _manual_tld_blocklist_path.read_text(encoding="utf-8").splitlines()
            _manual_tld_blocklist = {
                _normalize_tld_to_punycode(line) for line in raw_lines if line.strip()
            }
            _logger.info(
                "Загружено %s записей из ручного блоклиста TLD", len(_manual_tld_blocklist)
            )

    return _manual_tld_blocklist


def _load_domain_whitelist() -> set[str]:
    """Загрузить белый список доменов и закэшировать его."""

    global _domain_whitelist

    if _domain_whitelist is None:
        if not _domain_whitelist_path.exists():
            _logger.warning("Не найден файл белого списка доменов: %s", _domain_whitelist_path)
            _domain_whitelist = set()
        else:
            raw_lines = _domain_whitelist_path.read_text(encoding="utf-8").splitlines()
            _domain_whitelist = {
                _normalize_domain_to_punycode(line) for line in raw_lines if line.strip()
            }
            _logger.info(
                "Загружено %s записей из белого списка доменов", len(_domain_whitelist)
            )

    return _domain_whitelist


def _normalize_tld_to_punycode(tld: str) -> str:
    """Привести TLD к punycode для сопоставления."""

    parts = tld.strip().lower().split(".")
    return ".".join(part.encode("idna").decode("ascii") for part in parts if part)


def _normalize_domain_to_punycode(domain: str) -> str:
    """Привести домен к punycode перед сравнением."""

    parts = domain.strip().lower().split(".")
    return ".".join(part.encode("idna").decode("ascii") for part in parts if part)


def _extract_domain_tld(domain: str) -> str | None:
    """Вернуть TLD домена на основе publicsuffix."""

    if not domain:
        return None

    try:
        raw_tld = get_tld(domain, idna=True)
    except Exception:
        _logger.warning("Не удалось вычислить TLD для домена %s", domain)
        return None

    if not raw_tld:
        return None

    return _normalize_tld_to_punycode(raw_tld)


def _predict_domain_probability(features: Mapping[str, dict[str, Any]]) -> float:
    """Выполнить инференс доменной модели и вернуть вероятность вредоносности."""

    model = _load_domain_model()
    feature_row = _build_feature_row(features)
    df = pd.DataFrame([feature_row])
    proba = float(model.predict_proba(df)[0][1])
    _logger.info("Инференс модели завершён, вероятность вредоносности=%.4f", proba)
    return proba


@dataclass(frozen=True)
class DOMAINIndicator(IndicatorAnalysisMixin):
    """Индикатор, описывающий доменное имя."""

    value: str
    kinds: FrozenSet[str] = field(default_factory=frozenset)

    @property
    def category(self) -> str:
        """Категория индикатора."""

        return "domain"

    def pre_analysis(self) -> dict[str, Any]:
        """Отфильтровать домен по белому списку или ручному блоклисту TLD."""

        whitelist = _load_domain_whitelist()
        normalized_value = _normalize_domain_to_punycode(self.value)

        if normalized_value in whitelist:
            _logger.info(
                "Домен %s найден в белом списке до опроса провайдеров", self.value
            )
            return {
                "status": "ok",
                "verdicts": [IndicatorVerdict.IGNORE],
                "reasons": [{"text": DOMAIN_WHITELIST_REASON}],
                "features": {},
            }

        domain_tld = _extract_domain_tld(self.value)
        blocklist = _load_manual_tld_blocklist()

        if domain_tld and domain_tld in blocklist:
            _logger.info(
                "Домен %s попал в ручной блоклист TLD %s до запроса провайдеров",
                self.value,
                domain_tld,
            )
            return {
                "status": "ok",
                "verdicts": [IndicatorVerdict.BLOCK, IndicatorVerdict.MONITOR],
                "reasons": [
                    {"text": MANUAL_TLD_BLOCK_REASON_TEMPLATE.format(tld=domain_tld)}
                ],
                "features": {},
            }

        _logger.info(
            "Для домена %s нет быстрых правил, продолжаем опрос провайдеров", self.value
        )
        return {}

    def analysis(self, features: Mapping[str, Any] | None = None) -> dict[str, Any]:
        """Анализ домена на основе готовых фич провайдеров."""

        prepared_features = features or {}
        top1m_features = prepared_features.get("top1m") or {}
        top1m_rank = top1m_features.get("top1m_rank")
        in_top_1m = top1m_rank is not None
        cdn_substring = "cdn" in self.value.lower()
        kaspersky_features = prepared_features.get("kaspersky") or {}
        whois_features = prepared_features.get("whois") or {}
        kasp_log_files = kaspersky_features.get("kasp_log_files")
        whois_reg_period_days = whois_features.get("whois_reg_period_days")
        cdn_reliable = bool(
            cdn_substring
            and whois_reg_period_days is not None
            and whois_reg_period_days > 365
            and kasp_log_files is not None
            and kasp_log_files > math.log1p(1_000_000)
        )

        _logger.info("Проверяем топ-1М-ранг домена %s", self.value)

        reasons: list[dict[str, Any]] = []
        if top1m_rank is not None and top1m_rank < 100_000:
            _logger.info(
                "Домен %s входит в топ-100к топ-1М списков (ранг %s)",
                self.value,
                top1m_rank,
            )
            early_reasons = reasons + [
                {
                    "text": TOP1M_TOP_100K_REASON_TEMPLATE.format(rank=top1m_rank),
                    "score": 0,
                }
            ]
            return {
                "features": prepared_features,
                "status": "ok",
                "verdicts": [IndicatorVerdict.IGNORE],
                "reasons": early_reasons,
            }

        if top1m_rank is None:
            reasons.append({"text": TOP1M_RANK_MISSING_REASON})
        else:
            reasons.append(
                {"text": TOP1M_RANK_ABOVE_THRESHOLD_REASON_TEMPLATE.format(rank=top1m_rank)}
            )

        top_presence_reason = TOP1M_REASON if in_top_1m else NO_TOP1M_REASON
        reasons.append({"text": top_presence_reason})

        cdn_reason = CDN_SUBSTRING_REASON if cdn_reliable else CDN_SUBSTRING_MISSING_REASON
        reasons.append({"text": cdn_reason})

        threshold = settings.analysis.domain.model_threshold
        _logger.info(
            "Запускаем инференс модели для домена %s с порогом %.3f",
            self.value,
            threshold,
        )
        probability = _predict_domain_probability(prepared_features)
        reasons.append(
            {
                "text": MODEL_PROBABILITY_REASON_TEMPLATE.format(
                    probability=probability, threshold=threshold
                )
            }
        )

        if probability >= threshold:
            if in_top_1m or cdn_substring:
                verdict = {
                    "verdicts": [IndicatorVerdict.BLOCK],
                    "reasons": reasons,
                }
            else:
                verdict = {
                    "verdicts": [IndicatorVerdict.BLOCK, IndicatorVerdict.MONITOR],
                    "reasons": reasons,
                }
        else:
            verdict = {
                "verdicts": [IndicatorVerdict.IGNORE],
                "reasons": reasons,
            }

        return {
            "features": prepared_features,
            "status": "ok",
            **verdict,
        }


def build_patterns(normalize: Callable[[str], str]) -> tuple[PatternDefinition, ...]:
    """Вернуть правила выделения доменных имён."""

    return (
        PatternDefinition(
            name="domain",
            regex=DOMAIN_PATTERN,
            factory=lambda value, kinds: DOMAINIndicator(value=value, kinds=kinds),
            kinds=frozenset({"domain", "domain"}),
            normalize=normalize,
        ),
    )
