"""PDF-генератор для Trade-In Estimator — собственная визуальная система GenDesign/Mera.

Дизайн-токены (цвет / HUD-хром: header/footer/CSS) портированы из РЕАЛЬНОЙ Mera v2
HUD/blueprint-идентичности — canonical source:
tradein-mvp/frontend/src/components/trade-in/v2/tokens.ts (+ Footer.tsx/HeroBar.tsx/
ObjectSummary.tsx для composition-паттернов). Два предыдущих раунда редизайна
(PR #2327, #2427) ошибочно опирались на trade-in.css/tradein.html — старую
"PRINZIP"-брендированную внутреннюю демку, НЕ настоящую Mera-идентичность; этот
модуль на неё больше не ссылается (см. _PAGE_BG и соседние константы ниже).
Типографика — PT Serif (заголовки страниц тела отчёта, только жирное начертание),
PT Sans (текст/таблицы тела отчёта) — оба встроены в PDF как base64 @font-face
(SIL OFL 1.1, см. app/services/exporters/fonts/), с DejaVu Serif/Sans как
fallback-цепочкой; DejaVu Sans Mono (числовые значения тела отчёта, tabular-nums)
остаётся системным — из пакета fonts-dejavu-core, гарантированно установленного в
Docker-образе. Manrope/IBM Plex Mono (тоже base64 @font-face) — Mera v2 шрифтовая
пара, используется в HUD-хроме (_page_header/_page_footer); тело отчёта
(_build_cover/_build_listings_page/_build_deals_page/_build_offer_page) на них пока
не переключено — следующий этап. Диапазоны цен (_price_range_chart_svg) — реальный
matplotlib-график (Agg, SVG), не декоративный CSS-div.
Документ — деловой/утилитарный отчёт (ближе к банковской оценке, чем к
маркетинговому лендингу), НЕ 1:1 копия конкурентного референса.

Структура отчёта — 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 + дистанция + кликабельные ссылки.
"""

from __future__ import annotations

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

import matplotlib

matplotlib.use("Agg")  # headless-safe backend — ОБЯЗАТЕЛЬНО до любого рендера: в Docker-образе
# нет GUI toolkit (Tkinter/Qt/GTK), импорт matplotlib без явного backend может тянуть их/падать.
import matplotlib.font_manager as _font_manager
import segno
from matplotlib.figure import Figure  # object API, НЕ pyplot — см. _price_range_chart_svg
from matplotlib.patches import Rectangle

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

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",
    }
)

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


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:). URL: {url!r}"
        )

    return fetcher


# ── Design tokens (Mera v2 HUD design system) ───────────────────────────────
# Портировано 1:1 из канонического токен-контракта /trade-in/v2:
#   tradein-mvp/frontend/src/components/trade-in/v2/tokens.ts
# Предыдущие раунды (PR #2327/#2427) ошибочно брали этот блок из trade-in.css /
# tradein.html — несвязанной "PRINZIP"-брендированной внутренней демки, НЕ
# настоящей Mera-идентичности продукта. Это исправленный источник.
#
# Константы, помеченные "legacy alias", НЕ часть контракта tokens.ts — они
# существуют только потому, что тело отчёта (_build_cover/_build_listings_page/
# _build_deals_page/_build_offer_page — не тронуто в этом этапе, следующий этап)
# по-прежнему ссылается на старые имена; их ЗНАЧЕНИЯ указывают на ближайший
# Mera-эквивалент, чтобы весь документ (старое тело + новый HUD-хром) читался
# как одна согласованная палитра, а не два конфликтующих во время переходного
# многоэтапного редизайна.
_PAGE_BG = "#dde6ef"  # tokens.pageBg — фон страницы
_INK = "#1c2c40"  # tokens.ink — заголовки / основной текст
_INK_2 = "#17263a"  # tokens.ink2 — вторичный ink
_BODY = "#39506a"  # tokens.body — текст тела
_BODY_2 = "#46586c"  # tokens.body2 — текст тела, более выразительный (wordmark футера)
_MUTED = "#586777"  # tokens.muted — самый выразительный приглушённый (лейблы/подписи)
_MUTED_2 = "#5c6672"  # tokens.muted2 — заголовки карточек / meta-лейблы (header/footer)
_LINE = "#c2d3e3"  # tokens.line — базовый hairline/бордер
_LINE_2 = "#c8d7e6"  # tokens.line2 — бордер карточки
_LINE_SOFT = "#d3dfeb"  # tokens.lineSoft — вертикальные hairline (meta-cell разделители)
_LINE_DOTTED = "#cbd7e3"  # tokens.lineGradient — пунктирные row-разделители / центр футера
_ACCENT = "#2e8bff"  # tokens.accent
_ACCENT_DEEP = "#0d6fd6"  # tokens.accentDeep
_ACCENT_LITE = "#46a8ff"  # tokens.accentLite
_SUCCESS = "#1baa6b"  # tokens.success
_SUCCESS_BORDER = "#b6e0cc"  # tokens.successBorder
_WARN = "#b58a3e"  # tokens.warn
_DANGER = "#cd6868"  # tokens.danger
_BADGE_TINT = "#eaf1f8"  # tokens.badgeTint — непрозрачная бледно-синяя заливка чипа
_CARD_BG = "#eef3f7"  # flattened tokens.surface.w50 поверх _PAGE_BG — у WeasyPrint нет
# backdrop-filter/blur, поэтому «стеклянная» карточка — заранее посчитанная плоская
# заливка вместо rgba(255,255,255,.5) + blur (см. фон карточки в ObjectSummary.tsx).
_BRACKET = "#9bbce0"  # tokens.bracket — акцент HUD corner-bracket
_TRACK = "#dbe6f1"  # tokens.track — «range track + donut track» (дословно из tokens.ts,
# один токен на оба применения: подложка _price_range_chart_svg И трек-круг
# _data_quality_gauge_svg — этот этап добавляет второе использование).

# Legacy aliases — см. комментарий блока выше; убрать при редизайне тела отчёта.
_FG = _INK
_ACCENT_SOFT = _BADGE_TINT
_ACCENT_2 = _WARN  # у Mera нет отдельного тёплого "accent-2" — ближайший аналог
_ACCENT_2_SOFT = "#f6f1e8"  # мягкий тёплый тон (12% _WARN на белом) — иконки Offer-страницы
_DANGER_SOFT = "#f9eded"  # мягкий тон (12% _DANGER на белом) — фон warning-плашек
_BORDER = _LINE
_BORDER_STRONG = "#b8c8d8"  # tokens.line3 — edge карточки/фото, оси графика (сильнее hairline)

# Декоративная версия «движка отчёта» в футере (см. _page_footer) — зеркалит
# tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts::version. Не
# brand-данные (одинаковая для всех white-label брендов) — косметическая деталь
# HUD, а не версия PDF-модуля/API.
_REPORT_ENGINE_VERSION = "v2.0.6"

# Type scale — консолидировано с ~11 разрозненных значений (7/7.5/8/8.5/9/10/
# 11/12/13/14/18pt) до 6 шагов, применяется единообразно на всех 4 страницах.
_FS_XS = "8pt"  # футеры, дисклеймеры, source badges, sub-captions
_FS_SM = "9pt"  # labels, заголовки таблиц, explainer-текст
_FS_MD = "10.5pt"  # базовый текст (body), значения в таблицах
_FS_LG = "13pt"  # заголовки страниц (h2, PT Serif)
_FS_XL = "16pt"  # главный заголовок cover (h1, PT Serif)
_FS_XXL = "22pt"  # крупные ценовые цифры (dual-price блок)


# ── Embedded fonts (PT Sans / PT Serif, ParaType, SIL OFL 1.1) ──────────────
# Раньше CSS декларировал 'DejaVu Serif'/'DejaVu Sans' — это системные fallback-шрифты
# без гарантированного визуального качества (только моноширинный 'DejaVu Sans Mono'
# для цифр остаётся — не часть этой замены, см. _mono()). Теперь настоящие PT Sans/
# PT Serif встроены как base64 @font-face (см. _build_css) — файлы читаются и
# кодируются ОДИН раз при импорте модуля, не на каждый рендер PDF.
_FONTS_DIR = pathlib.Path(__file__).parent / "fonts"


def _font_data_url(filename: str) -> str:
    """Читает .ttf из fonts/ (рядом с этим модулем) и возвращает data:font/ttf;base64 URI."""
    data = (_FONTS_DIR / filename).read_bytes()
    return f"data:font/ttf;base64,{base64.b64encode(data).decode('ascii')}"


_PT_SANS_REGULAR_URL = _font_data_url("PTSans-Regular.ttf")
_PT_SANS_BOLD_URL = _font_data_url("PTSans-Bold.ttf")
_PT_SERIF_BOLD_URL = _font_data_url("PTSerif-Bold.ttf")

# Manrope (UI-текст) и IBM Plex Mono (числа/лейблы) — та же пара шрифтов, что
# зарегистрирована в живом /trade-in/v2 (см. frontend/src/app/v2/layout.tsx,
# next/font/google, subsets latin+cyrillic). Variable-font Manrope[wght].ttf
# инстанцирован в статические Regular/Bold через fonttools varLib.instancer
# (варьируемые шрифты не поддерживаются как @font-face src в WeasyPrint) —
# исходный variable-файл НЕ коммитится, только полученные статические веса.
# IBM Plex Mono подключён только 400/500 (см. @font-face ниже) — bold mono
# не понадобился нигде в текущей вёрстке.
_MANROPE_REGULAR_URL = _font_data_url("Manrope-Regular.ttf")
_MANROPE_BOLD_URL = _font_data_url("Manrope-Bold.ttf")
_IBM_PLEX_MONO_REGULAR_URL = _font_data_url("IBMPlexMono-Regular.ttf")
_IBM_PLEX_MONO_MEDIUM_URL = _font_data_url("IBMPlexMono-Medium.ttf")

