"""PDF генератор для Trade-In Estimator — Брусника-style.

Точное соответствие референсу docs/BRUSNIKA_REFERENCE_EKB-2485.pdf.

Структура отчёта — 4 страницы:
  1. Cover         — № отчёта + параметры объекта + 2 диапазона цен + 3 блока «Что важно»
  2. Listings      — рынок квартир-аналогов по объявлениям + таблица примеров
  3. Deals         — фактические сделки + таблица примеров
  4. Offer         — формирование выкупной стоимости (Trade-In vs самопродажа) + 4 преимущества

White-label через Brand: PRINZIP / Практика / generic (см. app/services/brand.py).
Расширения над Брусникой (сохранены): source badges + дистанция + кликабельные ссылки + QR-код.
"""

from __future__ import annotations

import base64
import datetime as dt
import html as _html
import io
import logging
import re
from urllib.parse import urlparse
from uuid import UUID

import segno

from app.core.config import settings
from app.schemas.trade_in import AggregatedEstimate, AnalogLot


def _bar_svg_data_url(q_low: float = 0.20, q_high: float = 0.80) -> str:
    """SVG диапазон-бара как data URI. WeasyPrint надёжно рендерит SVG (как QR-код)."""
    width, height = 600, 32
    x1 = width * q_low
    x2 = width * q_high
    svg = (
        f'<svg xmlns="http://www.w3.org/2000/svg" '
        f'viewBox="0 0 {width} {height}" preserveAspectRatio="none">'
        f'<rect x="0.5" y="0.5" width="{width - 1}" height="{height - 1}" '
        f'fill="#ffffff" stroke="#9ca3af" stroke-width="1"/>'
        f'<rect x="{x1}" y="1" width="{x2 - x1}" height="{height - 2}" fill="#dc2626"/>'
        f"</svg>"
    )
    return f"data:image/svg+xml;base64,{base64.b64encode(svg.encode('utf-8')).decode('ascii')}"


logger = logging.getLogger(__name__)


# ── URL allowlist (C-6 security audit) ─────────────────────────────────────
# Только доверенные домены-источники объявлений попадают в <a href=> в PDF.
# Защита от javascript: / data: инъекций через source_url объявлений.
_ALLOWED_URL_DOMAINS: frozenset[str] = frozenset(
    {
        "www.avito.ru",
        "avito.ru",
        "m.avito.ru",
        "www.cian.ru",
        "cian.ru",
        "realty.yandex.ru",
        "n1.ru",
        "ekaterinburg.n1.ru",
    }
)

# CDN-домены для изображений аналогов (photo_url).
_ALLOWED_PHOTO_CDN: frozenset[str] = frozenset(
    {
        "images.avito.st",
        "cdn-p.cian.site",
        "avatars.mds.yandex.net",
        "n1ru.cdn-cw.com",
    }
)


def _safe_url(url: str | None, *, cdn: bool = False) -> str | None:
    """Проверяет URL по allowlist схемы и домена.

    cdn=False — allowlist для source_url объявлений (ссылки на листинги).
    cdn=True  — allowlist для photo_url (CDN изображений).
    Возвращает None если URL не прошёл проверку — embed/ссылка пропускается.
    """
    if not url:
        return None
    try:
        p = urlparse(url)
    except Exception:
        return None
    if p.scheme != "https":
        return None
    allowed = _ALLOWED_PHOTO_CDN if cdn else _ALLOWED_URL_DOMAINS
    if p.netloc not in allowed:
        return None
    return url


# ── Logo URL и color-sanitation (#13 SSRF / CSS-injection guard) ─────────────
# Допустимые домены для логотипов брендов (white-label).
_ALLOWED_LOGO_CDN: frozenset[str] = frozenset(
    {
        "gk-praktika.ru",
        "www.gk-praktika.ru",
        "prinzip.ru",
        "www.prinzip.ru",
        "cdn.gk-praktika.ru",
    }
)

_COLOR_RE = re.compile(r"^#[0-9A-Fa-f]{6}$")


def _safe_logo_url(url: str | None) -> str | None:
    """Валидирует logo_url бренда: только https:// + allowlist домен + не data:/file:.

    data: URI допускаются для инлайн-логотипов (нет сетевого запроса).
    Всё остальное отбрасывается. Если передан file:// — отбрасывается.
    """
    if not url:
        return None
    try:
        p = urlparse(url)
    except Exception:
        return None
    if p.scheme == "data":
        # data: URI безопасны — нет SSRF, только инлайн
        return url
    if p.scheme != "https":
        # file://, http://, javascript: — все отбрасываем
        return None
    if p.netloc not in _ALLOWED_LOGO_CDN:
        logger.warning("logo_url domain not in allowlist: %s", p.netloc)
        return None
    return url


def _safe_color(color: str | None, fallback: str) -> str:
    """Принимает только #RRGGBB hex. Всё остальное → fallback.

    Защита от CSS-injection через primary_color/accent_color бренда
    (WeasyPrint вставляет значение напрямую в style=).
    """
    if color and _COLOR_RE.match(color):
        return color
    return fallback


# ── WeasyPrint custom url_fetcher (SSRF guard) ───────────────────────────────


def _make_safe_url_fetcher():  # type: ignore[no-untyped-def]
    """Возвращает url_fetcher для WeasyPrint, блокирующий file:// и нелоговые схемы.

    Разрешены: https:// (логотипы + шрифты CDN) и data: (инлайн SVG/PNG).
    Блокированы: file://, http://, ftp:// и любые другие.
    """

    def fetcher(url: str, timeout: int = 10, ssl_context=None):  # type: ignore[no-untyped-def]
        # Импорт отложен до момента первого реального fetch, чтобы тесты могли
        # подменять weasyprint.urls через sys.modules без ModuleNotFoundError.
        from weasyprint.urls import default_url_fetcher

        try:
            p = urlparse(url)
        except Exception:
            raise ValueError(f"Invalid URL in PDF resource: {url!r}") from None
        if p.scheme in ("data", "https"):
            return default_url_fetcher(url, timeout=timeout, ssl_context=ssl_context)
        raise ValueError(
            f"PDF resource scheme '{p.scheme}' is not allowed (only data: and https:). "
            f"URL: {url!r}"
        )

    return fetcher


# ── Source pseudo-logos (текстовые pill-badges как у Брусники с логотипами) ──
_SOURCE_LOGO_COLORS: dict[str, tuple[str, str]] = {
    "avito": ("#00aaff", "#fff"),  # Avito brand blue (упрощённо)
    "cian": ("#0468ff", "#fff"),  # Циан фирменный синий
    "domklik": ("#1ab248", "#fff"),  # ДомКлик зелёный Сбер
    "yandex": ("#fc3f1d", "#fff"),  # Я.Недвижимость красный
    "n1": ("#ff6b00", "#fff"),  # N1 оранжевый
    "rosreestr": ("#003d82", "#fff"),  # Росреестр тёмно-синий
    "etazhi": ("#e30613", "#fff"),  # Этажи красный
}

_SOURCE_DISPLAY_NAMES: dict[str, str] = {
    "avito": "Avito",
    "cian": "Циан",
    "domklik": "Домклик · Сбер",
    "yandex": "Я.Недвижимость",
    "n1": "N1",
    "rosreestr": "Росреестр",
    "etazhi": "Этажи",
}


