"""Роутинг RU-вопроса в intent + сборка шаблонного RU-ответа (#957, Step 1, БЕЗ LLM).

ДЕТЕРМИНИРОВАННО:
  • route_intent — явный intent (если задан) ИЛИ keyword/regex-матч RU-вопроса; нет
    совпадения → ChatIntent.unknown.
  • render_answer — тянет нужную(ые) секцию(и) `SiteFinderReport.as_dict()` и собирает
    краткий RU-ответ с числами движка ВЕРБАТИМ (числа берутся ИЗ отчёта, НИКОГДА не
    фабрикуются). Возвращает (answer_text, sections_used).

ROBUST к частичному/пустому отчёту (отчёт может быть тонким — каждая секция Optional):
никаких KeyError — отсутствует секция → честно говорим «данных по X в отчёте нет», НЕ
выдумываем. Микрокопирайт: RU, нейтральный бизнес-тон (ui-microcopy.md), `₽/м²` с
пробелами, тысячи через пробел, без emoji/«Готово!». Каждый ответ завершается advisory-
оговоркой (отчёт советующий, не основание для инвест-решения).
"""

from __future__ import annotations

import math
import re
from typing import Any

from app.schemas.chat import ChatIntent

# Неразрывный пробел — разделитель тысяч (зеркало toLocaleString("ru"), ui-microcopy.md:
# '6 832 540'). Явная константа, чтобы separator не зависел от способа ввода в исходнике.
_NBSP = " "

# advisory-оговорка в конце КАЖДОГО ответа (зеркало report.advisory). Полное
# предложение, не аббревиатура (ui-microcopy.md).
_ADVISORY_NOTE = (
    "Отчёт носит советующий характер и не является основанием для инвестиционного решения."
)

# Сообщение, когда конкретной секции в отчёте нет (честный graceful, не фабрикуем).
_NO_SECTION_TMPL = "Данных по разделу «{name}» в отчёте нет."

# RU-уровни уверенности (movement из low/medium/high в человекочитаемое).
_CONFIDENCE_RU = {
    "high": "высокая",
    "medium": "средняя",
    "low": "низкая",
}

# RU-метки сценариев.
_SCENARIO_RU = {
    "conservative": "консервативный",
    "base": "базовый",
    "aggressive": "агрессивный",
}

# RU-метки ключей §25 спец-индексов (для intent=risks).
_INDEX_RU = {
    "launch_window": "окно запуска",
    "product_void": "продуктовое «белое пятно»",
    "cannibalization": "каннибализация портфеля",
    "competitor_strength": "сила конкурентов",
    "artificial_demand": "искусственный (ипотечный) спрос",
    "cost_of_error": "цена ошибки выбора продукта",
}

# ── Keyword-роутинг intent'ов ───────────────────────────────────────────────────
# Каждый intent → список regex'ов по строчному RU-вопросу. Порядок проверки задан
# _INTENT_ORDER (specific → general): «почему такой прогноз» матчим РАНЬШЕ голого
# «прогноз», иначе why_forecast перехватится более общим правилом.
_INTENT_PATTERNS: dict[ChatIntent, list[re.Pattern[str]]] = {
    ChatIntent.what_to_build: [
        re.compile(r"что\s+(тут|здесь|строить|возвод)"),
        re.compile(r"какой\s+продукт"),
        re.compile(r"квартирограф|нарезк[аеуи]\b"),
        re.compile(r"какой\s+класс"),
        re.compile(r"\bтз\b|техзадан"),
    ],
    ChatIntent.why_forecast: [
        re.compile(r"почему\s+(такой|так)\b"),
        re.compile(r"на\s+чём\s+основан"),
        re.compile(r"откуда\s+(такой|эти|цифр|прогноз)"),
        re.compile(r"чем\s+обоснован"),
        re.compile(r"(почему|обоснуй).{0,20}прогноз"),
    ],
    ChatIntent.risks: [
        re.compile(r"риск"),
        re.compile(r"опасн|угроз"),
        re.compile(r"каннибал"),
        re.compile(r"подводн|что\s+может\s+пойти"),
        re.compile(r"спец.?индекс|индекс[ыаов]\b"),
    ],
    ChatIntent.scenarios: [
        re.compile(r"сценари"),
        re.compile(r"консерватив|агрессивн|базов"),
        re.compile(r"разброс|варианты\s+развит"),
    ],
    ChatIntent.summary: [
        re.compile(r"резюме|итог|кратк|сводк|вывод"),
        re.compile(r"вердикт|общая\s+картин"),
        re.compile(r"расскажи\s+про\s+участ|стоит\s+ли"),
    ],
}