# Регистрируем файлы в matplotlib. С этого этапа chart endpoint-labels (см.
# _price_range_chart_svg) рендерятся Manrope — той же гарнитурой, что и весь
# HUD-хром (_page_header/_page_footer) и (следующий шаг) тело отчёта; PT Sans
# по-прежнему зарегистрирован (нужен для @font-face тела документа, ещё не
# переключённого на Manrope в этом этапе).
_font_manager.fontManager.addfont(str(_FONTS_DIR / "PTSans-Regular.ttf"))
_font_manager.fontManager.addfont(str(_FONTS_DIR / "PTSans-Bold.ttf"))
_font_manager.fontManager.addfont(str(_FONTS_DIR / "Manrope-Regular.ttf"))
_font_manager.fontManager.addfont(str(_FONTS_DIR / "Manrope-Bold.ttf"))
matplotlib.rcParams["font.family"] = "Manrope"


# ── Source pseudo-logos (текстовые pill-badges с брендовыми цветами источников) ─
# Неизвестный/снятый source (напр. историческое inactive 'n1', #2204) рендерится
# через безопасный fallback в _source_pill: серый фон + source.title() — код на
# исторических строках НЕ падает. Цвета источников — реальные бренд-цвета
# сторонних площадок (Avito/Циан/...), НЕ часть внутренней design-системы —
# намеренно не конвертируются в наши OKLCH-токены.
_SOURCE_LOGO_COLORS: dict[str, tuple[str, str]] = {
    "avito": ("#00aaff", "#fff"),  # Avito brand blue (упрощённо)
    "cian": ("#0468ff", "#fff"),  # Циан фирменный синий
    "domklik": ("#1ab248", "#fff"),  # ДомКлик зелёный Сбер
    "yandex": ("#fc3f1d", "#fff"),  # Я.Недвижимость красный
    "rosreestr": ("#003d82", "#fff"),  # Росреестр тёмно-синий
    "etazhi": ("#e30613", "#fff"),  # Этажи красный
}

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


def _source_logo_pill(source: str) -> str:
    """Source pill — мягкий HUD-чип (.source-pill, ObjectSummary.tsx dot-идиома):
    акцентная точка цвета источника + название на светлой заливке. Раньше — сплошной
    непрозрачный цветной чип; теперь единая с остальным документом мягкая палитра
    (Mera v2), бренд-цвет остаётся только акцентной точкой. Название — из
    _SOURCE_DISPLAY_NAMES (короткий bounded набор, max ~18 символов) —
    overflow-wrap на всякий случай, если source незнаком и попадёт .title()
    произвольной длины (см. fallback ниже)."""
    dot = _SOURCE_LOGO_COLORS.get(source, (_MUTED, "#fff"))[0]
    name = _SOURCE_DISPLAY_NAMES.get(source, source.title())
    return (
        "<span class='source-pill' style='display:inline-flex;align-items:center;gap:4pt;"
        f"margin:0 4pt 4pt 0;background:{_CARD_BG};color:{_BODY};vertical-align:middle;"
        "overflow-wrap:anywhere;'>"
        f"<span style='width:5pt;height:5pt;border-radius:50%;background:{dot};"
        f"display:inline-block;flex-shrink:0;'></span>{_html.escape(name)}</span>"
    )


def _source_badge_inline(source: str | None) -> str:
    """Маленький source badge для table cells (без фона)."""
    if not source:
        return f"<span style='color:{_MUTED};'>—</span>"
    bg, fg = _SOURCE_LOGO_COLORS.get(source, (_MUTED, "#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:{_FS_XS};font-weight:700;border-radius:2pt;'>{_html.escape(name)}</span>"
    )


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


_NBSP = "\u00a0"  # неразрывный пробел — разряды/валюта не переносятся посередине


def _fmt_rub(value: int) -> str:
    """12 500 000 ₽ — неразрывные пробелы, чтобы число никогда не переносилось
    посередине (разряды/символ валюты не должны сиротеть на разных строках)."""
    return f"{value:,}".replace(",", _NBSP) + _NBSP + "₽"


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 _mono(text: str) -> str:
    """Оборачивает числовой текст (цены/даты/%/№ отчёта/м²/₽-м²/срок экспозиции)
    в моноширинный span — DejaVu Sans Mono + tabular-nums (см. .mono в _build_css),
    зеркалит web-конвенцию .num/.mono из trade-in.css (HeroSummary.tsx и др.)."""
    return f"<span class='mono'>{text}</span>"


# Город (region 66) → буквенный префикс № отчёта. Раньше _report_number
# захардкоживал «EKБ» для ВСЕХ оценок — объект в Серове / Нижнем Тагиле получал
# екатеринбургский код на обложке, в шапках и в футере (#pdf-honesty). Префикс
# теперь выводится из адреса оценки; ключи в нижнем регистре, ё→е (симметрично
# _resolve_target_city в estimator.py). Город вне карты → _DEFAULT_REPORT_PREFIX.
_CITY_REPORT_PREFIX: dict[str, str] = {
    "екатеринбург": "ЕКБ",
    "нижний тагил": "НТ",
    "каменск-уральский": "КУ",
    "первоуральск": "ПРВ",
    "серов": "СЕР",
    "новоуральск": "НВУ",
    "ревда": "РЕВ",
    "полевской": "ПЛВ",
    "асбест": "АСБ",
    "верхняя пышма": "ВП",
    "березовский": "БРЗ",
    "краснотурьинск": "КРТ",
    "камышлов": "КМШ",
}
# Нейтральный префикс, когда город адреса не распознан (пустой / вне региона) —
# НЕ выдумываем локацию, не подставляем ложный «EKБ» (#pdf-honesty).
_DEFAULT_REPORT_PREFIX = "МЕРА"
# Длинные имена раньше коротких — чтобы «нижний тагил» матчился целиком.
_REPORT_CITY_RE = re.compile(
    r"\b(?:"
    + "|".join(re.escape(c) for c in sorted(_CITY_REPORT_PREFIX, key=len, reverse=True))
    + r")\b"
)


def _report_city_prefix(address: str | None) -> str:
    """Буквенный префикс № отчёта по городу из адреса (region 66).

    «МЕРА» если город не распознан — не выдумываем локацию (раньше был хардкод
    «EKБ» для любого адреса, включая не-екатеринбургские). ё→е нормализация до
    поиска (симметрично _resolve_target_city в estimator.py)."""
    if not address:
        return _DEFAULT_REPORT_PREFIX
    norm = address.replace("ё", "е").replace("Ё", "е").lower()
    m = _REPORT_CITY_RE.search(norm)
    return _CITY_REPORT_PREFIX[m.group(0)] if m else _DEFAULT_REPORT_PREFIX


def _report_number(estimate: AggregatedEstimate) -> str:
    """№ отчёта: 'PREFIX-NNNN-XXXXXXXXXX'.

    PREFIX — буквенный код города оценки (ЕКБ / НТ / … по target_address, «МЕРА»
    если город не распознан); NNNN/XXXXXXXXXX — детерминированные коды от
    estimate_id. Раньше PREFIX был захардкожен «EKБ» для всех оценок (#pdf-honesty)."""
    prefix = _report_city_prefix(estimate.target_address or estimate.canonical_address)
    short = int(estimate.estimate_id.int) % 10_000
    long = int(estimate.estimate_id.int) % 10_000_000_000
    return f"{prefix}-{short:04d}-{long:010d}"


def _expires_date(estimate: AggregatedEstimate) -> dt.date:
    """Срок действия данных отчёта — estimate.expires_at, fallback +30 дней.

    Та же логика, что в _build_cover; вынесена сюда, чтобы _page_footer могла
    показать «ДЕЙСТВИТЕЛЕН ДО» одинаково на всех страницах отчёта."""
    if estimate.expires_at is not None:
        return estimate.expires_at.date()
    return dt.date.today() + dt.timedelta(days=30)


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)


# ── Unified page header/footer — HUD idiom, портировано из /trade-in/v2
# (HeroBar.tsx meta-cells + Footer.tsx wordmark composition). Раньше — плашка с
# логотипом + текстовая полоска (cover) / голая footer-строка (pages 2-4); теперь
# единый персистентный masthead + единый footer на ВСЕХ 4 страницах отчёта. ────


def _page_header(brand, report_num: str, report_date: dt.date) -> str:  # type: ignore[no-untyped-def]
    """Masthead: слева — wordmark бренда (точка + tracked-uppercase имя, трактовка
    Footer.tsx, но цвет по-прежнему driven brand.primary_color — white-label не
    тронут), справа — компактные mono meta-cells (№ отчёта / дата), разделённые
    тонкими вертикальными hairline (см. meta-row HeroBar.tsx)."""
    logo_url = _safe_logo_url(brand.logo_url)
    if logo_url:
        # <img> уже жёстко ограничен max-height/max-width — переполнение исключено
        # само по себе; brand.name виден только в alt (не рендерится как текст).
        mark_html = (
            f"<span style='flex-shrink:0;'>"
            f"<img src='{_html.escape(logo_url)}' alt='{_html.escape(brand.name)}' "
            f"style='max-height:20pt;max-width:110pt;object-fit:contain;"
            f"vertical-align:middle;'/></span>"
        )
    else:
        # #overflow-hardening: brand.name — white-label произвольная строка, может
        # быть длиннее «Trade-In»/«Практика» — min-width:0 + overflow-wrap:anywhere
        # позволяет ей перенестись, а не наехать на meta-cell справа (flex-row).
        mark_html = (
            f"<span style='display:inline-flex;align-items:center;gap:5pt;min-width:0;"
            f"flex:1 1 auto;overflow-wrap:anywhere;'>"
            f"<span style='display:inline-block;width:5pt;height:5pt;border-radius:50%;"
            f"flex-shrink:0;background:{brand.primary_color};'></span>"
            f"<span style='font-size:{_FS_LG};font-weight:700;letter-spacing:0.14em;"
            f"color:{brand.primary_color};'>{_html.escape(brand.name).upper()}</span>"
            f"</span>"
        )

    def _meta_cell(label: str, value: str) -> str:
        # label/value — короткие гарантированно-ограниченные значения (лейбл ≤6
        # символов, № отчёта/дата — фиксированный формат) — nowrap оправдан здесь.
        return (
            f"<span style='display:inline-block;padding-left:11pt;margin-left:11pt;"
            f"border-left:1pt solid {_LINE_SOFT};white-space:nowrap;'>"
            f"<span style='display:block;font-size:{_FS_XS};letter-spacing:0.12em;"
            f"color:{_MUTED_2};'>{_html.escape(label)}</span>"
            f'<span style=\'font-family:"IBM Plex Mono","DejaVu Sans Mono",monospace;'
            f"font-variant-numeric:tabular-nums;font-size:{_FS_SM};color:{_INK};'>"
            f"{_html.escape(value)}</span></span>"
        )

    meta_html = _meta_cell("ОТЧЁТ", report_num) + _meta_cell(
        "ДАТА", report_date.strftime("%d.%m.%Y")
    )

    return (
        f'<div style="display:flex;align-items:center;justify-content:space-between;'
        f"flex-wrap:wrap;gap:6pt;border-bottom:2pt solid {brand.primary_color};"
        f'padding-bottom:6pt;margin-bottom:9pt;">'
        f"{mark_html}"
        f'<span style="display:flex;align-items:center;flex-shrink:0;">{meta_html}</span>'
        f"</div>"
    )


