"""Простые утилиты для ML-пайплайна HTTP-провайдеров."""

from __future__ import annotations

import json
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Mapping

import numpy as np
import pandas as pd
from pandas.api.types import is_bool_dtype
from catboost import CatBoostClassifier, Pool
from sklearn.metrics import precision_recall_curve, roc_curve

logger = logging.getLogger(__name__)


@dataclass
class DecisionThreshold:
    """Описывает оптимальный порог и связанный показатель качества."""

    название: str
    порог: float
    точность: float
    полнота: float
    метрика: float
    tpr: float | None = None
    fpr: float | None = None


def parse_raw_payload(raw_payload: Any) -> Mapping[str, Any]:
    """Преобразует сырое значение ответа в словарь."""

    if isinstance(raw_payload, Mapping):
        return raw_payload
    if isinstance(raw_payload, str):
        try:
            return json.loads(raw_payload)
        except json.JSONDecodeError:
            logger.warning("Не удалось распарсить JSON ответа: %s", raw_payload[:128])
            return {}
    return {}


def ensure_directory(path: Path) -> None:
    """Создаёт директорию при её отсутствии."""

    path.mkdir(parents=True, exist_ok=True)


def export_dataframe_stats(dataframe: pd.DataFrame, output_path: Path) -> None:
    """Сохраняет статистики по каждому признаку датафрейма в файл."""

    ensure_directory(output_path.parent)

    lines: list[str] = []
    total_rows = len(dataframe)
    for column in dataframe.columns:
        series = dataframe[column]
        non_null = series.notna().sum()
        missing = total_rows - non_null
        unique = series.nunique(dropna=False)

        lines.append(f"Признак: {column}")
        lines.append(f"  Тип: {series.dtype}")
        lines.append(f"  Непустых: {non_null} из {total_rows}, пропусков: {missing}")
        lines.append(f"  Уникальных значений: {unique}")

        if pd.api.types.is_bool_dtype(series):
            true_count = int((series == True).sum())  # noqa: E712
            false_count = int((series == False).sum())  # noqa: E712
            lines.append(
                "  Булевы частоты: True={true}, False={false}, доля True={share:.4f}".format(
                    true=true_count,
                    false=false_count,
                    share=true_count / non_null if non_null else 0.0,
                )
            )
        elif pd.api.types.is_numeric_dtype(series):
            desc = series.describe(percentiles=[0.25, 0.5, 0.75, 0.9, 0.99])
            lines.append(
                "  Статистики: count={count}, mean={mean:.4f}, std={std:.4f}, min={min}, max={max}".format(
                    count=int(desc.get("count", 0)),
                    mean=float(desc.get("mean", 0) or 0),
                    std=float(desc.get("std", 0) or 0),
                    min=desc.get("min", 0),
                    max=desc.get("max", 0),
                )
            )
            quantiles = {
                q: desc.get(str(q), None) for q in ["25%", "50%", "75%", "90%", "99%"]
            }
            quantile_parts = [
                f"{label}={value}"
                for label, value in quantiles.items()
                if value is not None
            ]
            if quantile_parts:
                lines.append("  Квантили: " + ", ".join(quantile_parts))

            top_values = series.value_counts(dropna=False).head(5)
            if not top_values.empty:
                lines.append("  Топ значений (частота):")
                for value, count in top_values.items():
                    freq = count / total_rows if total_rows else 0
                    lines.append(f"    {value!r}: {count} ({freq:.4f})")
        else:
            top_values = series.value_counts(dropna=False).head(5)
            if not top_values.empty:
                lines.append("  Топ значений:")
                for value, count in top_values.items():
                    lines.append(f"    {value!r}: {count}")

        lines.append("")

    output_path.write_text("\n".join(lines), encoding="utf-8")
    logger.info("Статистики по датафрейму сохранены в %s", output_path)


def log_model_metrics(model: CatBoostClassifier, test_pool: Pool) -> dict[str, float]:
    """Логирует и возвращает метрики качества CatBoost."""

    metrics = model.eval_metrics(
        test_pool,
        metrics=["AUC", "Accuracy", "Precision", "Recall", "F1"],
        ntree_end=model.tree_count_,
    )
    if not metrics:
        logger.warning("Метрики не получены для модели CatBoost")
        return {}

    summary = {name: values[-1] if values else None for name, values in metrics.items()}
    logger.info(
        "Качество модели на тесте: AUC=%.4f, Accuracy=%.4f, Precision=%.4f, Recall=%.4f, F1=%.4f",
        summary.get("AUC", 0.0) or 0.0,
        summary.get("Accuracy", 0.0) or 0.0,
        summary.get("Precision", 0.0) or 0.0,
        summary.get("Recall", 0.0) or 0.0,
        summary.get("F1", 0.0) or 0.0,
    )
    return {name: value or 0.0 for name, value in summary.items()}