def _source_logo_pill(source: str) -> str:
    """Pill-badge с цветом источника (заменяет реальные логотипы Брусники)."""
    bg, fg = _SOURCE_LOGO_COLORS.get(source, ("#6b7280", "#fff"))
    name = _SOURCE_DISPLAY_NAMES.get(source, source.title())
    return (
        f"<span style='display:inline-block;padding:3pt 8pt;background:{bg};color:{fg};"
        f"font-size:8pt;font-weight:700;border-radius:3pt;margin-right:4pt;"
        f"vertical-align:middle;'>{_html.escape(name)}</span>"
    )


def _source_badge_inline(source: str | None) -> str:
    """Маленький source badge для table cells (без фона)."""
    if not source:
        return "<span style='color:#9ca3af;'>—</span>"
    bg, fg = _SOURCE_LOGO_COLORS.get(source, ("#6b7280", "#fff"))
    name = _SOURCE_DISPLAY_NAMES.get(source, source.title())
    return (
        f"<span style='display:inline-block;padding:1pt 4pt;background:{bg};color:{fg};"
        f"font-size:6.5pt;font-weight:700;border-radius:2pt;'>{_html.escape(name)}</span>"
    )


# ── Helpers ──────────────────────────────────────────────────────────────────


def _fmt_rub(value: int) -> str:
    """12 500 000 ₽"""
    return f"{value:,}".replace(",", " ") + " ₽"


def _fmt_rub_m(value: int) -> str:
    """3.4 млн. руб."""
    m = value / 1_000_000
    return f"{m:.1f}".replace(".", ",") + " млн. руб."


def _fmt_ppm2(value: int) -> str:
    """101 176"""
    return f"{value:,}".replace(",", " ")


def _report_number(estimate_id: UUID) -> str:
    """Брусника-style № отчёта: 'EKБ-NNNN-XXXXXXX' где NNNN — короткий код."""
    short = int(estimate_id.int) % 10_000
    long = int(estimate_id.int) % 10_000_000_000
    return f"EKБ-{short:04d}-{long:010d}"


def _qr_code_data_url(text: str, size: int = 4) -> str:
    """QR-код как SVG data URL."""
    qr = segno.make(text, error="m")
    buf = io.BytesIO()
    qr.save(buf, kind="svg", scale=size, dark="#1a1d23", light="#ffffff")
    return f"data:image/svg+xml;base64,{base64.b64encode(buf.getvalue()).decode('ascii')}"


def _conf_label(confidence: str) -> str:
    return {"high": "Высокая", "medium": "Средняя", "low": "Низкая"}.get(confidence, confidence)


# ── Price range SVG bar (как у Брусники) ────────────────────────────────────


_BAR_PNG_URL = _bar_svg_data_url()


def _price_range_bar(
    range_low: int,
    range_high: int,
    *,
    label_left: str | None = None,
    label_right: str | None = None,
    sub_label: str = "Рынок",
    days_min: int | None = None,
    days_max: int | None = None,
    show_days: bool = False,
) -> str:
    """Полоска диапазона цен в стиле Брусники — PNG через Pillow."""
    if range_high <= range_low:
        return ""

    label_left = label_left or _fmt_rub_m(range_low)
    label_right = label_right or _fmt_rub_m(range_high)

    # Ц-таблица: 3 ячейки фиксированной ширины с background-color на td.
    # Высота через padding (WeasyPrint stable с padding на td).
    days_row = ""
    if show_days and days_min is not None and days_max is not None:
        days_row = (
            f"<tr><td></td>"
            f'<td style="text-align:left;font-size:8pt;color:#dc2626;font-weight:700;'
            f'padding-top:3pt;">'
            f"{days_min} дней</td>"
            f'<td style="text-align:right;font-size:8pt;color:#dc2626;font-weight:700;'
            f'padding-top:3pt;">'
            f"{days_max} дней</td>"
            f"<td></td></tr>"
        )

    return (
        f'<table style="width:100%;border-collapse:collapse;margin:6pt 0;">'
        f'<tr><td colspan="4" style="text-align:center;font-size:9pt;color:#6b7280;'
        f'padding-bottom:3pt;">'
        f"{_html.escape(sub_label)}"
        f"</td></tr>"
        f'<tr style="line-height:0;">'
        f'<td style="width:1%;border:1pt solid #9ca3af;border-right:none;'
        f'background:#ffffff;padding:6pt 0;"></td>'
        f'<td style="width:60%;border-top:1pt solid #9ca3af;border-bottom:1pt solid #9ca3af;'
        f'background:#dc2626;padding:6pt 0;"></td>'
        f'<td style="width:1%;border:1pt solid #9ca3af;border-left:none;'
        f'background:#ffffff;padding:6pt 0;"></td>'
        f'<td style="width:38%;background:transparent;padding:6pt 0;"></td>'
        f"</tr>"
        f'<tr><td colspan="2" style="text-align:left;font-size:10pt;font-weight:700;'
        f'color:#374151;padding-top:3pt;">'
        f"{_html.escape(label_left)}</td>"
        f'<td colspan="2" style="text-align:right;font-size:10pt;font-weight:700;'
        f'color:#374151;padding-top:3pt;">'
        f"{_html.escape(label_right)}</td></tr>"
        f"{days_row}"
        f"</table>"
    )


# ── Dual price headline: «Ориентир запроса» + «Ожидаемая цена продажи» ───────
# Зеркалит web HeroSummary (#648 S5a): две равновесные цифры. Asking — медиана
# по объявлениям; expected_sold — реалистичная цена сделки (asking × ratio из
# asking_to_sold_ratios, migration 080). Graceful: при отсутствии sold (None/0 —
# старые оценки / пустой бакет ratio-таблицы) рисуем ТОЛЬКО asking-колонку.


def _discount_pct(estimate: AggregatedEstimate) -> int | None:
    """Скидка запрос→продажа в % (как web): из asking_to_sold_ratio при наличии,
    иначе из самих чисел sold/median. None если sold нет или скидка < 1%."""
    sold = estimate.expected_sold_price_rub
    if not sold or sold <= 0:
        return None
    ratio = estimate.asking_to_sold_ratio
    if ratio is not None and ratio > 0:
        pct = round((1 - ratio) * 100)
    elif estimate.median_price_rub > 0:
        pct = round((1 - sold / estimate.median_price_rub) * 100)
    else:
        return None
    return pct if pct >= 1 else None