def _page_footer(
    brand,  # type: ignore[no-untyped-def]
    report_num: str,
    report_date: dt.date,
    page_note: str | None = None,
    *,
    valid_until: dt.date | None = None,
) -> str:
    """Единый footer — порт 3-частной композиции Footer.tsx (/trade-in/v2), но
    развёрнутый в 2 явных строки, а не 3 flex-item'а с fallback-переносом в одну
    (см. ниже #overflow-hardening про причину):

    строка 1 — mono meta (№ отчёта / дата / срок действия); тонкая градиентная
    линия-разделитель; строка 2 — точка акцента + wordmark (brand.name — НЕ
    хардкод «МЕРА», white-label остаётся рабочим) + версия движка отчёта.

    page_note — старый текст footer'а (бренд/подзаголовок/№ страницы/дисклеймер
    на офер-странице), которого нет в веб-референсе (там нет пагинации). Не
    удалён — вынесен приглушённой строкой НАД HUD-баром, чтобы не терять
    полезную для печатного многостраничного отчёта информацию.
    """
    note_html = ""
    if page_note:
        note_html = (
            f'<div style="font-size:{_FS_XS};color:{_MUTED_2};padding-bottom:5pt;'
            f'overflow-wrap:anywhere;">{page_note}</div>'
        )
    valid_until_html = ""
    if valid_until is not None:
        valid_until_html = (
            f'<span style="margin-left:16pt;">ДЕЙСТВИТЕЛЕН ДО '
            f'<span style="color:{_MUTED};">{valid_until.strftime("%d.%m.%Y")}</span></span>'
        )
    mono_family = "'IBM Plex Mono','DejaVu Sans Mono',monospace"
    # #overflow-hardening: изначально meta / декоративная линия-коннектор / wordmark
    # были 3 flex-child'а ОДНОЙ строки с `flex-wrap:wrap` как fallback на случай
    # нехватки места. На практике meta (особенно со СРОК ДЕЙСТВИЯ на Cover) почти
    # всегда заполняет строку целиком, так что wordmark systematically уезжал на
    # свою отдельную строку — но даже НА ОТДЕЛЬНОЙ, целиком его строке, WeasyPrint
    # заметно занижал доступную ширину wordmark-item (воспроизводится детерминированно
    # и не зависело от max-width/margin декоративной линии или flex-shrink на самом
    # wordmark — похоже на ограничение реализации multi-line flex-wrap в WeasyPrint,
    # а не на конкуренцию за ширину), из-за чего даже короткий generic бренд
    # «Trade-In» рвался ПОСРЕДИ слова («TRADE-» / «IN») на КАЖДОЙ странице с footer'ом.
    # Фикс — не патчить flex-veights, а убрать саму двусмысленность: 2 явные
    # block-строки (meta / wordmark) вместо одной строки с fallback-переносом.
    # Декоративный коннектор (2 точки+градиент с каждой стороны) как самостоятельный
    # flex-item убран — он дублировал функцию border-top (уже отделяет footer от
    # тела страницы) и был источником сложности; заменён на простую тонкую
    # градиентную линию-разделитель между строками meta/wordmark.
    return f"""
<div style="margin-top:6pt;">
  {note_html}
  <div style="border-top:1pt solid {_LINE_SOFT};padding-top:8pt;
       font-family:{mono_family};font-size:{_FS_XS};letter-spacing:0.06em;
       color:{_MUTED_2};">
    <div style="white-space:nowrap;overflow:hidden;">
      ОТЧЁТ <span style="color:{_MUTED};">{_html.escape(report_num)}</span>
      <span style="margin-left:16pt;">ДАТА
        <span style="color:{_MUTED};">{report_date.strftime("%d.%m.%Y")}</span></span>
      {valid_until_html}
    </div>
    <div style="height:1pt;margin:6pt 0;background:linear-gradient(90deg,
         transparent,{_LINE_DOTTED} 15%,{_ACCENT} 50%,{_LINE_DOTTED} 85%,
         transparent);"></div>
    <div style="display:flex;align-items:center;gap:7pt;min-width:0;">
      <span style="width:4pt;height:4pt;border-radius:50%;flex-shrink:0;
           background:{_ACCENT};display:inline-block;"></span>
      <span style="flex:1 1 auto;font-family:'Manrope','DejaVu Sans',sans-serif;
           font-size:{_FS_SM};font-weight:600;letter-spacing:0.28em;color:{_BODY_2};
           min-width:0;overflow-wrap:anywhere;">
        {_html.escape(brand.name).upper()}</span>
      <span style="font-size:7pt;letter-spacing:0.08em;color:{_MUTED_2};
           flex-shrink:0;white-space:nowrap;">
        {_REPORT_ENGINE_VERSION}</span>
    </div>
  </div>
</div>
"""


# ── Price range chart — matplotlib SVG bullet/range chart (real chart, не div) ──
# Раньше рендерилась одним div с фиксированной 64%-заливкой/14%-отступом — числа
# НЕ определяли позицию заливки (чисто декоративный "прогресс-бар", не привязанный
# к range_low/range_high). Теперь — настоящий график: серый трек на всю область
# построения + акцентная заливка ровно на [range_low, range_high] (в млн ₽) + подписи-
# концы (tabular figures, тот же PT Sans, что и весь документ) + опционально срок
# экспозиции. Рендерится headless (Agg backend, см. import matplotlib.use выше) в
# SVG и встраивается как base64 <img> — WeasyPrint рендерит SVG только через image
# pipeline (не как inline-разметку), см. reference prototype build_report.py.


def _price_range_chart_svg(
    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,
    accent: str = _ACCENT,
) -> str:
    """Bullet/range-chart диапазона цен (matplotlib Agg → SVG → base64 <img>).

    Подложка-трек (светло-серый) + закрашенная акцентная зона на реальном диапазоне
    [range_low, range_high] + акцентные end-tick'и + подписи-концы + опционально
    срок экспозиции (show_days=True) — та же информация, что раньше показывал
    _price_range_bar (удалена), но настоящий график, а не декоративный div."""
    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)

    lo_m = range_low / 1_000_000
    hi_m = range_high / 1_000_000
    pad = (hi_m - lo_m) * 0.08 or 0.3

    y = 0.5
    track_h = 0.34

    # Высота figure (points) задаёт только масштаб track/pad — реальная высота
    # итогового SVG определяется bbox_inches="tight" (см. savefig ниже) по фактическому
    # содержимому, включая подписи ниже track. Подписи — через ax.annotate с
    # textcoords="offset points": абсолютный точечный отступ, НЕ зависящий от масштаба
    # data-координат/figsize (в отличие от прежней data-fraction схемы, которая на
    # маленьком figsize схлопывала строки label/days друг в друга).
    # Figure() object API (НЕ pyplot.subplots) — write_pdf() выполняется в FastAPI
    # threadpool-executor (sync endpoint), где несколько запросов на PDF могут рендерить
    # график конкурентно; pyplot держит глобальный figure-manager, не потокобезопасный
    # для такого сценария (см. matplotlib FAQ "Embedding in a web application server").
    # Figure() не регистрируется нигде глобально — никакой утечки, plt.close() не нужен.
    fig = Figure(figsize=(6.85, 28 / 72), dpi=200)
    ax = fig.subplots()
    fig.patch.set_alpha(0)
    ax.set_facecolor("none")
    # Axes растянуты на всю figure — предсказуемая точка отсчёта для track_bottom.
    ax.set_position((0, 0, 1, 1))

    track_bottom = y - track_h / 2

    # подложка-трек — на всю область построения (визуальный "инструмент")
    ax.add_patch(
        Rectangle(
            (lo_m - pad, track_bottom),
            (hi_m - lo_m) + 2 * pad,
            track_h,
            facecolor=_TRACK,
            edgecolor="none",
            zorder=1,
        )
    )
    # закрашенная акцентная зона — реальный диапазон range_low..range_high
    ax.add_patch(
        Rectangle(
            (lo_m, track_bottom),
            hi_m - lo_m,
            track_h,
            facecolor=accent,
            edgecolor="none",
            zorder=2,
        )
    )
    # end-tick'и по границам активной зоны (bullet-chart акцент)
    for x in (lo_m, hi_m):
        ax.plot(
            [x, x],
            [track_bottom - 0.04, y + track_h / 2 + 0.04],
            color=accent,
            linewidth=2.2,
            solid_capstyle="round",
            zorder=3,
        )

    # Размер подписей выровнен с типографской шкалой документа (_FS_MD/_FS_XS) —
    # тот же масштаб, что раньше показывал div-бар.
    for x, label, ha in ((lo_m, label_left, "left"), (hi_m, label_right, "right")):
        ax.annotate(
            label,
            xy=(x, track_bottom),
            xycoords="data",
            xytext=(0, -4),
            textcoords="offset points",
            color=_INK,
            fontsize=10.5,
            ha=ha,
            va="top",
            fontweight="bold",
            annotation_clip=False,
        )

    if show_days and days_min is not None and days_max is not None:
        for x, days, ha in ((lo_m, days_min, "left"), (hi_m, days_max, "right")):
            ax.annotate(
                f"{days} дней",
                xy=(x, track_bottom),
                xycoords="data",
                xytext=(0, -19),
                textcoords="offset points",
                color=_DANGER,
                fontsize=8,
                ha=ha,
                va="top",
                fontweight="bold",
                annotation_clip=False,
            )

    ax.set_xlim(lo_m - pad, hi_m + pad)
    ax.set_ylim(0, 1)
    ax.axis("off")

    buf = io.BytesIO()
    fig.savefig(buf, format="svg", transparent=True, bbox_inches="tight", pad_inches=0.03)
    chart_b64 = base64.b64encode(buf.getvalue()).decode("ascii")

    return (
        f'<div style="margin:3pt 0;">'
        f'<div style="text-align:center;font-size:{_FS_SM};color:{_MUTED};'
        f'margin-bottom:3pt;">{_html.escape(sub_label)}</div>'
        f'<img style="width:100%;display:block;" alt="{_html.escape(sub_label)}" '
        f'src="data:image/svg+xml;base64,{chart_b64}">'
        f"</div>"
    )