# Порядок проверки: более СПЕЦИФИЧНЫЕ intent'ы раньше общих.
_INTENT_ORDER: tuple[ChatIntent, ...] = (
    ChatIntent.why_forecast,
    ChatIntent.what_to_build,
    ChatIntent.risks,
    ChatIntent.scenarios,
    ChatIntent.summary,
)


def route_intent(message: str, explicit: ChatIntent | None) -> ChatIntent:
    """Определить intent: явный (приоритет) ИЛИ keyword-матч; нет совпадения → unknown.

    Явный intent уважаем как есть (фронт/клиент знает лучше). Иначе нормализуем текст
    в нижний регистр и идём по _INTENT_ORDER (specific → general): первый сматчившийся
    pattern выигрывает. Ничего не сматчилось → ChatIntent.unknown (отдадим меню тем).
    """
    if explicit is not None:
        return explicit

    text = message.lower().strip()
    if not text:
        return ChatIntent.unknown

    for intent in _INTENT_ORDER:
        for pattern in _INTENT_PATTERNS[intent]:
            if pattern.search(text):
                return intent
    return ChatIntent.unknown


# ── Хелперы форматирования (RU-микрокопирайт) ────────────────────────────────────


def _fmt_thousands(value: float | int) -> str:
    """Целое с разделителем-NBSP тысяч ('6 832 540', зеркало ru-locale). PURE."""
    return f"{round(value):,}".replace(",", _NBSP)


def _fmt_number(value: Any) -> str | None:
    """Привести число к RU-строке (тысячи NBSP), не-числа → str() или None.

    None → None (вызывающий пропустит поле). int → тысячи NBSP. float: целозначный →
    тысячи NBSP; дробный → запятая-десятичный БЕЗ потери точности (число из отчёта уже
    округлено движком — НЕ пере-округляем, лишь меняем '.'→','). Прочее (готовая строка-
    метка) → как есть.
    """
    if value is None:
        return None
    if isinstance(value, bool):  # bool — подтип int, не форматируем как число
        return str(value)
    if isinstance(value, int):
        return _fmt_thousands(value)
    if isinstance(value, float):
        if not math.isfinite(value):  # NaN/Inf — не число: честно пропускаем (как None),
            return None  # иначе в RU-прозу утёк бы англ. литерал 'nan'/'inf' (#1585)
        if value == int(value):
            return _fmt_thousands(value)
        # Точность отчёта сохраняем: repr float'а → '.'→','. (0.31 → '0,31').
        return repr(value).replace(".", ",")
    return str(value)


def _confidence_ru(level: Any) -> str | None:
    """RU-метка уровня уверенности или None (если нет/неизвестный). PURE."""
    if not isinstance(level, str):
        return None
    return _CONFIDENCE_RU.get(level.lower())


def _assemble(lines: list[str]) -> str:
    """Склеить непустые строки-абзацы в ответ + advisory-оговорка в конце. PURE.

    Пустые/None-строки выкидываем (graceful: пропущенные поля не оставляют дыр).
    advisory-оговорка добавляется ВСЕГДА последней строкой.
    """
    body = [ln for ln in lines if ln]
    body.append(_ADVISORY_NOTE)
    return "\n".join(body)


# ── Рендереры по секциям ─────────────────────────────────────────────────────────