def _price_figure_cell(
    *,
    label: str,
    value_rub: int,
    range_low: int | None,
    range_high: int | None,
    per_m2: int | None,
    accent: str,
    delta_pct: int | None = None,
) -> str:
    """Одна ценовая колонка (label + крупное число + диапазон + ₽/м²).

    Каждое под-поле (диапазон / ₽/м²) рендерится только при валидном значении —
    как web (guard на typeof number). delta_pct — опциональный chip «−N%»."""
    delta_chip = ""
    if delta_pct is not None:
        delta_chip = (
            f"<span style='display:inline-block;margin-left:6pt;padding:1pt 6pt;"
            f"background:{accent};color:#fff;font-size:8pt;font-weight:700;"
            f"border-radius:8pt;vertical-align:middle;'>&#8722;{delta_pct}%</span>"
        )
    range_html = ""
    if range_low is not None and range_high is not None and range_high > range_low:
        range_html = (
            f"<div style='font-size:10pt;color:#6b7280;font-weight:600;margin-top:3pt;"
            f"font-variant-numeric:tabular-nums;'>"
            f"{_html.escape(_fmt_rub_m(range_low))} &#8211; {_html.escape(_fmt_rub_m(range_high))}"
            f"</div>"
        )
    perm2_html = ""
    if per_m2 is not None and per_m2 > 0:
        perm2_html = (
            f"<div style='font-size:9pt;color:#9ca3af;margin-top:2pt;"
            f"font-variant-numeric:tabular-nums;'>{_fmt_ppm2(per_m2)} &#8381;/м²</div>"
        )
    return (
        f"<td style='width:50%;vertical-align:top;padding:10pt 12pt;background:#f9fafb;"
        f"border-top:2pt solid {accent};border-radius:4pt;'>"
        f"<div style='font-size:9pt;color:#6b7280;font-weight:700;text-transform:uppercase;"
        f"letter-spacing:0.03em;'>{_html.escape(label)}{delta_chip}</div>"
        f"<div style='font-size:18pt;font-weight:900;color:#1a1d23;margin-top:4pt;"
        f"font-variant-numeric:tabular-nums;'>{_html.escape(_fmt_rub_m(value_rub))}</div>"
        f"{range_html}{perm2_html}"
        f"</td>"
    )


def _dual_price_block(estimate: AggregatedEstimate, brand) -> str:  # type: ignore[no-untyped-def]
    """Две равновесные цены: «Ориентир запроса» и «Ожидаемая цена продажи».

    Graceful: если expected_sold_price_rub None/0 — одиночная колонка (asking),
    explainer/chip скрыты (текущее поведение PDF для старых оценок)."""
    accent = brand.primary_color if brand else "#1d4ed8"
    sold = estimate.expected_sold_price_rub
    has_sold = bool(sold) and sold > 0

    asking_cell = _price_figure_cell(
        label="Ориентир запроса",
        value_rub=estimate.median_price_rub,
        range_low=estimate.range_low_rub,
        range_high=estimate.range_high_rub,
        per_m2=estimate.median_price_per_m2,
        accent=accent,
    )

    if not has_sold:
        # Одиночная колонка — растягиваем на всю ширину (graceful fallback).
        single = asking_cell.replace("width:50%", "width:100%", 1)
        return (
            f'<table style="width:100%;border-collapse:separate;border-spacing:0;'
            f'margin:8pt 0 4pt 0;"><tr>{single}</tr></table>'
        )

    sold_cell = _price_figure_cell(
        label="Ожидаемая цена продажи",
        value_rub=sold,
        range_low=estimate.expected_sold_range_low_rub,
        range_high=estimate.expected_sold_range_high_rub,
        per_m2=estimate.expected_sold_per_m2,
        accent="#dc2626",
        delta_pct=_discount_pct(estimate),
    )

    explainer = (
        '<p style="margin:6pt 0 4pt 0;font-size:8.5pt;color:#6b7280;line-height:1.4;">'
        "<strong>Запрос</strong> — по чему выставлены сопоставимые квартиры в объявлениях. "
        "<strong>Ожидаемая цена продажи</strong> — реалистичная цена сделки по данным ДКП "
        "Росреестра, обычно ниже запроса."
        "</p>"
    )

    return (
        f'<table style="width:100%;border-collapse:separate;border-spacing:8pt 0;'
        f'margin:8pt 0 0 0;"><tr>{asking_cell}{sold_cell}</tr></table>'
        f"{explainer}"
    )


# ── Page 1: Cover ────────────────────────────────────────────────────────────


def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str:  # type: ignore[no-untyped-def,type-arg]
    today = dt.date.today()
    # Используем реальное поле expires_at из оценки; fallback на +30 дней если None.
    expires = (
        estimate.expires_at.date()
        if estimate.expires_at is not None
        else today + dt.timedelta(days=30)
    )
    report_num = _report_number(estimate.estimate_id)

    # Короткий адрес (для cover): берём первую часть до запятой
    full_address = input_snapshot.get("address", "—")
    address_short = full_address.split(",")[0:3]
    address_short = ", ".join(s.strip() for s in address_short)
    address = _html.escape(address_short or full_address)
    area = input_snapshot.get("area_m2", 0)
    rooms = input_snapshot.get("rooms", 0)
    floor = input_snapshot.get("floor") or "—"
    total_floors = input_snapshot.get("total_floors") or "—"
    year_built = input_snapshot.get("year_built", "—")
    house_type = input_snapshot.get("house_type")
    repair_state = input_snapshot.get("repair_state")
    has_balcony = input_snapshot.get("has_balcony")

    house_labels = {
        "panel": "Панельный",
        "brick": "Кирпичный",
        "monolith": "Монолитный",
        "monolith_brick": "Монолит-кирпич",
        "other": "Другое",
    }
    repair_labels = {
        "needs_repair": "Требуется ремонт",
        "standard": "Стандартный",
        "good": "Хороший",
        "excellent": "Евроремонт",
    }

    rooms_label = (
        "Студия"
        if rooms == 0
        else f"{rooms} комнат" + ("а" if rooms == 1 else "ы" if 2 <= rooms <= 4 else "")
    )
    house_label = house_labels.get(house_type, "—") if house_type else "—"
    repair_label = repair_labels.get(repair_state, "Не указано") if repair_state else "Не указано"
    balcony_label = "Есть" if has_balcony else "Нет" if has_balcony is False else "—"

    # Active market subband — 4-118 days range (как у Брусники).
    # Если есть days_on_market в analogs — берём min/max, иначе фиксированно.
    days_min, days_max = _days_on_market_range(estimate.analogs)

    # Deals range — если deals есть, считаем; иначе fallback к listings range
    deals_low, deals_high = _deals_range(
        estimate.actual_deals, fallback=(estimate.range_low_rub, estimate.range_high_rub)
    )

    listings_bar = _price_range_bar(
        estimate.range_low_rub,
        estimate.range_high_rub,
        sub_label="Активный рынок с аналогичным состоянием ремонта",
        days_min=days_min,
        days_max=days_max,
        show_days=True,
    )

    deals_bar = _price_range_bar(
        deals_low,
        deals_high,
        sub_label="Диапазон цен по фактическим сделкам",
    )

    logo_url = _safe_logo_url(brand.logo_url)
    logo_html = (
        f"<img src='{_html.escape(logo_url)}' alt='{_html.escape(brand.name)}' "
        f"style='max-height:28pt;max-width:120pt;object-fit:contain;vertical-align:middle;'/>"
        if logo_url
        else f"<span style='font-size:12pt;font-weight:900;letter-spacing:0.06em;'>"
        f"{_html.escape(brand.name).upper()}</span>"
    )
    disclaimer_html = ""
    if brand.pdf_disclaimer:
        disclaimer_html = (
            f"<p style='margin-top:10pt;font-size:7.5pt;color:#6b7280;line-height:1.4;"
            f"border-top:1pt solid #e6e8ec;padding-top:6pt;'>"
            f"{_html.escape(brand.pdf_disclaimer)}</p>"
        )

    return f"""
<div style="page-break-after:always;">

  <!-- Top brand bar — компактный -->
  <div style="background:{brand.primary_color};color:#fff;padding:8pt 14pt;margin-bottom:8pt;">
    {logo_html}
  </div>

  <h1 style="font-size:12pt;font-weight:700;margin:4pt 0 8pt 0;text-transform:uppercase;
             letter-spacing:0.02em;line-height:1.25;">
    АНАЛИЗ РЫНКА И РАСЧЕТ ВЫКУПНОЙ СТОИМОСТИ КВАРТИРЫ
  </h1>

  <!-- Параметры квартиры — table как у Брусники -->
  <table class="params-table" style="width:100%;border-collapse:collapse;margin-bottom:14pt;">
    <tr><td>№ отчета</td><td class="bold">{report_num}</td></tr>
    <tr><td>Дата отчета</td><td class="bold">{today.strftime("%d.%m.%Y")}</td></tr>
    <tr><td>Срок действия данных</td><td class="bold">до {expires.strftime("%d.%m.%Y")}</td></tr>
    <tr><td>Адрес</td><td class="bold">{address}</td></tr>
    <tr><td>Год постройки</td><td class="bold">{year_built}</td></tr>
    <tr><td>Тип дома</td><td class="bold">{house_label}</td></tr>
    <tr><td>Этаж / этажность</td><td class="bold">{floor} / {total_floors}</td></tr>
    <tr><td>Площадь</td><td class="bold">{area} м²</td></tr>
    <tr><td>Планировка</td><td class="bold">{rooms_label}</td></tr>
    <tr><td>Состояние ремонта</td><td class="bold">{repair_label}</td></tr>
    <tr><td>Балкон / лоджия</td><td class="bold">{balcony_label}</td></tr>
  </table>

  <!-- Две равновесные цены: ориентир запроса vs ожидаемая цена продажи (#648 S5b) -->
  {_dual_price_block(estimate, brand)}

  <p style="margin:10pt 0 4pt 0;font-size:10pt;font-weight:700;">
    Диапазон цен в объявлениях
    <span style="font-weight:400;font-size:9pt;color:#6b7280;">(без учета ремонта)</span>
  </p>
  {listings_bar}

  <p style="margin:10pt 0 4pt 0;font-size:10pt;font-weight:700;">
    Диапазон цен по фактическим сделкам
  </p>
  {deals_bar}

  <!-- Что важно при оценке -->
  <h3 style="margin-top:10pt;font-size:10pt;font-weight:700;">
    Что важно при оценке стоимости квартиры:
  </h3>
  <table class="advice-table" style="width:100%;border-collapse:collapse;margin-top:8pt;">
    <tr>
      <td class="advice-title">Цены в объявлениях — ожидания собственников</td>
      <td class="advice-text">Фактические сделки проходят на 5–12% ниже, что подтверждают
        Росреестр, ДомКлик и продажи агенств недвижимости</td>
    </tr>
    <tr>
      <td class="advice-title">Ремонт оценивается по состоянию, а не по вложенным суммам</td>
      <td class="advice-text">Инвестиции в 400 – 600 тыс. руб. повышают цену объекта всего
        на 150 – 250 тыс. руб — покупатель оценивает общее состояние квартиры</td>
    </tr>
    <tr>
      <td class="advice-title">Неочевидные расходы при самостоятельной продаже</td>
      <td class="advice-text">При самостоятельной продаже суммарные расходы могут достигать
        до 15% стоимости квартиры (торг, риелтор, нотариус, справки)</td>
    </tr>
  </table>

  <p style="margin-top:18pt;font-size:8pt;color:#6b7280;border-top:1pt solid #e6e8ec;
            padding-top:8pt;">
    <strong>Этот отчёт онлайн:</strong> {settings.public_url}?id={estimate.estimate_id}
  </p>
  {disclaimer_html}

</div>
"""