# ── 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%».
    Все числовые значения — DejaVu Sans Mono (tabular-nums), как на web (.mono).

    HUD-карточка (card-glass + bracket-tr, corner-bracket accent из tokens.ts) —
    вложенный <div> внутри <td> (НЕ position:relative на самом <td> — надёжность
    absolute-позиционированного ::before внутри table cell в разных движках
    рендеринга непредсказуема). padding-right на label-строке — clearance от
    delta_chip (короткий bounded chip, но всё равно на одной строке с label)."""
    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:{_FS_XS};font-weight:700;"
            f"border-radius:8pt;vertical-align:middle;white-space:nowrap;'>"
            f"{_mono(f'&#8722;{delta_pct}%')}</span>"
        )
    range_html = ""
    if range_low is not None and range_high is not None and range_high > range_low:
        range_text = (
            f"{_html.escape(_fmt_rub_m(range_low))} &#8211; {_html.escape(_fmt_rub_m(range_high))}"
        )
        range_html = (
            f"<div style='font-size:{_FS_MD};color:{_MUTED};font-weight:600;margin-top:3pt;"
            f"overflow-wrap:anywhere;'>{_mono(range_text)}</div>"
        )
    perm2_html = ""
    if per_m2 is not None and per_m2 > 0:
        perm2_html = (
            f"<div style='font-size:{_FS_SM};color:{_MUTED};margin-top:2pt;"
            f"overflow-wrap:anywhere;'>{_mono(f'{_fmt_ppm2(per_m2)} &#8381;/м²')}</div>"
        )
    return (
        f"<td style='width:50%;vertical-align:top;padding:0;'>"
        f"<div class='card-glass bracket-tr' style='padding:8pt 12pt 7pt 12pt;"
        f"border-top:2pt solid {accent};border-radius:4pt;overflow-wrap:anywhere;'>"
        f"<div style='font-size:{_FS_SM};color:{_MUTED};font-weight:700;text-transform:uppercase;"
        f"letter-spacing:0.03em;padding-right:16pt;'>{_html.escape(label)}{delta_chip}</div>"
        f"<div style='font-size:{_FS_XXL};font-weight:900;color:{_INK};margin-top:4pt;"
        f"overflow-wrap:anywhere;'>{_mono(_html.escape(_fmt_rub_m(value_rub)))}</div>"
        f"{range_html}{perm2_html}"
        f"</div></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=_DANGER,
        delta_pct=_discount_pct(estimate),
    )

    explainer = (
        f'<p style="margin:4pt 0 2pt 0;padding:4pt 10pt;background:{_ACCENT_SOFT};'
        f'border-radius:4pt;font-size:{_FS_SM};color:{_MUTED};line-height:1.25;">'
        "<strong>Запрос</strong> — по чему выставлены сопоставимые квартиры в объявлениях. "
        "<strong>Ожидаемая цена продажи</strong> — реалистичная цена сделки по данным ДКП "
        "Росреестра, обычно ниже запроса."
        "</p>"
    )

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


# ── Shared HUD chrome for page bodies (Mera v2 — section-badge/status-pill/
# card-glass+bracket) — used by all 4 page-builders below. ──────────────────


def _section_heading(number: str, text: str, *, tag: str = "h2", extra_right: str = "") -> str:
    """Заголовок страницы (h1 Cover / h2 Listings-Deals-Offer) с моно-нумерацией
    секции — HUD-идиома ObjectSummary.tsx («03 СВОДКА ОБЪЕКТА»), теперь общая для
    всех 4 страниц отчёта (раньше — только у cover не было border-bottom, у
    остальных был раздельный inline-стиль на каждой странице).

    extra_right — опциональный элемент справа (напр. status-pill); свой flex-item
    с flex-shrink:0, заголовок — flex:1 1 auto с min-width:0, чтобы длинный текст
    (в этом модуле текст заголовков статический, но структура остаётся safe by
    construction для любого будущего динамического текста) переносился, а не
    наезжал на правый элемент."""
    size = _FS_XL if tag == "h1" else _FS_LG
    right_html = f'<span style="flex-shrink:0;">{extra_right}</span>' if extra_right else ""
    return (
        '<div style="display:flex;align-items:baseline;justify-content:space-between;'
        f"gap:10pt;flex-wrap:wrap;border-bottom:1.5pt solid {_LINE};padding-bottom:5pt;"
        'margin-bottom:12pt;">'
        '<div style="display:flex;align-items:baseline;gap:8pt;min-width:0;flex:1 1 auto;">'
        f'<span class="section-badge">{number}</span>'
        f"<{tag} style=\"font-family:'PT Serif','DejaVu Serif',serif;font-size:{size};"
        "font-weight:700;text-transform:uppercase;letter-spacing:0.02em;line-height:1.25;"
        f'margin:0;">{text}</{tag}>'
        "</div>"
        f"{right_html}"
        "</div>"
    )


def _freshness_status_pill(freshness_minutes: int | None) -> str:
    """Status-pill (.status-pill — ObjectSummary.tsx «АКТУАЛЬНО» идиома) — честная
    метка свежести данных из estimate.data_freshness_minutes (реальное поле, НЕ
    fabricated метрика). Пусто, если поле не заполнено (graceful — старые записи
    без этого поля/пустой рехайдрат) — по аналогии с остальными optional-полями
    оценки в этом модуле (см. _dual_price_block)."""
    if freshness_minutes is None:
        return ""
    hours = freshness_minutes / 60
    if hours <= 24:
        label, color, border = "АКТУАЛЬНО", _SUCCESS, _SUCCESS_BORDER
    elif hours <= 24 * 7:
        label, color, border = "ОБНОВЛЕНО НА ЭТОЙ НЕДЕЛЕ", _WARN, "#e3d4b3"
    else:
        label, color, border = "ТРЕБУЕТ ОБНОВЛЕНИЯ", _DANGER, "#e8c9c9"
    # Три фиксированных RU-строки из закрытого набора (не free-form данные) —
    # nowrap оправдан тем же порядком, что и у web-референса (ObjectSummary.tsx
    # ставит whiteSpace:"nowrap" на идентичный pill).
    return (
        f"<span class='status-pill' style='color:{color};border-color:{border};"
        f"white-space:nowrap;'>{label}</span>"
    )


# Слоты источников для «Качество данных» — портировано 1:1 из mappers.ts
# SOURCE_SLOTS/TOTAL_SOURCES (см. buildSources/activeSourceCount): 5 живых
# источников; N1/Restate намеренно исключены там же (#2081/#2204 — мёртвые).
_QUALITY_SOURCE_SLOTS: tuple[tuple[str, ...], ...] = (
    ("cian",),
    ("yandex",),
    ("rosreestr",),
    ("avito", "avito_imv"),
    ("domklik", "domclick"),
)
_TOTAL_SOURCES = len(_QUALITY_SOURCE_SLOTS)

# Web-референс (ObjectSummary.tsx, статичная demo-fixture): confidence="средняя"
# → strokeDasharray=126/strokeDashoffset=72 → дуга заполнена ~43%. low/high —
# пропорциональная экстраполяция. Это категориальное визуальное кодирование ТОГО
# ЖЕ поля confidence, что уже показано текстом рядом (см. _conf_label) — НЕ
# отдельная выдуманная точная метрика (в AggregatedEstimate нет отдельного
# числового quality-score).
_QUALITY_TIER_PCT: dict[str, float] = {"high": 0.78, "medium": 0.43, "low": 0.22}


def _coeff_var_pct(values: list[float]) -> int | None:
    """Коэффициент вариации (%, округлён до целого) — портировано 1:1 из
    mappers.ts coeffVar/cvStr (population stddev / mean × 100). None при <2
    валидных (>0) значениях — как на web рендерится «—»."""
    clean = [v for v in values if v and v > 0]
    if len(clean) < 2:
        return None
    mean = sum(clean) / len(clean)
    if mean == 0:
        return None
    variance = sum((v - mean) ** 2 for v in clean) / len(clean)
    return round((variance**0.5) / mean * 100)


def _active_source_count(estimate: AggregatedEstimate) -> int:
    """Портировано 1:1 из mappers.ts activeSourceCount/buildSources — слот
    активен, если он в sources_used ИЛИ реально дал лоты (analogs+actual_deals)."""
    counts: dict[str, int] = {}
    for lot in (*estimate.analogs, *estimate.actual_deals):
        s = (lot.source or "").lower()
        if s:
            counts[s] = counts.get(s, 0) + 1
    used = {s.lower() for s in (estimate.sources_used or [])}
    active = 0
    for slot in _QUALITY_SOURCE_SLOTS:
        count = sum(counts.get(k, 0) for k in slot)
        if count > 0 or any(k in used for k in slot):
            active += 1
    return active


def _data_quality_gauge_svg(confidence: str, *, size: int = 46) -> str:
    """Кольцевой SVG-индикатор — техника ObjectSummary.tsx (2 концентрических
    <circle>, stroke-dasharray/stroke-dashoffset), инлайн в HTML (WeasyPrint
    поддерживает inline <svg> — см. существующие adv-ic иконки Offer-страницы),
    НЕ matplotlib и НЕ внешний файл."""
    r = 20.0
    circumference = 2 * 3.14159265358979 * r
    pct = _QUALITY_TIER_PCT.get(confidence, 0.5)
    dashoffset = circumference * (1 - pct)
    color = {"high": _SUCCESS, "medium": _WARN, "low": _DANGER}.get(confidence, _MUTED)
    return (
        f'<svg width="{size}" height="{size}" viewBox="0 0 50 50" '
        f'style="flex-shrink:0;" role="img" aria-label="Качество данных">'
        f'<circle cx="25" cy="25" r="{r:.0f}" fill="none" stroke="{_TRACK}" '
        f'stroke-width="3"></circle>'
        f'<circle cx="25" cy="25" r="{r:.0f}" fill="none" stroke="{color}" '
        f'stroke-width="3" stroke-linecap="round" stroke-dasharray="{circumference:.1f}" '
        f'stroke-dashoffset="{dashoffset:.1f}" transform="rotate(-90 25 25)"></circle>'
        f'<circle cx="25" cy="25" r="11" fill="none" stroke="{_LINE_SOFT}" '
        f'stroke-width="1"></circle>'
        f'<circle cx="25" cy="25" r="1.6" fill="{color}"></circle>'
        f"</svg>"
    )


def _quality_card_html(estimate: AggregatedEstimate) -> str:
    """«КАЧЕСТВО ДАННЫХ» карточка (Cover) — донат-индикатор + 3 честные метрики
    (источников / достоверность / разброс цен), см. ObjectSummary.tsx. Все числа —
    реальные вычисленные значения (не fabricated), см. _active_source_count/
    _coeff_var_pct/_conf_label. flex-wrap + min-width:0 + overflow-wrap:anywhere —
    метки короткие и bounded, но структура остаётся safe by construction."""
    n_active = _active_source_count(estimate)
    cv = _coeff_var_pct([lot.price_per_m2 for lot in estimate.analogs])
    cv_str = f"{cv}%" if cv is not None else "—"
    gauge = _data_quality_gauge_svg(estimate.confidence)
    return f"""