def _render_summary(report: dict[str, Any]) -> tuple[str, list[str]]:
    """summary → exec_summary (headline / verdict / key_numbers / overall_confidence)."""
    section = report.get("exec_summary")
    if not isinstance(section, dict):
        return _assemble([_NO_SECTION_TMPL.format(name="резюме")]), []

    lines: list[str] = []
    headline = section.get("headline")
    if headline:
        lines.append(f"Вердикт: {headline}")
    verdict = section.get("verdict")
    if verdict:
        lines.append(str(verdict))

    key_numbers = section.get("key_numbers")
    if isinstance(key_numbers, dict) and key_numbers:
        parts = []
        for label, raw in key_numbers.items():
            shown = _fmt_number(raw)
            if shown is not None:
                parts.append(f"{label}: {shown}")
        if parts:
            lines.append("Ключевые числа — " + "; ".join(parts) + ".")

    conf = _confidence_ru(section.get("overall_confidence"))
    if conf:
        lines.append(f"Общая уверенность: {conf}.")

    if not lines:
        lines.append("В отчёте пока нет заполненного резюме по участку.")
    return _assemble(lines), ["exec_summary"]


def _render_what_to_build(report: dict[str, Any]) -> tuple[str, list[str]]:
    """what_to_build → product_tz (класс / mix / коммерция / USP) + exec_summary.headline."""
    section = report.get("product_tz")
    sections_used: list[str] = []
    lines: list[str] = []

    exec_summary = report.get("exec_summary")
    if isinstance(exec_summary, dict) and exec_summary.get("headline"):
        lines.append(f"Вердикт: {exec_summary['headline']}")
        sections_used.append("exec_summary")

    if not isinstance(section, dict):
        lines.append(_NO_SECTION_TMPL.format(name="рекомендация продукта"))
        return _assemble(lines), sections_used

    sections_used.append("product_tz")

    obj_class = section.get("obj_class")
    if obj_class:
        lines.append(f"Рекомендованный класс: {obj_class}.")

    mix = section.get("mix")
    if isinstance(mix, list) and mix:
        lines.append(f"Квартирография проработана ({len(mix)} форматов).")

    commercial = section.get("commercial")
    if isinstance(commercial, dict) and commercial:
        lines.append("Заложен коммерческий блок (см. раздел продукта).")

    usp = section.get("usp")
    if isinstance(usp, list) and usp:
        lines.append(f"Выделено USP-преимуществ: {len(usp)}.")

    summary = section.get("summary")
    if summary:
        lines.append(str(summary))

    if not any(
        section.get(k) for k in ("obj_class", "mix", "commercial", "usp", "summary")
    ):
        lines.append("Раздел рекомендации продукта в отчёте пуст.")
    return _assemble(lines), sections_used


def _render_why_forecast(report: dict[str, Any]) -> tuple[str, list[str]]:
    """why_forecast → future_market (по горизонтам + future-supply) + product_tz.reasons +
    confidence.rationale."""
    sections_used: list[str] = []
    lines: list[str] = []

    future = report.get("future_market")
    if isinstance(future, dict):
        future_emitted = False  # секцию в provenance кладём только если выведена строка (#1630)
        horizons = future.get("forecasts_by_horizon")
        if isinstance(horizons, list) and horizons:
            lines.append(f"Прогноз построен по {len(horizons)} горизонтам спроса/предложения.")
            future_emitted = True
        future_supply = future.get("future_supply")
        if isinstance(future_supply, dict) and future_supply:
            lines.append("Учтено давление будущего предложения (выходящие проекты).")
            future_emitted = True
        summary = future.get("summary")
        if summary:
            lines.append(str(summary))
            future_emitted = True
        if future_emitted:
            sections_used.append("future_market")
    else:
        lines.append(_NO_SECTION_TMPL.format(name="будущий рынок"))

    product = report.get("product_tz")
    if isinstance(product, dict):
        reasons = product.get("reasons")
        if isinstance(reasons, list) and reasons:
            lines.append(f"Рекомендация опирается на {len(reasons)} обоснований (§16).")
            sections_used.append("product_tz")

    confidence = report.get("confidence")
    if isinstance(confidence, dict):
        rationale = confidence.get("rationale")
        if rationale:
            lines.append(f"Обоснование уверенности: {rationale}")
            sections_used.append("confidence")

    if not lines or all(ln.startswith("Данных по") for ln in lines):
        lines.append("Детали обоснования прогноза в отчёте пока не заполнены.")
    return _assemble(lines), sections_used