def _days_on_market_range(lots: list[AnalogLot]) -> tuple[int, int]:
    """Min/max days_on_market по аналогам. Fallback 4-118 как у Брусники."""
    days = [lot.days_on_market for lot in lots if lot.days_on_market is not None]
    if not days:
        return 4, 118
    return min(days), max(days)


def _deals_range(deals: list[AnalogLot], fallback: tuple[int, int]) -> tuple[int, int]:
    """Min/max price от deals. Fallback к listings range."""
    if not deals:
        return fallback
    prices = [d.price_rub for d in deals]
    return min(prices), max(prices)


# ── Page 2: Listings (market) ────────────────────────────────────────────────


def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str:  # type: ignore[no-untyped-def,type-arg]
    n_total = estimate.n_analogs
    # #1531: убрана строка-дубль «(с учётом ремонта)». Estimator НЕ фильтрует
    # аналоги по repair_state (coverage listings.repair_state ~2%, см. estimator.py:160),
    # а лишь применяет ценовой коэффициент к медиане/диапазону — поэтому отдельного
    # count «с учётом ремонта» не существует, второе число было идентично n_total.

    # Source logos (pseudo) — берём из estimate.sources_used (не захардкоженный список).
    sources_to_show = estimate.sources_used or []
    sources_html = "".join(_source_logo_pill(s) for s in sources_to_show[:5])

    # Params правой колонки — параметры поиска (НЕ конкретной квартиры)
    area = float(input_snapshot.get("area_m2", 0) or 0)
    area_min = round(area * 0.85, 1)
    area_max = round(area * 1.15, 1)
    rooms = input_snapshot.get("rooms", 0)
    year_built = input_snapshot.get("year_built")
    year_range = f"{year_built - 2}-{year_built + 2}" if year_built else "—"
    house_type = input_snapshot.get("house_type")
    house_label = (
        {
            "panel": "Эконом до 2000 года",
            "brick": "Кирпич до 2000",
            "monolith": "Современный (монолит)",
            "monolith_brick": "Современный (м-к)",
            "other": "Любой",
        }.get(house_type, "Любой")
        if house_type
        else "Любой"
    )
    repair_state = input_snapshot.get("repair_state")
    repair_label = (
        {
            "needs_repair": "Требуется ремонт",
            "standard": "Стандартный",
            "good": "Хороший",
            "excellent": "Евроремонт",
        }.get(repair_state, "Любой")
        if repair_state
        else "Любой"
    )
    rooms_label = "Студия" if rooms == 0 else f"{rooms} комнаты"

    # Полоска диапазона
    days_min, days_max = _days_on_market_range(estimate.analogs)
    range_bar = _price_range_bar(
        estimate.range_low_rub,
        estimate.range_high_rub,
        sub_label="Рынок",
        days_min=days_min,
        days_max=days_max,
        show_days=True,
    )

    # Топ-5 примеров (отсортированных по distance)
    top5 = sorted(estimate.analogs, key=lambda x: x.distance_m or 9999)[:5]
    examples_rows = _examples_rows(top5)

    return f"""
<div style="page-break-after:always;">

  <div class="brand-bar-small" style="border-bottom:2pt solid {brand.primary_color};
       padding:6pt 0;margin-bottom:12pt;">
    <span style="font-size:11pt;font-weight:900;letter-spacing:0.06em;color:{brand.primary_color};">
      {_html.escape(brand.name).upper()}
    </span>
  </div>

  <h2 style="font-size:13pt;font-weight:700;text-transform:uppercase;letter-spacing:0.02em;
             border-bottom:1.5pt solid #e6e8ec;padding-bottom:4pt;margin-bottom:12pt;">
    РЫНОК КВАРТИР – АНАЛОГОВ ПО ОБЪЯВЛЕНИЯМ
  </h2>

  <!-- Два колонки: левая (counts + источники), правая (params) -->
  <table style="width:100%;border-collapse:collapse;margin-bottom:14pt;">
    <tr>
      <td style="width:55%;vertical-align:top;padding-right:14pt;">
        <table style="width:100%;border-collapse:collapse;">
          <tr><td style="padding:4pt 0;">Количество объявлений по аналогичным объектам</td>
              <td class="bold" style="text-align:right;">{n_total} шт.</td></tr>
        </table>
        <div style="margin-top:14pt;font-size:9pt;color:#6b7280;">Источники данных</div>
        <div style="margin-top:6pt;">{sources_html}</div>
      </td>
      <td style="width:45%;vertical-align:top;background:#f9fafb;padding:10pt;border-radius:4pt;">
        <table class="search-params" style="width:100%;border-collapse:collapse;">
          <tr><td>Расположение</td><td class="bold">± 1 км от локации дома</td></tr>
          <tr><td>Тип дома</td><td class="bold">{house_label}</td></tr>
          <tr><td>Год постройки</td><td class="bold">{year_range}</td></tr>
          <tr><td>Диапазон площади</td><td class="bold">{area_min} - {area_max} м²</td></tr>
          <tr><td>Планировка</td><td class="bold">{rooms_label}</td></tr>
          <tr><td>Состояние ремонта</td><td class="bold">{repair_label}</td></tr>
        </table>
      </td>
    </tr>
  </table>

  <p style="margin:8pt 0 4pt 0;font-size:10pt;font-weight:700;">Диапазон цен в объявлениях</p>
  {range_bar}

  <!-- Примеры аналогов -->
  <h3 style="margin-top:16pt;font-size:11pt;font-weight:700;">
    Примеры аналогичных объектов с учётом ремонта
  </h3>
  <table class="examples-table" style="width:100%;border-collapse:collapse;margin-top:6pt;">
    <thead>
      <tr style="border-bottom:2pt solid #e6e8ec;">
        <th style="text-align:left;padding:6pt 4pt;font-size:9pt;color:#6b7280;
            font-weight:700;">Адрес квартиры</th>
        <th style="text-align:left;padding:6pt 4pt;font-size:9pt;color:#6b7280;
            font-weight:700;">Источник</th>
        <th style="text-align:right;padding:6pt 4pt;font-size:9pt;color:#6b7280;
            font-weight:700;">Стоимость 1 м², руб.</th>
        <th style="text-align:right;padding:6pt 4pt;font-size:9pt;color:#6b7280;
            font-weight:700;">Стоимость объекта, руб.</th>
        <th style="text-align:right;padding:6pt 4pt;font-size:9pt;color:#6b7280;
            font-weight:700;">Срок экспозиции, дней</th>
      </tr>
    </thead>
    <tbody>{examples_rows}</tbody>
  </table>

  <div class="footer" style="margin-top:20pt;padding-top:6pt;border-top:1px solid #e6e8ec;
       font-size:7pt;color:#9ca3af;">
    {_html.escape(brand.name)} · Анализ рынка вторичной недвижимости · стр. 2
  </div>

</div>
"""