<div class="card-glass bracket-tr" style="margin:3pt 0 3pt 0;padding:6pt 12pt 5pt 12pt;
     display:flex;align-items:center;gap:12pt;flex-wrap:wrap;">
  {gauge}
  <div style="flex:1;min-width:170pt;overflow-wrap:anywhere;">
    <div style="font-size:{_FS_XS};letter-spacing:0.1em;color:{_MUTED_2};
         text-transform:uppercase;margin-bottom:5pt;">Качество данных</div>
    <div style="display:flex;gap:16pt;flex-wrap:wrap;font-size:{_FS_SM};color:{_MUTED};">
      <span>Источников
        <span class="mono" style="color:{_INK};">{n_active} / {_TOTAL_SOURCES}</span></span>
      <span>Достоверность
        <span style="color:{_INK};">{_conf_label(estimate.confidence)}</span></span>
      <span>Разброс цен
        <span class="mono" style="color:{_ACCENT};">{cv_str}</span></span>
    </div>
  </div>
</div>
"""


# ── 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)

    # Короткий адрес (для 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 "—"
    # #pdf-none: year_built может быть None (адрес без house_metadata) — раньше в
    # таблицу утекало литеральное «None»; показываем «—» как в остальных пустых полях.
    year_label = _mono(str(year_built)) if year_built else "—"

    # Срок экспозиции показываем ТОЛЬКО при реальных days_on_market в analogs;
    # если данных нет — days не рисуем (не выдумываем «4-118 дней», #pdf-honesty).
    days_range = _days_on_market_range(estimate.analogs)

    listings_bar = _price_range_chart_svg(
        estimate.range_low_rub,
        estimate.range_high_rub,
        sub_label="Активный рынок с аналогичным состоянием ремонта",
        days_min=days_range[0] if days_range else None,
        days_max=days_range[1] if days_range else None,
        show_days=days_range is not None,
    )

    # Совет «Цены в объявлениях — ожидания собственников» ссылается на РЕАЛЬНЫЙ
    # рассчитанный дисконт запрос→продажа (тот же, что chip «−N%» в dual-price),
    # а не хардкод «10–18%», который противоречил бы вычисленному «−N%» (#pdf-honesty).
    discount_pct = _discount_pct(estimate)
    if discount_pct is not None:
        advice_discount_text = (
            f"Фактические сделки проходят ниже цен в объявлениях — по этому объекту "
            f"на {discount_pct}% (см. «Ожидаемая цена продажи»); подтверждают Росреестр, "
            f"ДомКлик и продажи агентств недвижимости"
        )
    else:
        advice_discount_text = (
            "Фактические сделки проходят ниже цен в объявлениях, что подтверждают "
            "Росреестр, ДомКлик и продажи агентств недвижимости"
        )

    disclaimer_html = ""
    if brand.pdf_disclaimer:
        disclaimer_html = (
            f"<p style='margin-top:10pt;font-size:{_FS_XS};color:{_MUTED};line-height:1.4;"
            f"border-top:1pt solid {_LINE};padding-top:6pt;overflow-wrap:anywhere;'>"
            f"{_html.escape(brand.pdf_disclaimer)}</p>"
        )
    heading_html = _section_heading(
        "01",
        "АНАЛИЗ РЫНКА И РАСЧЕТ ВЫКУПНОЙ СТОИМОСТИ КВАРТИРЫ",
        tag="h1",
        extra_right=_freshness_status_pill(estimate.data_freshness_minutes),
    )

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

  {heading_html}

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

  <!-- Качество данных — донат-индикатор + честные метрики (card-glass/bracket-tr) -->
  {_quality_card_html(estimate)}

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

  <div style="break-inside:avoid;page-break-inside:avoid;">
    <p style="margin:3pt 0 2pt 0;font-size:{_FS_MD};font-weight:700;">
      Диапазон цен в объявлениях
      <span style="font-weight:400;font-size:{_FS_SM};color:{_MUTED};">(без учета ремонта)</span>
    </p>
    {listings_bar}
  </div>

  <!-- Что важно при оценке -->
  <h3 style="margin-top:4pt;font-size:{_FS_MD};font-weight:700;">
    Что важно при оценке стоимости квартиры:
  </h3>
  <table class="advice-table" style="width:100%;border-collapse:collapse;margin-top:2pt;">
    <tr>
      <td class="advice-title">Цены в объявлениях — ожидания собственников</td>
      <td class="advice-text">{advice_discount_text}</td>
    </tr>
    <tr>
      <td class="advice-title">Ремонт оценивается по состоянию, а не по вложенным суммам</td>
      <td class="advice-text">Инвестиции в 400 – 600 тыс. руб. повышают цену объекта всего
        на 150 – 250 тыс. руб — покупатель оценивает общее состояние квартиры</td>
    </tr>
  </table>
  <!-- #4-pages-fix: 3-я строка advice-table («Неочевидные расходы при
       самостоятельной продаже — до 15%») убрана — дублирует детальную
       постатейную разбивку на Offer-странице (см. _build_offer_page: торг
       5-15% + риелтор 2-5% + аренда + юрсопровождение + реклама), которая
       даёт ту же цифру НАМНОГО подробнее. Единственный content-cut в этом
       фиксе (см. PR/задача про 2 пустые страницы); остальной контент cover
       сохранён полностью. -->

  <p style="margin-top:3pt;font-size:{_FS_XS};color:{_MUTED};border-top:1pt solid {_LINE};
            padding-top:3pt;overflow-wrap:anywhere;">
    <strong>Этот отчёт онлайн:</strong> {settings.public_url}?id={estimate.estimate_id}
  </p>
  {disclaimer_html}

</div>
"""


def _days_on_market_range(lots: list[AnalogLot]) -> tuple[int, int] | None:
    """Min/max days_on_market по аналогам, либо None если реальных данных нет.

    Раньше возвращал хардкод (4, 118) при отсутствии days_on_market — обложка и
    страница объявлений печатали этот выдуманный коридор как измеренный срок
    экспозиции (~48% оценок без days_on_market). Теперь None → caller не рисует
    срок (show_days=False), как уже делает страница сделок (#pdf-honesty)."""
    days = [lot.days_on_market for lot in lots if lot.days_on_market is not None]
    if not days:
        return None
    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_on_market
    # (иначе не рисуем, не выдумываем «4-118 дней», #pdf-honesty).
    days_range = _days_on_market_range(estimate.analogs)
    range_bar = _price_range_chart_svg(
        estimate.range_low_rub,
        estimate.range_high_rub,
        sub_label="Рынок",
        days_min=days_range[0] if days_range else None,
        days_max=days_range[1] if days_range else None,
        show_days=days_range is not None,
    )

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

    heading_html = _section_heading("02", "РЫНОК КВАРТИР – АНАЛОГОВ ПО ОБЪЯВЛЕНИЯМ")

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

  {heading_html}

  <!-- Два колонки: левая (counts + источники), правая (params card + bracket-tr) -->
  <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;">{_mono(f"{n_total} шт.")}</td></tr>
        </table>
        <div style="margin-top:14pt;font-size:{_FS_SM};color:{_MUTED};">
          <span class="bullet-dot" style="margin-right:5pt;"></span>Источники данных</div>
        <div style="margin-top:6pt;overflow-wrap:anywhere;">{sources_html}</div>
      </td>
      <td style="width:45%;vertical-align:top;">
        <div class="card-glass bracket-tr" style="padding:10pt 12pt 9pt 12pt;
             overflow-wrap:anywhere;">
          <table class="search-params" style="width:100%;border-collapse:collapse;">
            <tr><td class="dotted-row">Расположение</td>
                <td class="bold dotted-row">± 1 км от локации дома</td></tr>
            <tr><td class="dotted-row">Тип дома</td>
                <td class="bold dotted-row">{house_label}</td></tr>
            <tr><td class="dotted-row">Год постройки</td>
                <td class="bold dotted-row">{_mono(year_range)}</td></tr>
            <tr><td class="dotted-row">Диапазон площади</td>
                <td class="bold dotted-row">{_mono(f"{area_min} - {area_max} м²")}</td></tr>
            <tr><td class="dotted-row">Планировка</td>
                <td class="bold dotted-row">{rooms_label}</td></tr>
            <tr><td>Состояние ремонта</td><td class="bold">{repair_label}</td></tr>
          </table>
        </div>
      </td>
    </tr>
  </table>

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

  <!-- Примеры аналогов -->
  <h3 style="margin-top:16pt;font-size:{_FS_MD};font-weight:700;">
    Примеры аналогичных объектов с учётом ремонта
  </h3>
  <table class="examples-table" style="width:100%;border-collapse:collapse;
         table-layout:fixed;margin-top:6pt;">
    {_examples_table_head()}
    <tbody>{examples_rows}</tbody>
  </table>

