"""#648 S5b — dual-price display in the estimate PDF.

Mirrors the web HeroSummary (S5a): the cover must show BOTH «Ориентир запроса»
(asking median) and «Ожидаемая цена продажи» (expected_sold) with equal
prominence, the explainer, and a −N% discount chip — and gracefully fall back
to the asking figure alone when expected_sold is None/0 (old estimates / empty
ratio bucket).

Tests target the pure HTML builders (`_discount_pct`, `_dual_price_block`,
`_build_cover`), which do not require WeasyPrint's native libs — the import of
WeasyPrint inside `generate_trade_in_pdf` is lazy, so the HTML-building path is
exercisable without the GTK/pango/cairo stack (unavailable in CI / on macOS).
"""

from __future__ import annotations

import os
import sys
from datetime import UTC, datetime, timedelta
from html.parser import HTMLParser
from typing import ClassVar
from unittest.mock import MagicMock
from uuid import uuid4

# DATABASE_URL required by config before any app import (см. test_dadata.py).
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")

# WeasyPrint stub — not installed in CI без GTK (consistent с test_dadata.py).
# The render-path import is lazy, but settings/other modules may still touch it.
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)

from app.schemas.trade_in import AggregatedEstimate, AnalogLot  # noqa: E402
from app.services.brand import Brand  # noqa: E402
from app.services.exporters import trade_in_pdf as mod  # noqa: E402

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

_SNAPSHOT = {
    "address": "Екатеринбург, ул. Ленина, 1",
    "area_m2": 50.0,
    "rooms": 2,
    "floor": 3,
    "total_floors": 9,
    "year_built": 2010,
    "house_type": "panel",
    "repair_state": "standard",
    "has_balcony": True,
}


def _estimate(**overrides) -> AggregatedEstimate:
    base = dict(
        estimate_id=uuid4(),
        median_price_rub=10_000_000,
        range_low_rub=9_000_000,
        range_high_rub=11_000_000,
        median_price_per_m2=200_000,
        confidence="high",
        n_analogs=15,
        period_months=24,
        analogs=[],
        actual_deals=[],
        expires_at=datetime.now(UTC) + timedelta(days=30),
    )
    base.update(overrides)
    return AggregatedEstimate(**base)


class _WellFormed(HTMLParser):
    """Minimal balanced-tag check: every non-void tag that opens must close."""

    _VOID: ClassVar[set[str]] = {
        "br",
        "img",
        "hr",
        "meta",
        "input",
        "rect",
        "path",
        "circle",
        "line",
        "polyline",
    }

    def __init__(self) -> None:
        super().__init__(convert_charrefs=True)
        self.stack: list[str] = []
        self.ok = True

    def handle_starttag(self, tag: str, attrs: list) -> None:
        if tag not in self._VOID:
            self.stack.append(tag)

    def handle_endtag(self, tag: str) -> None:
        if tag in self._VOID:
            return
        if self.stack and self.stack[-1] == tag:
            self.stack.pop()
        elif tag in self.stack:
            # tolerate minor nesting slack but flag truly broken close
            while self.stack and self.stack[-1] != tag:
                self.stack.pop()
            if self.stack:
                self.stack.pop()
        else:
            self.ok = False


def _assert_well_formed(fragment: str) -> None:
    p = _WellFormed()
    p.feed(f"<root>{fragment}</root>")
    p.close()
    assert p.ok, "unbalanced tags in HTML fragment"


# ── _discount_pct ────────────────────────────────────────────────────────────


def test_discount_pct_from_ratio() -> None:
    # ratio 0.74 → −26%
    est = _estimate(expected_sold_price_rub=7_400_000, asking_to_sold_ratio=0.74)
    assert mod._discount_pct(est) == 26


def test_discount_pct_from_numbers_when_no_ratio() -> None:
    # sold/median = 9.0/10.0 → −10%
    est = _estimate(expected_sold_price_rub=9_000_000, asking_to_sold_ratio=None)
    assert mod._discount_pct(est) == 10


def test_discount_pct_none_when_no_sold() -> None:
    assert mod._discount_pct(_estimate(expected_sold_price_rub=None)) is None
    assert mod._discount_pct(_estimate(expected_sold_price_rub=0)) is None


def test_discount_pct_none_when_below_one_percent() -> None:
    # ratio 0.997 → rounds to 0% → hidden
    est = _estimate(expected_sold_price_rub=9_970_000, asking_to_sold_ratio=0.997)
    assert mod._discount_pct(est) is None


# ── _dual_price_block ──────────────────────────────────────────────────────────


def test_dual_block_with_sold_shows_both_figures_and_explainer() -> None:
    est = _estimate(
        expected_sold_price_rub=8_000_000,
        expected_sold_range_low_rub=7_500_000,
        expected_sold_range_high_rub=8_500_000,
        expected_sold_per_m2=160_000,
        asking_to_sold_ratio=0.80,
    )
    html = mod._dual_price_block(est, _BRAND)
    _assert_well_formed(html)
    assert "Ориентир запроса" in html
    assert "Ожидаемая цена продажи" in html
    # explainer wording (web parity)
    assert "реалистичная цена сделки по данным ДКП" in html
    assert "обычно ниже запроса" in html
    # discount chip −20% (minus sign rendered as entity)
    assert "&#8722;20%" in html
    # both asking + sold values present (10.0 / 8.0 млн)
    assert "10,0 млн. руб." in html
    assert "8,0 млн. руб." in html
    # sold range + per-m² present
    assert "7,5 млн. руб." in html and "8,5 млн. руб." in html
    assert "160 000" in html  # expected_sold_per_m2 via _fmt_ppm2