def _examples_rows(lots: list[AnalogLot]) -> str:
    if not lots:
        return (
            "<tr><td colspan='5' class='empty' "
            "style='text-align:center;padding:14pt;color:#9ca3af;'>Нет данных</td></tr>"
        )
    rows = []
    for lot in lots:
        addr = _html.escape(lot.address)
        safe = _safe_url(lot.source_url)
        if safe:
            addr_cell = (
                f"<a href='{_html.escape(safe)}' "
                f"style='color:#1d4ed8;text-decoration:none;'>{addr} ↗</a>"
            )
        else:
            addr_cell = addr
        ppm2_cell = _fmt_ppm2(lot.price_per_m2) if lot.price_per_m2 is not None else "—"
        price_cell = _fmt_ppm2(lot.price_rub) if lot.price_rub is not None else "—"
        # #1533: 0 — валидное значение (лот размещён сегодня), `0 or '—'` теряло бы его.
        days_cell = lot.days_on_market if lot.days_on_market is not None else "—"
        rows.append(
            "<tr>"
            f"<td style='padding:7pt 4pt;border-bottom:1px solid #f3f4f6;'>{addr_cell}</td>"
            f"<td style='padding:7pt 4pt;border-bottom:1px solid #f3f4f6;'>"
            f"{_source_badge_inline(lot.source)}</td>"
            f"<td style='padding:7pt 4pt;border-bottom:1px solid #f3f4f6;text-align:right;"
            f"font-variant-numeric:tabular-nums;'>{ppm2_cell}</td>"
            f"<td style='padding:7pt 4pt;border-bottom:1px solid #f3f4f6;text-align:right;"
            f"font-variant-numeric:tabular-nums;'>{price_cell}</td>"
            f"<td style='padding:7pt 4pt;border-bottom:1px solid #f3f4f6;text-align:right;"
            f"font-variant-numeric:tabular-nums;'>{days_cell}</td>"
            "</tr>"
        )
    return "".join(rows)


# ── Page 3: Deals ────────────────────────────────────────────────────────────