def find_optimal_thresholds(
    *,
    истинные_метки: np.ndarray,
    вероятности_позитивного_класса: np.ndarray,
) -> list[DecisionThreshold]:
    """Подбирает оптимальные пороги по F1 и индексу Юдена."""

    precision, recall, pr_thresholds = precision_recall_curve(
        истинные_метки, вероятности_позитивного_класса
    )
    найденные: list[DecisionThreshold] = []

    if pr_thresholds.size:
        f1_scores = (
            2
            * precision[:-1]
            * recall[:-1]
            / (precision[:-1] + recall[:-1] + 1e-12)
        )
        best_f1_index = int(np.argmax(f1_scores))
        найденные.append(
            DecisionThreshold(
                название="Максимум F1",
                порог=float(pr_thresholds[best_f1_index]),
                точность=float(precision[best_f1_index]),
                полнота=float(recall[best_f1_index]),
                метрика=float(f1_scores[best_f1_index]),
            )
        )

    fpr, tpr, roc_thresholds = roc_curve(
        истинные_метки, вероятности_позитивного_класса
    )
    if roc_thresholds.size:
        youden_scores = tpr - fpr
        best_youden_index = int(np.argmax(youden_scores))
        найденные.append(
            DecisionThreshold(
                название="Максимум индекса Юдена",
                порог=float(roc_thresholds[best_youden_index]),
                точность=float(1 - fpr[best_youden_index]),
                полнота=float(tpr[best_youden_index]),
                метрика=float(youden_scores[best_youden_index]),
                tpr=float(tpr[best_youden_index]),
                fpr=float(fpr[best_youden_index]),
            )
        )

    for threshold in найденные:
        logger.info(
            "Рекомендованный порог (%s): %.4f | метрика=%.4f, точность=%.4f, полнота=%.4f",
            threshold.название,
            threshold.порог,
            threshold.метрика,
            threshold.точность,
            threshold.полнота,
        )
    return найденные


def log_feature_importance(
    model: CatBoostClassifier, feature_names: list[str]
) -> list[tuple[str, float]]:
    """Возвращает отсортированную значимость признаков и выводит топ-10."""

    importances = model.get_feature_importance(type="FeatureImportance")
    ranked = sorted(
        zip(feature_names, importances), key=lambda item: item[1], reverse=True
    )
    top_features = ranked[:10]
    logger.info("Топ значимых признаков (первые 10):")
    for name, score in top_features:
        logger.info("%s: %.4f", name, score)
    return ranked


def write_training_report(
    output_path: Path,
    metrics: dict[str, float],
    feature_importance: list[tuple[str, float]],
    pruned_metrics: dict[str, float] | None = None,
    pruned_feature_importance: list[tuple[str, float]] | None = None,
    thresholds: list[DecisionThreshold] | None = None,
    pruned_thresholds: list[DecisionThreshold] | None = None,
) -> None:
    """Сохраняет метрики и значимость признаков в файл."""

    lines = ["Отчёт по обучению модели", ""]
    if metrics:
        lines.append("Метрики на тестовом наборе (полный набор признаков):")
        for name, value in metrics.items():
            lines.append(f"  {name}: {value:.4f}")
        lines.append("")

    if pruned_metrics is not None:
        lines.append("Метрики после отсеивания малозначимых признаков:")
        for name, value in pruned_metrics.items():
            baseline_value = metrics.get(name, 0.0)
            delta = value - baseline_value
            lines.append(f"  {name}: {value:.4f} (Δ {delta:+.4f})")
        lines.append("")

    lines.append("Значимость признаков (полный набор):")
    for name, score in feature_importance:
        lines.append(f"  {name}: {score:.6f}")

    if pruned_feature_importance is not None:
        lines.append("")
        lines.append("Значимость признаков (усечённый набор):")
        for name, score in pruned_feature_importance:
            lines.append(f"  {name}: {score:.6f}")

    if thresholds is not None:
        lines.append("")
        lines.append("Рекомендованные пороги для полного набора признаков:")
        for threshold in thresholds:
            lines.append(
                "  {label}: порог={threshold:.4f}, точность={precision:.4f}, "
                "полнота={recall:.4f}, метрика={score:.4f}".format(
                    label=threshold.название,
                    threshold=threshold.порог,
                    precision=threshold.точность,
                    recall=threshold.полнота,
                    score=threshold.метрика,
                )
            )

    if pruned_thresholds is not None:
        lines.append("")
        lines.append("Рекомендованные пороги после отбора признаков:")
        for threshold in pruned_thresholds:
            lines.append(
                "  {label}: порог={threshold:.4f}, точность={precision:.4f}, "
                "полнота={recall:.4f}, метрика={score:.4f}".format(
                    label=threshold.название,
                    threshold=threshold.порог,
                    precision=threshold.точность,
                    recall=threshold.полнота,
                    score=threshold.метрика,
                )
            )

    output_path.write_text("\n".join(lines), encoding="utf-8")
    logger.info("Отчёт об обучении сохранён в %s", output_path)


def select_features_by_importance(
    feature_importance: list[tuple[str, float]],
    *,
    threshold: float = 0.0,
    percentile: float | None = None,
    max_features: int | None = None,
) -> tuple[list[str], float]:
    """Возвращает отсортированный список признаков и применённый порог важности."""

    if not feature_importance:
        return [], threshold

    scores = [score for _, score in feature_importance]
    applied_threshold = threshold

    if percentile is not None:
        safe_percentile = max(0.0, min(100.0, percentile))
        applied_threshold = float(np.percentile(scores, safe_percentile))
        logger.info(
            "Динамический порог важности: %.2f-й процентиль = %.6f",
            safe_percentile,
            applied_threshold,
        )
    else:
        logger.info("Статический порог важности: %.6f", applied_threshold)

    filtered = [
        name for name, score in feature_importance if score >= applied_threshold
    ]
    if max_features:
        filtered = filtered[:max_features]
    return filtered, applied_threshold