def test_dual_block_graceful_without_sold_is_single_figure() -> None:
    html = mod._dual_price_block(_estimate(expected_sold_price_rub=None), _BRAND)
    _assert_well_formed(html)
    assert "Ориентир запроса" in html
    # no sold column / explainer / chip
    assert "Ожидаемая цена продажи" not in html
    assert "ДКП" not in html
    assert "&#8722;" not in html
    # single column stretched full-width
    assert "width:100%" in html
    assert "width:50%" not in html


def test_dual_block_zero_sold_treated_as_absent() -> None:
    html = mod._dual_price_block(_estimate(expected_sold_price_rub=0), _BRAND)
    assert "Ожидаемая цена продажи" not in html


def test_dual_block_guards_each_subfield_independently() -> None:
    # sold present but range/per-m² missing — value still shown, sub-rows omitted
    est = _estimate(
        expected_sold_price_rub=8_000_000,
        expected_sold_range_low_rub=None,
        expected_sold_range_high_rub=None,
        expected_sold_per_m2=None,
        asking_to_sold_ratio=0.80,
    )
    html = mod._dual_price_block(est, _BRAND)
    _assert_well_formed(html)
    # sold headline value still shown
    assert "Ожидаемая цена продажи" in html
    assert "8,0 млн. руб." in html
    # asking column keeps its own per-m² (200 000); sold per-m² omitted entirely
    assert "200 000" in html
    assert "160 000" not in html
    # sold range absent (low/high were None) — only the asking range 9/11 млн remains
    assert "7,5 млн. руб." not in html and "8,5 млн. руб." not in html
    assert "9,0 млн. руб." in html and "11,0 млн. руб." in html


# ── _build_cover integration (HTML only, no WeasyPrint render) ─────────────────


def test_build_cover_with_sold_contains_dual_block() -> None:
    est = _estimate(
        expected_sold_price_rub=8_000_000,
        expected_sold_range_low_rub=7_500_000,
        expected_sold_range_high_rub=8_500_000,
        expected_sold_per_m2=160_000,
        asking_to_sold_ratio=0.80,
    )
    html = mod._build_cover(est, _SNAPSHOT, _BRAND)
    _assert_well_formed(html)
    assert "Ориентир запроса" in html
    assert "Ожидаемая цена продажи" in html
    # existing sections untouched
    assert "Диапазон цен в объявлениях" in html
    # #pdf-empty-pages: дубль-бар «по фактическим сделкам» убран с cover
    # (остался на стр. 03) — cover ужат до одной A4-страницы.
    assert "Диапазон цен по фактическим сделкам" not in html


def test_build_cover_without_sold_renders_only_asking() -> None:
    html = mod._build_cover(_estimate(), _SNAPSHOT, _BRAND)
    _assert_well_formed(html)
    assert "Ориентир запроса" in html
    assert "Ожидаемая цена продажи" not in html
    # existing layout intact
    assert "Диапазон цен в объявлениях" in html
    # #pdf-empty-pages: дубль-бар «по фактическим сделкам» убран с cover (см. выше).
    assert "Диапазон цен по фактическим сделкам" not in html


# ── _days_on_market_range — no fabricated (4, 118) corridor (#pdf-honesty) ─────


def _lot(days_on_market: int | None, price_rub: int = 10_000_000) -> AnalogLot:
    return AnalogLot(
        address="Екатеринбург, ул. Ленина, 1",
        area_m2=50.0,
        rooms=2,
        floor=3,
        total_floors=9,
        price_rub=price_rub,
        price_per_m2=int(price_rub / 50),
        listing_date=None,
        days_on_market=days_on_market,
    )


def test_days_range_none_when_no_lots() -> None:
    # Раньше возвращал хардкод (4, 118) — выдуманный срок экспозиции.
    assert mod._days_on_market_range([]) is None


def test_days_range_none_when_all_days_missing() -> None:
    assert mod._days_on_market_range([_lot(None), _lot(None)]) is None


def test_days_range_min_max_when_data_present() -> None:
    assert mod._days_on_market_range([_lot(12), _lot(None), _lot(88)]) == (12, 88)


def test_cover_without_days_data_still_renders_range_chart() -> None:
    # Нет days_on_market → show_days=False, но ценовой бар всё равно строится.
    est = _estimate(analogs=[_lot(None)])
    html = mod._build_cover(est, _SNAPSHOT, _BRAND)
    _assert_well_formed(html)
    assert "Диапазон цен в объявлениях" in html


# ── _report_number — city-aware prefix, never hardcoded «EKБ» (#pdf-honesty) ───