</div>
"""


# colgroup общий для examples-table (Listings/Deals) — table-layout:fixed +
# явные проценты вместо auto-layout, который на длинных адресах мог раздувать
# таблицу шире полосы набора (переполнение страницы читается как «наложение»).
_EXAMPLES_COLGROUP = (
    '<colgroup><col style="width:38%;"><col style="width:14%;">'
    '<col style="width:16%;"><col style="width:18%;"><col style="width:14%;"></colgroup>'
)


def _examples_table_head() -> str:
    """<colgroup>+<thead> общий для _build_listings_page/_build_deals_page —
    одна и та же структура таблицы примеров на обеих страницах."""
    return f"""{_EXAMPLES_COLGROUP}
    <thead>
      <tr style="border-bottom:2pt solid {_BORDER_STRONG};">
        <th style="text-align:left;padding:6pt 4pt;font-size:{_FS_SM};color:{_MUTED};
            font-weight:700;">Адрес квартиры</th>
        <th style="text-align:left;padding:6pt 4pt;font-size:{_FS_SM};color:{_MUTED};
            font-weight:700;">Источник</th>
        <th style="text-align:right;padding:6pt 4pt;font-size:{_FS_SM};color:{_MUTED};
            font-weight:700;">Стоимость 1 м², руб.</th>
        <th style="text-align:right;padding:6pt 4pt;font-size:{_FS_SM};color:{_MUTED};
            font-weight:700;">Стоимость объекта, руб.</th>
        <th style="text-align:right;padding:6pt 4pt;font-size:{_FS_SM};color:{_MUTED};
            font-weight:700;">Срок экспозиции, дней</th>
      </tr>
    </thead>"""


def _examples_rows(lots: list[AnalogLot]) -> str:
    if not lots:
        return (
            "<tr><td colspan='5' class='empty' "
            f"style='text-align:center;padding:14pt;color:{_MUTED};'>Нет данных</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:{_ACCENT};text-decoration:none;overflow-wrap:anywhere;'>"
                f"{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 "—"
        # Адрес/источник — data-derived, БЕЗ white-space:nowrap, с overflow-wrap:anywhere
        # (длинный адрес/название ЖК без пробелов не должен раздвигать колонку —
        # см. _EXAMPLES_COLGROUP). ₽/м²/цена/срок — числовые/bounded, nowrap уместен.
        rows.append(
            "<tr>"
            f"<td style='padding:7pt 4pt;border-bottom:1px solid {_BORDER};"
            f"overflow-wrap:anywhere;'>{addr_cell}</td>"
            f"<td style='padding:7pt 4pt;border-bottom:1px solid {_BORDER};"
            f"overflow-wrap:anywhere;'>{_source_badge_inline(lot.source)}</td>"
            f"<td style='padding:7pt 4pt;border-bottom:1px solid {_BORDER};text-align:right;"
            f"white-space:nowrap;'>{_mono(str(ppm2_cell))}</td>"
            f"<td style='padding:7pt 4pt;border-bottom:1px solid {_BORDER};text-align:right;"
            f"white-space:nowrap;'>{_mono(str(price_cell))}</td>"
            f"<td style='padding:7pt 4pt;border-bottom:1px solid {_BORDER};text-align:right;"
            f"white-space:nowrap;'>{_mono(str(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)

    # Баннер дисконта ссылается на РЕАЛЬНЫЙ рассчитанный дисконт запрос→продажа
    # (тот же _discount_pct, что chip «−N%» на обложке), а не хардкод «10–18%»,
    # который противоречил бы вычисленному «−N%» в том же PDF. Убран и ложный
    # хвост «(Екатеринбург, 2026)» — локация не привязана к объекту (#pdf-honesty).
    discount_pct = _discount_pct(estimate)
    if discount_pct is not None:
        deals_discount_text = (
            "По данным реальных сделок, квартиры продаются дешевле, чем заявлено в "
            f"объявлениях — по этому объекту на {discount_pct}%"
        )
    else:
        deals_discount_text = (
            "По данным реальных сделок, квартиры продаются дешевле, чем заявлено в объявлениях"
        )

    # Источники для сделок — берём из 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_chart_svg(
        deals_low,
        deals_high,
        sub_label="Рынок",
    )

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

    heading_html = _section_heading("03", "ФАКТИЧЕСКИЕ СДЕЛКИ ПО КВАРТИРАМ — АНАЛОГАМ")

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

  {heading_html}

  <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;">{_mono(f"{n_deals} шт.")}</td></tr>
          <tr><td style="padding:4pt 0;">Период сделок</td>
              <td class="bold" style="text-align:right;">
                {_mono(f"{period_start.strftime('%m.%Y')} – {today.strftime('%m.%Y')}")}</td></tr>
        </table>
        <div style="margin-top:14pt;font-size:{_FS_SM};color:{_MUTED};">
          <span class="bullet-dot" style="margin-right:5pt;"></span>Источники данных</div>
        <div style="margin-top:6pt;overflow-wrap:anywhere;">{sources_html}</div>
      </td>
      <td style="width:45%;vertical-align:top;">
        <div class="card-glass bracket-tr" style="padding:10pt 12pt 9pt 12pt;
             overflow-wrap:anywhere;">
          <table class="search-params" style="width:100%;border-collapse:collapse;">
            <tr><td class="dotted-row">Расположение</td>
                <td class="bold dotted-row">± 1 км от локации дома</td></tr>
            <tr><td class="dotted-row">Тип дома</td>
                <td class="bold dotted-row">{house_label}</td></tr>
            <tr><td class="dotted-row">Год постройки</td>
                <td class="bold dotted-row">{_mono(year_range)}</td></tr>
            <tr><td class="dotted-row">Диапазон площади</td>
                <td class="bold dotted-row">{_mono(f"{area_min} - {area_max} м²")}</td></tr>
            <tr><td>Планировка</td><td class="bold">{rooms_label}</td></tr>
          </table>
        </div>
      </td>
    </tr>
  </table>

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

  <div style="margin-top:14pt;padding:10pt 14pt;border-left:3pt solid {_DANGER};
       background:{_DANGER_SOFT};font-size:{_FS_MD};color:{_DANGER};font-weight:600;">
    {deals_discount_text}
  </div>

  <h3 style="margin-top:16pt;font-size:{_FS_MD};font-weight:700;">
    Примеры аналогичных объектов
  </h3>
  <table class="examples-table" style="width:100%;border-collapse:collapse;
         table-layout:fixed;margin-top:6pt;">
    {_examples_table_head()}
    <tbody>{examples_rows}</tbody>
  </table>

</div>
"""


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