def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str:  # type: ignore[no-untyped-def,type-arg]
    n_deals = len(estimate.actual_deals)
    today = dt.date.today()
    period_start = today - dt.timedelta(days=estimate.period_months * 30)

    # Источники для сделок — берём из estimate.sources_used (не захардкоженный список).
    # Фильтруем по известным источникам сделок; fallback к пустому (не fabricate).
    _deal_source_keys = {"etazhi", "domklik", "rosreestr"}
    deal_sources = [s for s in (estimate.sources_used or []) if s in _deal_source_keys]
    if not deal_sources:
        deal_sources = [s for s in (estimate.sources_used or [])]
    sources_html = "".join(_source_logo_pill(s) for s in deal_sources[:5])

    area = float(input_snapshot.get("area_m2", 0) or 0)
    area_min = round(area * 0.85, 1)
    area_max = round(area * 1.15, 1)
    rooms = input_snapshot.get("rooms", 0)
    year_built = input_snapshot.get("year_built")
    year_range = f"{year_built - 2}–{year_built + 2}" if year_built else "—"
    house_type = input_snapshot.get("house_type")
    # #1532: маппинг идентичен странице «Листинги» (включая monolith_brick/other),
    # иначе один и тот же «Тип дома» рассинхронизирован между стр.2 и стр.3.
    house_label = (
        {
            "panel": "Эконом до 2000 года",
            "brick": "Кирпич до 2000",
            "monolith": "Современный (монолит)",
            "monolith_brick": "Современный (м-к)",
            "other": "Любой",
        }.get(house_type, "Любой")
        if house_type
        else "Любой"
    )
    rooms_label = "Студия" if rooms == 0 else f"{rooms} комнаты"

    deals_low, deals_high = _deals_range(
        estimate.actual_deals, fallback=(estimate.range_low_rub, estimate.range_high_rub)
    )
    # #1366: НЕ показываем срок экспозиции для бара сделок. actual_deals — это
    # зарегистрированные ДКП Росреестра без days_on_market (поле None), поэтому
    # _days_on_market_range вернул бы хардкод-fallback (4, 118) — выдуманные сроки
    # под заголовком «Диапазон цен по фактическим сделкам». Cover-страница так же
    # намеренно не рисует days для бара сделок (show_days по умолчанию False).
    range_bar = _price_range_bar(
        deals_low,
        deals_high,
        sub_label="Рынок",
    )

    top5 = estimate.actual_deals[:5]
    examples_rows = _examples_rows(top5)

    return f"""
<div style="page-break-after:always;">

  <div class="brand-bar-small" style="border-bottom:2pt solid {brand.primary_color};
       padding:6pt 0;margin-bottom:12pt;">
    <span style="font-size:11pt;font-weight:900;letter-spacing:0.06em;color:{brand.primary_color};">
      {_html.escape(brand.name).upper()}
    </span>
  </div>

  <h2 style="font-size:13pt;font-weight:700;text-transform:uppercase;letter-spacing:0.02em;
             border-bottom:1.5pt solid #e6e8ec;padding-bottom:4pt;margin-bottom:12pt;">
    ФАКТИЧЕСКИЕ СДЕЛКИ ПО КВАРТИРАМ — АНАЛОГАМ
  </h2>

  <table style="width:100%;border-collapse:collapse;margin-bottom:14pt;">
    <tr>
      <td style="width:55%;vertical-align:top;padding-right:14pt;">
        <table style="width:100%;border-collapse:collapse;">
          <tr><td style="padding:4pt 0;">Количество сделок по аналогичном объектам</td>
              <td class="bold" style="text-align:right;">{n_deals} шт.</td></tr>
          <tr><td style="padding:4pt 0;">Период сделок</td>
              <td class="bold" style="text-align:right;">
                {period_start.strftime("%m.%Y")} – {today.strftime("%m.%Y")}</td></tr>
        </table>
        <div style="margin-top:14pt;font-size:9pt;color:#6b7280;">Источники данных</div>
        <div style="margin-top:6pt;">{sources_html}</div>
      </td>
      <td style="width:45%;vertical-align:top;background:#f9fafb;padding:10pt;border-radius:4pt;">
        <table class="search-params" style="width:100%;border-collapse:collapse;">
          <tr><td>Расположение</td><td class="bold">± 1 км от локации дома</td></tr>
          <tr><td>Тип дома</td><td class="bold">{house_label}</td></tr>
          <tr><td>Год постройки</td><td class="bold">{year_range}</td></tr>
          <tr><td>Диапазон площади</td><td class="bold">{area_min} - {area_max} м²</td></tr>
          <tr><td>Планировка</td><td class="bold">{rooms_label}</td></tr>
        </table>
      </td>
    </tr>
  </table>

  <p style="margin:8pt 0 4pt 0;font-size:10pt;font-weight:700;">
    Диапазон цен по фактическим сделкам</p>
  {range_bar}

  <div style="margin-top:14pt;padding:10pt 14pt;border-left:3pt solid #dc2626;
       background:#fff5f5;font-size:10pt;color:#7f1d1d;font-weight:600;">
    По данным реальных сделок, квартиры продаются в среднем на 5–12 % дешевле,
    чем заявлено в объявлениях
  </div>

  <h3 style="margin-top:16pt;font-size:11pt;font-weight:700;">
    Примеры аналогичных объектов
  </h3>
  <table class="examples-table" style="width:100%;border-collapse:collapse;margin-top:6pt;">
    <thead>
      <tr style="border-bottom:2pt solid #e6e8ec;">
        <th style="text-align:left;padding:6pt 4pt;font-size:9pt;color:#6b7280;
            font-weight:700;">Адрес квартиры</th>
        <th style="text-align:left;padding:6pt 4pt;font-size:9pt;color:#6b7280;
            font-weight:700;">Источник</th>
        <th style="text-align:right;padding:6pt 4pt;font-size:9pt;color:#6b7280;
            font-weight:700;">Стоимость 1 м², руб.</th>
        <th style="text-align:right;padding:6pt 4pt;font-size:9pt;color:#6b7280;
            font-weight:700;">Стоимость объекта, руб.</th>
        <th style="text-align:right;padding:6pt 4pt;font-size:9pt;color:#6b7280;
            font-weight:700;">Срок экспозиции, дней</th>
      </tr>
    </thead>
    <tbody>{examples_rows}</tbody>
  </table>

  <div class="footer" style="margin-top:20pt;padding-top:6pt;border-top:1px solid #e6e8ec;
       font-size:7pt;color:#9ca3af;">
    {_html.escape(brand.name)} · Анализ рынка вторичной недвижимости · стр. 3
  </div>

</div>
"""


# ── Page 4: Offer (Trade-In vs Самопродажа) ──────────────────────────────────