def test_report_number_ekb_prefix_for_ekb_address() -> None:
    est = _estimate(target_address="Екатеринбург, ул. Ленина, 1")
    assert mod._report_number(est).startswith("ЕКБ-")


def test_report_number_city_prefix_for_non_ekb_address() -> None:
    assert mod._report_number(_estimate(target_address="Нижний Тагил, ул. Мира, 5")).startswith(
        "НТ-"
    )
    assert mod._report_number(_estimate(target_address="Серов, ул. Ленина, 3")).startswith("СЕР-")


def test_report_number_neutral_prefix_when_city_unknown() -> None:
    # None адрес и адрес вне региона → нейтральный «МЕРА», НЕ ложный «EKБ».
    assert mod._report_number(_estimate(target_address=None)).startswith("МЕРА-")
    assert mod._report_number(_estimate(target_address="Москва, ул. Тверская, 1")).startswith(
        "МЕРА-"
    )


def test_report_number_never_hardcodes_old_ekb_literal() -> None:
    # Старый баг: «EKБ-» (латинские EK + кириллическая Б) для ЛЮБОГО адреса.
    for addr in (None, "Серов, ул. Ленина, 3", "Москва, ул. Тверская, 1"):
        assert not mod._report_number(_estimate(target_address=addr)).startswith("EK")


def test_report_number_falls_back_to_canonical_address() -> None:
    est = _estimate(target_address=None, canonical_address="Первоуральск, ул. Вайнера, 2")
    assert mod._report_number(est).startswith("ПРВ-")


# ── Static «10–18%» haircut replaced by computed «−N%» (#pdf-honesty) ──────────


def test_cover_advice_uses_computed_discount_not_static_range() -> None:
    est = _estimate(expected_sold_price_rub=8_000_000, asking_to_sold_ratio=0.80)  # −20%
    html = mod._build_cover(est, _SNAPSHOT, _BRAND)
    assert "10–18%" not in html
    assert "на 20%" in html


def test_cover_advice_neutral_when_no_discount() -> None:
    html = mod._build_cover(_estimate(expected_sold_price_rub=None), _SNAPSHOT, _BRAND)
    assert "10–18%" not in html
    assert "проходят ниже цен в объявлениях" in html


def test_deals_banner_uses_computed_discount_not_static_range() -> None:
    est = _estimate(expected_sold_price_rub=8_000_000, asking_to_sold_ratio=0.80)  # −20%
    html = mod._build_deals_page(est, _SNAPSHOT, _BRAND)
    _assert_well_formed(html)
    assert "10–18%" not in html
    # Ложный локейшн-хвост «(Екатеринбург, 2026)» удалён.
    assert "Екатеринбург, 2026" not in html
    assert "на 20%" in html


def test_deals_banner_neutral_when_no_discount() -> None:
    html = mod._build_deals_page(_estimate(expected_sold_price_rub=None), _SNAPSHOT, _BRAND)
    _assert_well_formed(html)
    assert "10–18%" not in html
    assert "продаются дешевле, чем заявлено" in html


# ── _build_offer_page — rent scales with room count, not a flat 1-room figure ──
# (#pdf-honesty LOW audit R2 #5: «Общие финансовые потери» hardcoded a 1-room
# 3-month rent for EVERY object, including 3-4-room ones.)


def test_offer_page_rent_scales_up_for_larger_objects() -> None:
    est = _estimate()
    snapshot_1room = {**_SNAPSHOT, "rooms": 1}
    snapshot_3room = {**_SNAPSHOT, "rooms": 3}

    html_1room = mod._build_offer_page(est, snapshot_1room, _BRAND)
    html_3room = mod._build_offer_page(est, snapshot_3room, _BRAND)
    _assert_well_formed(html_1room)
    _assert_well_formed(html_3room)

    # Base (1-room) rent range unchanged: 84 000 – 135 000.
    assert mod._fmt_rub(84_000) in html_1room
    assert mod._fmt_rub(135_000) in html_1room
    # 3-room rent scaled up (×1.8 heuristic): 151 000 – 243 000.
    assert mod._fmt_rub(151_000) in html_3room
    assert mod._fmt_rub(243_000) in html_3room
    # The flat 1-room figure must NOT leak into the 3-room object's report.
    assert mod._fmt_rub(135_000) not in html_3room


def test_offer_page_rent_subtitle_reflects_room_count() -> None:
    est = _estimate()

    html_1room = mod._build_offer_page(est, {**_SNAPSHOT, "rooms": 1}, _BRAND)
    assert "1-комн. квартиры на 3 месяца" in html_1room
    assert "однокомнатной квартиры на 3 месяца" not in html_1room

    html_3room = mod._build_offer_page(est, {**_SNAPSHOT, "rooms": 3}, _BRAND)
    assert "3-комн. квартиры на 3 месяца" in html_3room

    # Unknown room count (0/missing) → neutral subtitle, no fabricated room number.
    html_unknown = mod._build_offer_page(est, {**_SNAPSHOT, "rooms": 0}, _BRAND)
    assert "квартиры на 3 месяца" in html_unknown
    assert "-комн. квартиры на 3 месяца" not in html_unknown