def _build_offer_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str:  # type: ignore[no-untyped-def,type-arg]
    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)

    # Data-consistency fix: sold_price теперь зеркалит cover (_dual_price_block) — берём
    # estimate.expected_sold_price_rub (та же реальная цифра по ДКП Росреестра,
    # что на cover в «Ожидаемая цена продажи»), а не независимый плоский 10%
    # хайркат от медианы. Раньше стр.1 и стр.4 одного и того же PDF могли
    # показывать разные цифры (расхождение 10%+) — тот же класс бага, что и
    # _discount_pct/_dual_price_block graceful fallback ниже.
    sold = estimate.expected_sold_price_rub
    if sold is not None and sold > 0:
        sold_price = sold
    else:
        # Fallback только для старых оценок без sold-данных (None/<=0).
        sold_price = listing_price - int(listing_price * 0.10)

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

    # 3 месяца аренды квартиры, ЕКБ 2026 (базовая ставка — студия/1-комн., ~28-45
    # тыс. руб./мес.), масштабируется по числу комнат объекта (#pdf-honesty: раньше
    # был плоский 1-комнатный тариф для ЛЮБОГО объекта, включая 3-4-комнатные).
    # Ориентировочные месячные ставки аренды по комнатности, ЕКБ 2026:
    #   студия/1к ~28-45k, 2к ~40-60k, 3к ~55-80k, 4к+ ~75-110k.
    # Множители — грубое отношение верхних границ этих диапазонов к базовой (1к).
    rooms = int(input_snapshot.get("rooms", 0) or 0)
    if rooms <= 1:
        rent_multiplier = 1.0
    elif rooms == 2:
        rent_multiplier = 1.35
    elif rooms == 3:
        rent_multiplier = 1.8
    else:
        rent_multiplier = 2.4
    rent_base_low, rent_base_high = 84_000, 135_000
    rent_low = round(rent_base_low * rent_multiplier / 1000) * 1000
    rent_high = round(rent_base_high * rent_multiplier / 1000) * 1000
    # Юридическое сопровождение сделки — диапазон (не фикс), варьируется по объёму работ.
    juridical_low, juridical_high = 15_000, 50_000
    # Реклама/продвижение за 3 месяца — низкая уверенность в оценке (нет единого
    # ЕКБ-агрегированного источника), см. sub-caption "ориентировочно" в таблице.
    ads_low, ads_high = 6_000, 45_000

    total_low = torg_low + rieltor_low + rent_low + juridical_low + ads_low
    total_high = torg_high + rieltor_high + rent_high + juridical_high + 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 -->

  {_section_heading("04", "ФОРМИРОВАНИЕ ВЫКУПНОЙ СТОИМОСТИ")}

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

  <table class="offer-table" style="width:100%;border-collapse:collapse;table-layout:fixed;">
    <colgroup><col style="width:42%;"><col style="width:16%;"><col style="width:42%;"></colgroup>
    <thead>
      <tr style="border-bottom:2pt solid {_BORDER_STRONG};">
        <th style="text-align:left;padding:8pt 4pt;font-size:{_FS_SM};color:{_MUTED};
            font-weight:700;"></th>
        <th style="text-align:right;padding:8pt 4pt;font-size:{_FS_SM};color:{_MUTED};
            font-weight:700;overflow-wrap:anywhere;">
          {trade_in_label}
        </th>
        <th style="text-align:right;padding:8pt 4pt;font-size:{_FS_SM};color:{_MUTED};
            font-weight:700;">
          Самостоятельная продажа, руб.
        </th>
      </tr>
    </thead>
    <tbody>
      <tr style="border-bottom:1px solid {_BORDER};">
        <td style="padding:6pt 4pt;">
          <div class="bold">Торг потенциальных покупателей</div>
          <div style="font-size:{_FS_XS};color:{_MUTED};">при стоимости квартиры в объявлении
            {_mono(_fmt_rub(listing_price))}</div>
        </td>
        <td style="padding:6pt 4pt;text-align:right;color:{_MUTED};">Не применимо</td>
        <td style="padding:6pt 4pt;text-align:right;font-weight:700;color:{_DANGER};">
          от {_mono(f"{_fmt_rub(torg_low)} – {_fmt_rub(torg_high)}")}<br>
          <span style="font-size:{_FS_XS};color:{_MUTED};font-weight:400;">
            {_mono(f"от {torg_pct_low}–{torg_pct_high}%")}</span>
        </td>
      </tr>
      <tr style="border-bottom:1px solid {_BORDER};">
        <td style="padding:6pt 4pt;">
          <div class="bold">Услуги риэлтора при продаже квартиры</div>
          <div style="font-size:{_FS_XS};color:{_MUTED};">с минимальным торгом за
            {_mono(_fmt_rub(sold_price))}</div>
        </td>
        <td style="padding:6pt 4pt;text-align:right;color:{_SUCCESS};font-weight:700;">
          бесплатно</td>
        <td style="padding:6pt 4pt;text-align:right;font-weight:700;">
          {_mono(f"{_fmt_rub(rieltor_low)} – {_fmt_rub(rieltor_high)}")}<br>
          <span style="font-size:{_FS_XS};color:{_MUTED};font-weight:400;">
            {_mono(f"от {rieltor_pct_low}–{rieltor_pct_high}%")}</span>
        </td>
      </tr>
      <tr style="border-bottom:1px solid {_BORDER};">
        <td style="padding:6pt 4pt;">
          <div class="bold">Аренда после сделки</div>
          <div style="font-size:{_FS_XS};color:{_MUTED};">
            {f"{rooms}-комн. квартиры на 3 месяца" if rooms > 0 else "квартиры на 3 месяца"}</div>
        </td>
        <td style="padding:6pt 4pt;text-align:right;color:{_SUCCESS};font-weight:700;">
          бесплатно</td>
        <td style="padding:6pt 4pt;text-align:right;font-weight:700;">
          {_mono(f"{_fmt_rub(rent_low)} – {_fmt_rub(rent_high)}")}
        </td>
      </tr>
      <tr style="border-bottom:1px solid {_BORDER};">
        <td style="padding:6pt 4pt;">
          <div class="bold">Юридическое сопровождение</div>
          <div style="font-size:{_FS_XS};color:{_MUTED};">Проверка документов и подготовка
            договоров</div>
        </td>
        <td style="padding:6pt 4pt;text-align:right;color:{_SUCCESS};font-weight:700;">
          бесплатно</td>
        <td style="padding:6pt 4pt;text-align:right;font-weight:700;">
          от {_mono(f"{_fmt_rub(juridical_low)} – {_fmt_rub(juridical_high)}")}</td>
      </tr>
      <tr style="border-bottom:2pt solid {_BORDER_STRONG};">
        <td style="padding:6pt 4pt;">
          <div class="bold">Расходы на рекламу</div>
          <div style="font-size:{_FS_XS};color:{_MUTED};">Ежемесячное базовое продвижение объекта
            на Циан, Авито, Я.Недвижимости</div>
        </td>
        <td style="padding:6pt 4pt;text-align:right;color:{_SUCCESS};font-weight:700;">
          бесплатно</td>
        <td style="padding:6pt 4pt;text-align:right;font-weight:700;">
          от {_mono(f"{_fmt_rub(ads_low)} – {_fmt_rub(ads_high)}")}<br>
          <span style="font-size:{_FS_XS};color:{_MUTED};font-weight:400;font-style:italic;">
            за 3 месяца, ориентировочно</span>
        </td>
      </tr>
      <tr style="background:{_DANGER_SOFT};">
        <td style="padding:8pt 4pt;font-size:{_FS_LG};font-weight:700;">
          Общие финансовые потери</td>
        <td style="padding:8pt 4pt;text-align:right;"></td>
        <td style="padding:8pt 4pt;text-align:right;font-size:{_FS_LG};font-weight:700;
            color:{_DANGER};">
          от {_mono(f"{_fmt_rub(total_low)} – {_fmt_rub(total_high)}")}
        </td>
      </tr>
    </tbody>
  </table>

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

  <h3 style="margin-top:18pt;font-size:{_FS_MD};font-weight:700;">Преимущества</h3>
  <!-- break-inside:avoid на самом grid-контейнере убран (был избыточен поверх
       break-inside:avoid у .advantage-item — см. verify-фикс) — при нехватке
       места это заставляло ВЕСЬ ряд из 4 карточек разом уезжать на новую страницу
       вместе со всем, что после него (disclaimer/footer), оставляя страницу почти
       пустой. .advantage-item сохраняет свой собственный break-inside:avoid (не
       разрывает карточку изнутри) — снятие внешнего avoid не меняет неделимость
       одной карточки, только позволяет самому grid не требовать себе гарантированно
       непрерывного места как единый блок. Было опробовано 2x2 (2 колонки вместо 4)
       — вдвое уменьшает неделимый кусок ряда, но удваивает суммарную высоту блока
       (2 строки вместо 1), что на практике регрессило нормальный (не «stress»)
       фикстур с новым переносом Offer-страницы, где раньше всё помещалось на одной
       — оставлена 4-колоночная раскладка.-->
  <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="{_ACCENT_2}"
             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="{_ACCENT_2}"
             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="{_ACCENT_2}"
             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="{_ACCENT_2}"
             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>

  <p style="margin-top:14pt;font-size:{_FS_XS};color:{_MUTED};">
    Расчёт носит ориентировочный характер и не является офертой.
  </p>

</div>
"""


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


def _build_css(brand=None) -> str:  # type: ignore[no-untyped-def]
    primary = brand.primary_color if brand else "#1d4ed8"
    return f"""
@font-face {{
    font-family: 'PT Sans';
    src: url({_PT_SANS_REGULAR_URL}) format('truetype');
    font-weight: 400;
    font-style: normal;
}}
@font-face {{
    font-family: 'PT Sans';
    src: url({_PT_SANS_BOLD_URL}) format('truetype');
    font-weight: 700;
    font-style: normal;
}}
@font-face {{
    font-family: 'PT Serif';
    src: url({_PT_SERIF_BOLD_URL}) format('truetype');
    font-weight: 700;
    font-style: normal;
}}
@font-face {{
    font-family: 'Manrope';
    src: url({_MANROPE_REGULAR_URL}) format('truetype');
    font-weight: 400;
    font-style: normal;
}}
@font-face {{
    font-family: 'Manrope';
    src: url({_MANROPE_BOLD_URL}) format('truetype');
    font-weight: 700;
    font-style: normal;
}}
@font-face {{
    font-family: 'IBM Plex Mono';
    src: url({_IBM_PLEX_MONO_REGULAR_URL}) format('truetype');
    font-weight: 400;
    font-style: normal;
}}
@font-face {{
    font-family: 'IBM Plex Mono';
    src: url({_IBM_PLEX_MONO_MEDIUM_URL}) format('truetype');
    font-weight: 500;
    font-style: normal;
}}
@page {{
    size: A4;
    margin: 25mm 18mm 19mm 18mm;
    @top-center {{ content: element(runningHeader); vertical-align: bottom; }}
    @bottom-center {{ content: element(runningFooter); vertical-align: top; }}
}}
.running-header {{ position: running(runningHeader); }}
.running-footer {{ position: running(runningFooter); }}
* {{ box-sizing: border-box; }}
body {{
    font-family: 'PT Sans', 'DejaVu Sans', sans-serif;
    font-size: {_FS_MD};
    color: {_INK};
    background: {_PAGE_BG};
    margin: 0;
    padding: 0;
    line-height: 1.35;
}}
h1, h2, h3 {{ margin: 0; }}
h1, h2 {{ font-family: 'PT Serif', 'DejaVu Serif', serif; }}
.page {{ page-break-after: always; position: relative; }}
.page:last-child {{ page-break-after: avoid; }}

.bold {{ font-weight: 700; }}
.mono, .mono * {{
    font-family: 'DejaVu Sans Mono', monospace;
    font-variant-numeric: tabular-nums;
}}