def _build_offer_page(estimate: AggregatedEstimate, brand) -> str:  # type: ignore[no-untyped-def]
    median = estimate.median_price_rub

    # Расчёт расходов как у Брусники: на основе медианы
    listing_price = median  # цена в объявлении
    torg_pct_low, torg_pct_high = 5, 15
    torg_low = int(listing_price * torg_pct_low / 100)
    torg_high = int(listing_price * torg_pct_high / 100)

    # После торга — средний по диапазону для остальных %
    sold_price = listing_price - int(listing_price * 0.10)  # ~10% торг

    rieltor_pct_low, rieltor_pct_high = 2, 3
    rieltor_low = int(sold_price * rieltor_pct_low / 100)
    rieltor_high = int(sold_price * rieltor_pct_high / 100)

    rent_low, rent_high = 90_000, 150_000  # 3 месяца аренды (фикс)
    juridical_low = 19_250  # фикс
    ads_low, ads_high = 4_000, 36_000  # за 3 месяца

    total_low = torg_low + rieltor_low + rent_low + juridical_low + ads_low
    total_high = torg_high + rieltor_high + rent_high + juridical_low + ads_high

    brand_short = _html.escape(brand.name)
    trade_in_label = f"{brand_short}.Обмен" if brand.slug != "generic" else "Trade-In"

    return f"""
<div><!-- last page: NO page-break-after -->

  <div class="brand-bar-small" style="border-bottom:2pt solid {brand.primary_color};
       padding:6pt 0;margin-bottom:12pt;">
    <span style="font-size:11pt;font-weight:900;letter-spacing:0.06em;color:{brand.primary_color};">
      {brand_short.upper()}
    </span>
  </div>

  <h2 style="font-size:13pt;font-weight:700;text-transform:uppercase;letter-spacing:0.02em;
             border-bottom:1.5pt solid #e6e8ec;padding-bottom:4pt;margin-bottom:14pt;">
    ФОРМИРОВАНИЕ ВЫКУПНОЙ СТОИМОСТИ
  </h2>

  <h3 style="font-size:11pt;font-weight:700;margin-bottom:8pt;">Структура стоимости сделки</h3>

  <table class="offer-table" style="width:100%;border-collapse:collapse;">
    <thead>
      <tr style="border-bottom:2pt solid #e6e8ec;">
        <th style="text-align:left;padding:8pt 4pt;font-size:9pt;color:#6b7280;
            font-weight:700;"></th>
        <th style="text-align:right;padding:8pt 4pt;font-size:9pt;color:#6b7280;
            font-weight:700;">
          Продажа через<br>{trade_in_label}
        </th>
        <th style="text-align:right;padding:8pt 4pt;font-size:9pt;color:#6b7280;
            font-weight:700;">
          Самостоятельная продажа, руб.
        </th>
      </tr>
    </thead>
    <tbody>
      <tr style="border-bottom:1px solid #f3f4f6;">
        <td style="padding:6pt 4pt;">
          <div class="bold">Торг потенциальных покупателей</div>
          <div style="font-size:8pt;color:#6b7280;">при стоимости квартиры в объявлении
            {_fmt_rub(listing_price)}</div>
        </td>
        <td style="padding:6pt 4pt;text-align:right;color:#6b7280;">Не применимо</td>
        <td style="padding:6pt 4pt;text-align:right;font-weight:700;color:#dc2626;">
          от {_fmt_rub(torg_low)} – {_fmt_rub(torg_high)}<br>
          <span style="font-size:8pt;color:#6b7280;font-weight:400;">от
            {torg_pct_low}–{torg_pct_high}%</span>
        </td>
      </tr>
      <tr style="border-bottom:1px solid #f3f4f6;">
        <td style="padding:6pt 4pt;">
          <div class="bold">Услуги риэлтора при продаже квартиры</div>
          <div style="font-size:8pt;color:#6b7280;">с минимальным торгом за
            {_fmt_rub(sold_price)}</div>
        </td>
        <td style="padding:6pt 4pt;text-align:right;color:#15803d;font-weight:700;">бесплатно</td>
        <td style="padding:6pt 4pt;text-align:right;font-weight:700;">
          {_fmt_rub(rieltor_low)} - {_fmt_rub(rieltor_high)}<br>
          <span style="font-size:8pt;color:#6b7280;font-weight:400;">от
            {rieltor_pct_low}–{rieltor_pct_high}%</span>
        </td>
      </tr>
      <tr style="border-bottom:1px solid #f3f4f6;">
        <td style="padding:6pt 4pt;">
          <div class="bold">Аренда после сделки</div>
          <div style="font-size:8pt;color:#6b7280;">однокомнатной квартиры на 3 месяца</div>
        </td>
        <td style="padding:6pt 4pt;text-align:right;color:#15803d;font-weight:700;">бесплатно</td>
        <td style="padding:6pt 4pt;text-align:right;font-weight:700;">
          {_fmt_rub(rent_low)} - {_fmt_rub(rent_high)}
        </td>
      </tr>
      <tr style="border-bottom:1px solid #f3f4f6;">
        <td style="padding:6pt 4pt;">
          <div class="bold">Юридическое сопровождение</div>
          <div style="font-size:8pt;color:#6b7280;">Проверка документов и подготовка договоров</div>
        </td>
        <td style="padding:6pt 4pt;text-align:right;color:#15803d;font-weight:700;">бесплатно</td>
        <td style="padding:6pt 4pt;text-align:right;font-weight:700;">
          от {_fmt_rub(juridical_low)}</td>
      </tr>
      <tr style="border-bottom:2pt solid #e6e8ec;">
        <td style="padding:6pt 4pt;">
          <div class="bold">Расходы на рекламу</div>
          <div style="font-size:8pt;color:#6b7280;">Ежемесячное базовой продвижение объекта
            на Циан, Авито, Я.Недвижимости</div>
        </td>
        <td style="padding:6pt 4pt;text-align:right;color:#15803d;font-weight:700;">бесплатно</td>
        <td style="padding:6pt 4pt;text-align:right;font-weight:700;">
          от {_fmt_rub(ads_low)} - {_fmt_rub(ads_high)}<br>
          <span style="font-size:8pt;color:#6b7280;font-weight:400;">за 3 месяца</span>
        </td>
      </tr>
      <tr style="background:#fef3c7;">
        <td style="padding:8pt 4pt;font-size:11pt;font-weight:700;">Общие финансовые потери</td>
        <td style="padding:8pt 4pt;text-align:right;"></td>
        <td style="padding:8pt 4pt;text-align:right;font-size:11pt;font-weight:700;
            color:#7f1d1d;">
          от {_fmt_rub(total_low)} - {_fmt_rub(total_high)}
        </td>
      </tr>
    </tbody>
  </table>

  <div style="margin-top:14pt;padding:10pt 14pt;border-left:3pt solid #dc2626;
       background:#fff5f5;font-size:10pt;color:#7f1d1d;font-weight:600;">
    Издержки при обмене сопоставимы с личными расходами собственника, которые возникают при
    самостоятельной продаже квартиры
  </div>

  <h3 style="margin-top:18pt;font-size:11pt;font-weight:700;">Преимущества</h3>
  <div class="advantages" style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;
       gap:8pt;margin-top:8pt;">
    <div class="advantage-item">
      <div class="adv-ic">
        <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#b91c1c"
             stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
          <circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-9.6 9.6"/>
          <path d="m15.5 7.5 3 3L22 7l-3-3"/>
        </svg>
      </div>
      <div class="adv-title">Экономия времени</div>
      <div class="adv-desc">Берём на себя показы, переговоры и поиск покупателей</div>
    </div>
    <div class="advantage-item">
      <div class="adv-ic">
        <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#b91c1c"
             stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
          <path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
          <polyline points="9 22 9 12 15 12 15 22"/>
        </svg>
      </div>
      <div class="adv-title">Юридическая безопасность</div>
      <div class="adv-desc">Проверяем документы, исключаем риски, сопровождаем на всех этапах</div>
    </div>
    <div class="advantage-item">
      <div class="adv-ic">
        <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#b91c1c"
             stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
          <line x1="19" y1="5" x2="5" y2="19"/><circle cx="6.5" cy="6.5" r="2.5"/>
          <circle cx="17.5" cy="17.5" r="2.5"/>
        </svg>
      </div>
      <div class="adv-title">Фиксированная стоимость новостройки</div>
      <div class="adv-desc">Сохраняем цену выбранной планировки в {brand_short}</div>
    </div>
    <div class="advantage-item">
      <div class="adv-ic">
        <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#b91c1c"
             stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
          <circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/>
          <circle cx="12" cy="12" r="2"/>
        </svg>
      </div>
      <div class="adv-title">Гарантия цены</div>
      <div class="adv-desc">Снимаем угрозу колебаний рынка и удерживаем договорную стоимость</div>
    </div>
  </div>

  <div class="footer" style="margin-top:20pt;padding-top:6pt;border-top:1px solid #e6e8ec;
       font-size:7pt;color:#9ca3af;">
    {_html.escape(brand.name)} · Анализ рынка вторичной недвижимости · стр. 4 ·
    Расчёт носит ориентировочный характер и не является офертой.
  </div>

</div>
"""


# ── CSS ──────────────────────────────────────────────────────────────────────