def _render_risks(report: dict[str, Any]) -> tuple[str, list[str]]:
    """risks → scoring.special_indices (§25, вкл. каннибализацию) + confidence (уровень +
    тянущие факторы)."""
    sections_used: list[str] = []
    lines: list[str] = []

    scoring = report.get("scoring")
    special = scoring.get("special_indices") if isinstance(scoring, dict) else None
    if isinstance(special, dict):
        indices = special.get("indices")
        if isinstance(indices, dict) and indices:
            sections_used.append("scoring")
            parts = []
            for key, idx in indices.items():
                if not isinstance(idx, dict):
                    continue
                name = _INDEX_RU.get(key, key)
                value = idx.get("value")
                label = idx.get("label")
                if value is None and not label:
                    continue  # индекс не посчитан (тонкие данные) — пропускаем честно
                shown = label if label else _fmt_number(value)
                if shown is not None:
                    parts.append(f"{name}: {shown}")
            if parts:
                lines.append("Специальные индексы §25 — " + "; ".join(parts) + ".")
        if not lines:
            lines.append("Специальные индексы §25 в отчёте не посчитаны (тонкие данные).")
    else:
        lines.append(_NO_SECTION_TMPL.format(name="специальные индексы §25"))

    confidence = report.get("confidence")
    if isinstance(confidence, dict):
        level = _confidence_ru(confidence.get("level"))
        if level:
            lines.append(f"Уверенность отчёта: {level}.")
            sections_used.append("confidence")
        factors = confidence.get("factors")
        if isinstance(factors, dict) and factors:
            lines.append(f"Факторы, влияющие на уверенность: {len(factors)}.")
            if "confidence" not in sections_used:
                sections_used.append("confidence")

    return _assemble(lines), sections_used


def _render_scenarios(report: dict[str, Any]) -> tuple[str, list[str]]:
    """scenarios → scenarios (by_scenario + summary)."""
    section = report.get("scenarios")
    if not isinstance(section, dict):
        return _assemble([_NO_SECTION_TMPL.format(name="сценарии")]), []

    lines: list[str] = []
    by_scenario = section.get("by_scenario")
    if isinstance(by_scenario, dict) and by_scenario:
        names: list[str] = [_SCENARIO_RU.get(str(k), str(k)) for k in by_scenario]
        lines.append("Проработаны сценарии: " + ", ".join(names) + ".")
    else:
        lines.append("Разбивка по сценариям в отчёте не заполнена.")

    summary = section.get("summary")
    if summary:
        lines.append(str(summary))
    return _assemble(lines), ["scenarios"]


def _render_unknown(report: dict[str, Any]) -> tuple[str, list[str]]:
    """unknown → graceful RU-меню поддерживаемых типов вопросов (секции не трогаем)."""
    menu = _assemble(
        [
            "Я отвечаю по форсайт-отчёту участка. Могу помочь с такими вопросами:",
            "• что здесь строить и какой продукт рекомендуется;",
            "• почему такой прогноз и на чём он основан;",
            "• какие риски и специальные индексы (включая каннибализацию);",
            "• как различаются сценарии (консервативный / базовый / агрессивный);",
            "• краткое резюме и общий вердикт по участку.",
        ]
    )
    return menu, []


# Диспетч intent → рендерер. unknown не зависит от отчёта.
_RENDERERS = {
    ChatIntent.summary: _render_summary,
    ChatIntent.what_to_build: _render_what_to_build,
    ChatIntent.why_forecast: _render_why_forecast,
    ChatIntent.risks: _render_risks,
    ChatIntent.scenarios: _render_scenarios,
    ChatIntent.unknown: _render_unknown,
}


def render_answer(intent: ChatIntent, report: dict[str, Any]) -> tuple[str, list[str]]:
    """Собрать RU-ответ под intent из секции(й) отчёта → (answer_text, sections_used).

    Числа берутся ВЕРБАТИМ из `report` (никогда не фабрикуются). ROBUST к частичному
    отчёту: отсутствует секция → честное «данных по X нет», без KeyError. Ответ всегда
    заканчивается advisory-оговоркой (см. _assemble). Неизвестный intent → меню тем.
    """
    renderer = _RENDERERS.get(intent, _render_unknown)
    return renderer(report)