/* Params table (Cover) — border-bottom пришёл из .dotted-row (см. HUD-блок ниже),
   применяется точечно per-<td> в разметке (последняя строка — без разделителя,
   ObjectSummary.tsx идиома). overflow-wrap — значения ячейки (адрес и т.п.)
   data-derived, НЕ nowrap. */
.params-table td {{
    padding: 2pt 4pt;
    font-size: {_FS_SM};
    overflow-wrap: anywhere;
}}
.params-table td:first-child {{
    color: {_MUTED};
    width: 40%;
}}
.params-table td:last-child {{
    text-align: right;
}}

/* Search params (Listings/Deals) */
.search-params td {{
    padding: 4pt 0;
    font-size: {_FS_SM};
    overflow-wrap: anywhere;
}}
.search-params td:first-child {{
    color: {_MUTED};
}}
.search-params td:last-child {{
    text-align: right;
}}

/* Что важно — advice table */
.advice-table td {{
    padding: 3.5pt 6pt;
    border-bottom: 1px solid {_BORDER};
    vertical-align: top;
    font-size: {_FS_SM};
    line-height: 1.3;
}}
.advice-title {{
    font-weight: 700;
    width: 30%;
    color: {_FG};
}}
.advice-text {{
    color: {_MUTED};
}}

/* Examples table (Listings/Deals) — не разрывать <tr> между страницами: длинный
   адрес без break-inside:avoid оставляет badge/цену на одной странице, а адрес
   на следующей (см. verify-фикс, #1531/#1532 таблица). */
.examples-table tr {{
    break-inside: avoid;
    page-break-inside: avoid;
}}

/* Advantages (Offer) — тёплый accent-2 вместо generic red-tinted кругов */
.advantage-item {{
    border: 1px solid {_BORDER};
    border-radius: 4pt;
    padding: 8pt;
    text-align: left;
    break-inside: avoid;
    page-break-inside: avoid;
}}
.advantage-item .adv-ic {{
    display: block;
    width: 28pt;
    height: 28pt;
    border-radius: 50%;
    background: {_ACCENT_2_SOFT};
    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: {_FS_XS};
    margin-bottom: 3pt;
    color: {primary};
    letter-spacing: -0.01em;
}}
.advantage-item .adv-desc {{
    font-size: {_FS_XS};
    color: {_MUTED};
    line-height: 1.3;
    overflow-wrap: anywhere;
}}

/* ── Mera v2 HUD chrome (tokens.ts / ObjectSummary.tsx / HeroBar.tsx) ─────────
   Wired into all 4 page-builders this stage (_build_cover/_build_listings_page/
   _build_deals_page/_build_offer_page + shared _section_heading/_quality_card_html/
   _source_logo_pill/_price_figure_cell) — card-glass+bracket-tr on price/summary
   cards, section-badge on every page heading, status-pill (freshness, Cover),
   source-pill (Listings/Deals), dotted-row (params-table/search-params rows),
   bullet-dot (source list label). */

/* Glass card (flattened — no backdrop-filter/blur, see _CARD_BG comment). */
.card-glass {{
    position: relative;
    background: {_CARD_BG};
    border: 1pt solid {_LINE_2};
    border-radius: 6pt;
}}

/* Corner-bracket accent — small absolutely-positioned marks, NOT clip-path
   (WeasyPrint clip-path support is unreliable). Apply to a `position:relative`
   ancestor; one or more corners per card, see HeroBar.tsx (4 corners) vs
   ObjectSummary.tsx (single top-right corner). */
.bracket-tl::before, .bracket-tr::before, .bracket-bl::before, .bracket-br::before {{
    content: "";
    position: absolute;
    width: 11pt;
    height: 11pt;
}}
.bracket-tl::before {{ top: -1pt; left: -1pt;
    border-top: 1.5pt solid {_BRACKET}; border-left: 1.5pt solid {_BRACKET}; }}
.bracket-tr::before {{ top: -1pt; right: -1pt;
    border-top: 1.5pt solid {_BRACKET}; border-right: 1.5pt solid {_BRACKET}; }}
.bracket-bl::before {{ bottom: -1pt; left: -1pt;
    border-bottom: 1.5pt solid {_BRACKET}; border-left: 1.5pt solid {_BRACKET}; }}
.bracket-br::before {{ bottom: -1pt; right: -1pt;
    border-bottom: 1.5pt solid {_BRACKET}; border-right: 1.5pt solid {_BRACKET}; }}

/* Numbered mono section badge — "03" next to a tracked-uppercase heading
   (ObjectSummary.tsx). */
.section-badge {{
    font-family: 'IBM Plex Mono', 'DejaVu Sans Mono', monospace;
    font-size: {_FS_MD};
    color: {_ACCENT};
}}

/* Status pill — dot + uppercase text, soft border, fully rounded
   (ObjectSummary.tsx "АКТУАЛЬНО"). Default tint is success; override color/
   border-color inline for warn/danger variants. */
.status-pill {{
    display: inline-flex;
    align-items: center;
    gap: 4pt;
    font-size: {_FS_XS};
    letter-spacing: 0.05em;
    text-transform: uppercase;
    color: {_SUCCESS};
    border: 1pt solid {_SUCCESS_BORDER};
    border-radius: 20pt;
    padding: 2pt 7pt;
}}
.status-pill::before {{
    content: "";
    display: inline-block;
    width: 5pt;
    height: 5pt;
    border-radius: 50%;
    background: currentColor;
}}

/* Source pill — soft-tinted geometry, replacing today's solid-colour chips
   (_source_logo_pill/_source_badge_inline still emit the old solid style this
   stage — the per-source soft background/text colour is computed there, not
   here; this class only supplies the shared shape/typography). */
.source-pill {{
    display: inline-block;
    padding: 2pt 8pt;
    border-radius: 10pt;
    font-size: {_FS_XS};
    font-weight: 600;
    border: 1pt solid {_LINE_2};
}}

/* Dotted row separator (ObjectSummary.tsx summary rows). */
.dotted-row {{
    border-bottom: 1pt dotted {_LINE_DOTTED};
}}

/* Small colored bullet dot before row labels (ObjectSummary.tsx). */
.bullet-dot {{
    display: inline-block;
    width: 5pt;
    height: 5pt;
    border-radius: 50%;
    background: {_ACCENT};
    vertical-align: middle;
}}
"""


# ── 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)
    today = dt.date.today()
    return f"""
<div>
  {_page_header(brand, report_num, today)}
  <p style="font-size:{_FS_SM};color:{_MUTED};margin:0 0 18pt 0;">
    № {_mono(report_num)} · {_mono(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;color:{_MUTED};">—</div>
    <h2 style="font-family:'PT Serif','DejaVu Serif',serif;font-size:{_FS_XL};font-weight:700;
        color:{_FG};margin-bottom:8pt;">
      Недостаточно данных для оценки
    </h2>
    <p style="font-size:{_FS_MD};color:{_MUTED};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 отчёт Trade-In (собственная визуальная система).

    Если 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.
    # FontConfiguration берём через атрибут (не отдельный `from weasyprint.text.fonts
    # import ...`) — тесты подменяют sys.modules['weasyprint'] плоским MagicMock без
    # реальных субмодулей, а точечный submodule-импорт на моке падает с
    # "'weasyprint' is not a package"; атрибутный доступ работает и на Mock, и на
    # реальном пакете (weasyprint/__init__.py уже импортирует .text.fonts внутри себя).
    import weasyprint
    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:
        # Running header/footer — единый HUD-хром печатается WeasyPrint'ом в page
        # margin-boxes (@top-center/@bottom-center, см. _build_css) на КАЖДОЙ странице
        # документа, а не только там, где явно вставлен в поток секции-билдера.
        # Рендерится один раз здесь (не внутри каждого _build_*_page) — иначе WeasyPrint
        # печатает независимый running-элемент с последнего "видимого" перед разрывом
        # страницы, что дало бы 4 разных чанка вместо одного персистентного хрома.
        _chrome_report_num = _report_number(estimate)
        _chrome_today = dt.date.today()
        _chrome_expires = _expires_date(estimate)
        chrome_html = (
            f'<div class="running-header">'
            f"{_page_header(brand, _chrome_report_num, _chrome_today)}</div>"
            f'<div class="running-footer">'
            f"{_page_footer(brand, _chrome_report_num, _chrome_today, valid_until=_chrome_expires)}"
            f"</div>"
        )
        body_html = (
            chrome_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, input_snapshot, 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)
    # FontConfiguration обязателен для того, чтобы @font-face (PT Sans/PT Serif, см.
    # _build_css) реально зарегистрировались и использовались при layout — без него
    # WeasyPrint молча игнорирует @font-face и подставляет fallback из font-stack
    # (DejaVu). Один и тот же объект передаётся в CSS(...) и write_pdf(...).
    font_config = weasyprint.text.fonts.FontConfiguration()
    # SSRF guard (#13): кастомный url_fetcher блокирует file:// и нелоговые схемы.
    # base_url=None — не resolve никакие относительные URL (все ресурсы data: или https:).
    # url_fetcher передаётся в HTML(...) и CSS(...) конструкторы, а НЕ в write_pdf() —
    # в WeasyPrint 69 write_pdf(url_fetcher=...) молча игнорируется
    # ("Unknown rendering option"), а @font-face-ресолвинг внутри CSS(...) происходит
    # синхронно при конструировании, до write_pdf(). Один и тот же fetcher — на оба
    # объекта, иначе guard не покрывает CSS-ресурсы (сейчас только data:-шрифты, но
    # это тот путь, по которому в будущем может прилететь brand-specific URL).
    url_fetcher = _make_safe_url_fetcher()
    pdf_bytes = HTML(string=html_str, base_url=None, url_fetcher=url_fetcher).write_pdf(
        stylesheets=[CSS(string=css_str, font_config=font_config, url_fetcher=url_fetcher)],
        font_config=font_config,
    )
    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