def _build_css(brand=None) -> str:  # type: ignore[no-untyped-def]
    primary = brand.primary_color if brand else "#1d4ed8"
    return f"""
@page {{
    size: A4;
    margin: 20mm 18mm 20mm 18mm;
}}
* {{ box-sizing: border-box; }}
body {{
    font-family: 'Helvetica', 'Arial', sans-serif;
    font-size: 10pt;
    color: #1a1d23;
    margin: 0;
    padding: 0;
    line-height: 1.4;
}}
h1, h2, h3 {{ margin: 0; }}
.page {{ page-break-after: always; position: relative; }}
.page:last-child {{ page-break-after: avoid; }}

.bold {{ font-weight: 700; }}

/* Params table (Cover) */
.params-table td {{
    padding: 2.5pt 4pt;
    border-bottom: 1px solid #f3f4f6;
    font-size: 9pt;
}}
.params-table td:first-child {{
    color: #6b7280;
    width: 40%;
}}
.params-table td:last-child {{
    text-align: right;
}}

/* Search params (Listings/Deals) */
.search-params td {{
    padding: 4pt 0;
    font-size: 9pt;
}}
.search-params td:first-child {{
    color: #6b7280;
}}
.search-params td:last-child {{
    text-align: right;
}}

/* Range block */
.range-block {{
    margin-top: 6pt;
}}

/* Range bar — linear-gradient на одном div'е. WeasyPrint reliable. */
.range-bar-strip {{
    width: 100%;
    height: 16pt;
    line-height: 14pt;
    border: 1pt solid #9ca3af;
    background: linear-gradient(
        to right,
        #ffffff 0%, #ffffff 20%,
        #dc2626 20%, #dc2626 80%,
        #ffffff 80%, #ffffff 100%
    );
    margin: 4pt 0;
    overflow: hidden;
    color: transparent;
    font-size: 1pt;
}}
.range-title {{
    font-size: 10pt;
    font-weight: 700;
    color: #1a1d23;
    margin-bottom: 4pt;
}}

/* Что важно — advice table */
.advice-table td {{
    padding: 5pt 6pt;
    border-bottom: 1px solid #f3f4f6;
    vertical-align: top;
    font-size: 8.5pt;
    line-height: 1.3;
}}
.advice-title {{
    font-weight: 700;
    width: 30%;
    color: #1a1d23;
}}
.advice-text {{
    color: #4b5563;
}}

/* Advantages (Offer) */
.advantage-item {{
    border: 1px solid #e6e8ec;
    border-radius: 4pt;
    padding: 8pt;
    text-align: left;
}}
.advantage-item .adv-ic {{
    display: block;
    width: 28pt;
    height: 28pt;
    border-radius: 50%;
    background: #fef2f2;
    margin: 0 0 6pt 0;
    line-height: 28pt;
    text-align: center;
}}
.advantage-item .adv-ic svg {{
    vertical-align: middle;
}}
.advantage-item .adv-title {{
    font-weight: 700;
    font-size: 8.5pt;
    margin-bottom: 3pt;
    color: {primary};
    letter-spacing: -0.01em;
}}
.advantage-item .adv-desc {{
    font-size: 7pt;
    color: #5b6066;
    line-height: 1.3;
}}
"""


# ── Public API ───────────────────────────────────────────────────────────────


def _build_insufficient_data_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str:  # type: ignore[no-untyped-def,type-arg]
    """Страница-заглушка для случаев, когда данных недостаточно (#697/#9).

    Рендерится вместо числового отчёта при estimate.insufficient_data=True.
    Предотвращает публикацию «0,0 млн» и fabricated таблиц потерь.
    """
    address = _html.escape(input_snapshot.get("address", "—"))
    report_num = _report_number(estimate.estimate_id)
    today = dt.date.today()
    return f"""
<div>
  <div style="background:{brand.primary_color};color:#fff;padding:8pt 14pt;margin-bottom:8pt;
              font-size:12pt;font-weight:900;letter-spacing:0.06em;">
    {_html.escape(brand.name).upper()}
  </div>
  <p style="font-size:9pt;color:#6b7280;margin:0 0 18pt 0;">
    № {report_num} · {today.strftime("%d.%m.%Y")} · {address}
  </p>
  <div style="margin:40pt auto;text-align:center;max-width:320pt;">
    <div style="font-size:32pt;margin-bottom:12pt;">—</div>
    <h2 style="font-size:14pt;font-weight:700;color:#1a1d23;margin-bottom:8pt;">
      Недостаточно данных для оценки
    </h2>
    <p style="font-size:10pt;color:#6b7280;line-height:1.5;">
      По данному объекту не найдено достаточного количества аналогов
      для формирования надёжной рыночной оценки.
      Пожалуйста, уточните параметры или обратитесь к специалисту.
    </p>
  </div>
</div>
"""


def generate_trade_in_pdf(
    estimate: AggregatedEstimate,
    input_snapshot: dict,  # type: ignore[type-arg]
    *,
    brand=None,  # type: ignore[no-untyped-def]
) -> bytes:
    """Генерирует WeasyPrint PDF в стиле Брусника.Обмен.

    Если estimate.insufficient_data=True — рендерит одностраничный empty-state
    вместо числового отчёта («Недостаточно данных», #697/#9).

    Pages (normal):
      1. Cover    — № отчёта + параметры + 2 диапазона + 3 блока «Что важно»
      2. Listings — рынок аналогов + источники + таблица примеров
      3. Deals    — фактические сделки + источники + таблица
      4. Offer    — Trade-In vs самопродажа + 4 преимущества

    Args:
        estimate: AggregatedEstimate из БД
        input_snapshot: ввод пользователя (address, area_m2, ...)
        brand: optional Brand для white-label

    Returns:
        PDF bytes для Response(media_type="application/pdf")
    """
    # WeasyPrint импортируется лениво: его native-зависимости (pango/cairo) нужны
    # только для финального write_pdf — HTML-билдеры (cover/listings/...) работают
    # без них, что делает рендер-функции тестируемыми в окружениях без native libs.
    from weasyprint import CSS, HTML

    from app.services.brand import Brand

    if brand is None:
        brand = Brand(
            slug="generic",
            name="Trade-In",
            logo_url=None,
            primary_color="#1d4ed8",
            accent_color="#f59e0b",
            footer_text=None,
            pdf_disclaimer=None,
        )

    # Sanitize brand colors before inserting into CSS/style attributes (#13).
    brand = Brand(
        slug=brand.slug,
        name=brand.name,
        logo_url=brand.logo_url,
        primary_color=_safe_color(brand.primary_color, "#1d4ed8"),
        accent_color=_safe_color(brand.accent_color, "#f59e0b"),
        footer_text=brand.footer_text,
        pdf_disclaimer=brand.pdf_disclaimer,
    )

    # #697/#9: insufficient data → empty-state page, NOT fabricated 0-median report.
    if estimate.insufficient_data:
        body_html = _build_insufficient_data_page(estimate, input_snapshot, brand)
    else:
        body_html = (
            _build_cover(estimate, input_snapshot, brand)
            + _build_listings_page(estimate, input_snapshot, brand)
            + _build_deals_page(estimate, input_snapshot, brand)
            + _build_offer_page(estimate, brand)
        )

    html_str = (
        '<!DOCTYPE html><html lang="ru"><head><meta charset="UTF-8">'
        f"<title>Trade-In — {_html.escape(input_snapshot.get('address', ''))}</title>"
        f"</head><body>{body_html}</body></html>"
    )
    css_str = _build_css(brand=brand)
    # SSRF guard (#13): кастомный url_fetcher блокирует file:// и нелоговые схемы.
    # base_url=None — не resolve никакие относительные URL (все ресурсы data: или https:).
    pdf_bytes = HTML(string=html_str, base_url=None).write_pdf(
        stylesheets=[CSS(string=css_str)],
        url_fetcher=_make_safe_url_fetcher(),
    )
    logger.info(
        "PDF generated estimate_id=%s brand=%s size=%d insufficient_data=%s",
        estimate.estimate_id,
        brand.slug,
        len(pdf_bytes),
        estimate.insufficient_data,
    )
    return pdf_bytes
