"""PDF security + correctness tests (#7/#9/#13/#33).

Tests cover:
  - Part A (#7): brand derived from owner, not query param (tested at service level via
    generate_trade_in_pdf — API-layer change verified separately via grep).
  - Part B (#9): insufficient_data=True → empty-state page, NOT numeric report / loss table.
  - Part C (#8): pdf_disclaimer / logo_url from brand object rendered on cover.
  - Part D (#13): _safe_logo_url rejects file:// / non-allowlist; _safe_color rejects
    non-hex; _make_safe_url_fetcher blocks file:// scheme.
  - Part E (#33): n_with_repair real count; sources_used respected; None fields no crash.

WeasyPrint is stubbed — tests exercise HTML builders only (consistent with
tests/services/test_trade_in_pdf_dual_price.py pattern).
"""

from __future__ import annotations

import os
import sys
from datetime import UTC, datetime, timedelta
from unittest.mock import MagicMock, patch
from uuid import uuid4

os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")

_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)

import pytest  # noqa: E402

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

# ── Fixtures ─────────────────────────────────────────────────────────────────

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

_PRAKTIKA = Brand(
    slug="praktika",
    name="Практика",
    logo_url="https://gk-praktika.ru/images/logo/logo-pink.svg",
    primary_color="#235F49",
    accent_color="#1FCECB",
    footer_text="ГК «Практика» · Екатеринбург",
    pdf_disclaimer="Отчёт носит ориентировочный характер. ГК Практика, 2026.",
)

_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)


def _zero_estimate(**overrides) -> AggregatedEstimate:
    """Оценка с median=0 — insufficient_data=True."""
    base = dict(
        estimate_id=uuid4(),
        median_price_rub=0,
        range_low_rub=0,
        range_high_rub=0,
        median_price_per_m2=0,
        confidence="low",
        n_analogs=0,
        period_months=24,
        analogs=[],
        actual_deals=[],
        expires_at=datetime.now(UTC) + timedelta(days=30),
    )
    base.update(overrides)
    return AggregatedEstimate(**base)


# ── Part B (#9): insufficient_data guard ─────────────────────────────────────


def test_insufficient_data_renders_empty_state_not_price() -> None:
    """median=0 → _build_insufficient_data_page called, no numeric headline."""
    est = _zero_estimate()
    assert est.insufficient_data is True
    html = mod._build_insufficient_data_page(est, _SNAPSHOT, _GENERIC)
    assert "Недостаточно данных" in html
    # no fabricated price / loss table
    assert "млн. руб." not in html
    assert "Общие финансовые потери" not in html


def test_insufficient_data_page_contains_address_and_report_num() -> None:
    est = _zero_estimate()
    html = mod._build_insufficient_data_page(est, _SNAPSHOT, _GENERIC)
    assert "Ленина" in html  # address
    # #pdf-honesty: № отчёта city-aware — у оценки без target_address город не распознан
    # → нейтральный префикс «МЕРА» (раньше был захардкожен «EKБ» для любого объекта).
    assert "МЕРА-" in html
    assert "EKБ-" not in html  # старый mixed-script хардкод убран


def test_generate_pdf_insufficient_skips_offer_page() -> None:
    """generate_trade_in_pdf с insufficient_data → offer page (loss table) не рендерится."""
    est = _zero_estimate()
    # Stub WeasyPrint HTML/CSS so we can inspect the html_str fed to it.
    captured: list[str] = []

    class _FakeHTML:
        def __init__(self, string=None, base_url=None, **kw):
            captured.append(string or "")

        def write_pdf(self, stylesheets=None, url_fetcher=None, font_config=None):
            return b"%PDF-fake"

    class _FakeCSS:
        def __init__(self, string=None, font_config=None, url_fetcher=None):
            pass

    with patch.dict(sys.modules, {"weasyprint": MagicMock(HTML=_FakeHTML, CSS=_FakeCSS)}):
        import app.services.exporters.trade_in_pdf as _mod

        _mod.generate_trade_in_pdf(est, _SNAPSHOT, brand=_GENERIC)

    assert captured, "HTML was never passed to WeasyPrint mock"
    html = captured[0]
    assert "Недостаточно данных" in html
    assert "Общие финансовые потери" not in html
    assert "ФОРМИРОВАНИЕ ВЫКУПНОЙ СТОИМОСТИ" not in html


def test_generate_pdf_normal_estimate_has_offer_page() -> None:
    """Полная оценка → offer page присутствует."""
    est = _estimate()
    captured: list[str] = []

    class _FakeHTML:
        def __init__(self, string=None, base_url=None, **kw):
            captured.append(string or "")

        def write_pdf(self, stylesheets=None, url_fetcher=None, font_config=None):
            return b"%PDF-fake"

    class _FakeCSS:
        def __init__(self, string=None, font_config=None, url_fetcher=None):
            pass

    with patch.dict(sys.modules, {"weasyprint": MagicMock(HTML=_FakeHTML, CSS=_FakeCSS)}):
        import app.services.exporters.trade_in_pdf as _mod

        _mod.generate_trade_in_pdf(est, _SNAPSHOT, brand=_GENERIC)

    assert captured
    html = captured[0]
    assert "ФОРМИРОВАНИЕ ВЫКУПНОЙ СТОИМОСТИ" in html


# ── Part C (#8): disclaimer + logo_url in cover ──────────────────────────────


def test_cover_renders_pdf_disclaimer() -> None:
    est = _estimate()
    html = mod._build_cover(est, _SNAPSHOT, _PRAKTIKA)
    assert "Отчёт носит ориентировочный характер" in html


def test_cover_disclaimer_absent_when_none() -> None:
    est = _estimate()
    html = mod._build_cover(est, _SNAPSHOT, _GENERIC)
    # No stray disclaimer block
    assert "ориентировочный характер" not in html


def test_cover_renders_logo_img_when_valid_url() -> None:
    # #4-pages-fix: _page_header больше не встроен в _build_cover — он рендерится
    # ОДИН раз в generate_trade_in_pdf как running-header (position:running(...),
    # печатается WeasyPrint'ом в @top-center margin-box на каждой странице), см.
    # trade_in_pdf.py::generate_trade_in_pdf. Логотип/wordmark проверяем напрямую
    # через _page_header, не через _build_cover (симметрично уже существовавшему
    # паттерну в test_cover_falls_back_to_name_when_logo_none ниже).
    header_html = mod._page_header(_PRAKTIKA, "EKБ-0001-0000000001", datetime.now(UTC).date())
    assert "<img" in header_html
    assert "gk-praktika.ru" in header_html


def test_cover_falls_back_to_name_when_logo_none() -> None:
    # Logo-specific check via _page_header directly — не проверяем отсутствие <img> во
    # всей cover-странице, т.к. с #2327-follow-up диапазоны цен рендерятся как
    # matplotlib SVG <img> (см. _price_range_chart_svg), не связанные с логотипом бренда.
    header_html = mod._page_header(_GENERIC, "EKБ-0001-0000000001", datetime.now(UTC).date())
    assert "TRADE-IN" in header_html.upper()
    assert "<img" not in header_html


# ── Part D (#13): _safe_logo_url ─────────────────────────────────────────────


def test_safe_logo_url_accepts_allowlisted_https() -> None:
    url = "https://gk-praktika.ru/images/logo.svg"
    assert mod._safe_logo_url(url) == url


def test_safe_logo_url_rejects_file_scheme() -> None:
    assert mod._safe_logo_url("file:///etc/passwd") is None


def test_safe_logo_url_rejects_http() -> None:
    assert mod._safe_logo_url("http://gk-praktika.ru/logo.svg") is None


def test_safe_logo_url_rejects_unknown_domain() -> None:
    assert mod._safe_logo_url("https://evil.com/logo.svg") is None


def test_safe_logo_url_accepts_data_uri() -> None:
    data = "data:image/svg+xml;base64,PHN2Zy8+"
    assert mod._safe_logo_url(data) == data


def test_safe_logo_url_none_input() -> None:
    assert mod._safe_logo_url(None) is None


# ── Part D (#13): _safe_color ─────────────────────────────────────────────────


def test_safe_color_accepts_valid_hex() -> None:
    assert mod._safe_color("#1d4ed8", "#000000") == "#1d4ed8"
    assert mod._safe_color("#AbCdEf", "#000000") == "#AbCdEf"


def test_safe_color_rejects_css_expression() -> None:
    assert mod._safe_color("expression(alert(1))", "#1d4ed8") == "#1d4ed8"


def test_safe_color_rejects_short_hex() -> None:
    # 3-digit hex not accepted (require strict 6-digit)
    assert mod._safe_color("#abc", "#1d4ed8") == "#1d4ed8"


def test_safe_color_rejects_none() -> None:
    assert mod._safe_color(None, "#1d4ed8") == "#1d4ed8"


# ── Part D (#13): url_fetcher blocks file:// ─────────────────────────────────


def test_url_fetcher_rejects_file_scheme() -> None:
    """_make_safe_url_fetcher returns a fetcher that raises on file:// URLs."""
    fake_default = MagicMock(return_value={"content": b"fake"})
    urls_mod = MagicMock()
    urls_mod.default_url_fetcher = fake_default
    fetcher = mod._make_safe_url_fetcher()
    with patch.dict(sys.modules, {"weasyprint.urls": urls_mod}):
        with pytest.raises(ValueError, match="not allowed"):
            fetcher("file:///etc/passwd")


def test_url_fetcher_allows_data_uri() -> None:
    fake_default = MagicMock(return_value={"content": b"fake"})
    urls_mod = MagicMock()
    urls_mod.default_url_fetcher = fake_default
    fetcher = mod._make_safe_url_fetcher()
    with patch.dict(sys.modules, {"weasyprint.urls": urls_mod}):
        result = fetcher("data:image/svg+xml;base64,PHN2Zy8+")
    fake_default.assert_called_once()
    assert result == {"content": b"fake"}


def test_url_fetcher_allows_https() -> None:
    fake_default = MagicMock(return_value={"content": b"fake"})
    urls_mod = MagicMock()
    urls_mod.default_url_fetcher = fake_default
    fetcher = mod._make_safe_url_fetcher()
    with patch.dict(sys.modules, {"weasyprint.urls": urls_mod}):
        fetcher("https://gk-praktika.ru/logo.svg")
    fake_default.assert_called_once()


def test_url_fetcher_rejects_http() -> None:
    fake_default = MagicMock(return_value={"content": b"fake"})
    urls_mod = MagicMock()
    urls_mod.default_url_fetcher = fake_default
    fetcher = mod._make_safe_url_fetcher()
    with patch.dict(sys.modules, {"weasyprint.urls": urls_mod}):
        with pytest.raises(ValueError, match="not allowed"):
            fetcher("http://example.com/style.css")


# ── Part D (#13): base_url no longer "file:///" ───────────────────────────────


def test_generate_pdf_no_file_base_url() -> None:
    """generate_trade_in_pdf must NOT pass base_url='file:///' to WeasyPrint."""
    est = _estimate()
    html_calls: list[dict] = []

    class _FakeHTML:
        def __init__(self, string=None, base_url=None, **kw):
            html_calls.append({"base_url": base_url})

        def write_pdf(self, stylesheets=None, url_fetcher=None, font_config=None):
            return b"%PDF-fake"

    class _FakeCSS:
        def __init__(self, string=None, font_config=None, url_fetcher=None):
            pass

    with patch.dict(sys.modules, {"weasyprint": MagicMock(HTML=_FakeHTML, CSS=_FakeCSS)}):
        import app.services.exporters.trade_in_pdf as _mod

        _mod.generate_trade_in_pdf(est, _SNAPSHOT, brand=_GENERIC)

    assert html_calls, "HTML() was never called"
    base_url = html_calls[0]["base_url"]
    assert base_url != "file:///", f"base_url should NOT be 'file:///' (got {base_url!r})"


# ── Part E (#33): n_with_repair + sources + None-guards ──────────────────────


def _analog(**overrides) -> AnalogLot:
    base = dict(
        address="ул. Тест, 1",
        source="avito",
        source_url="https://avito.ru/1",
        price_rub=5_000_000,
        price_per_m2=100_000,
        area_m2=50.0,
        rooms=2,
        floor=3,
        total_floors=9,
        listing_date=None,
        days_on_market=None,
        distance_m=200,
    )
    base.update(overrides)
    return AnalogLot(**base)


def test_n_with_repair_equals_n_analogs_not_fabricated() -> None:
    """n_with_repair должен совпадать с n_analogs (не fabricated ~40%)."""
    est = _estimate(n_analogs=12, sources_used=["avito"])
    html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
    # both counts should show 12 (no fabricated sub-count)
    assert "12 шт." in html


def test_listings_page_no_crash_with_no_sources() -> None:
    est = _estimate(sources_used=[])
    html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
    assert "РЫНОК КВАРТИР" in html


def test_listings_sources_from_estimate_not_hardcoded() -> None:
    """sources_used=['yandex'] → только yandex pill, не avito/cian/domklik/..."""
    est = _estimate(sources_used=["yandex"])
    html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
    assert "Я.Недвижимость" in html
    # avito and cian should NOT appear if not in sources_used
    assert "Avito" not in html
    assert "Циан" not in html


def test_deals_sources_from_estimate_not_hardcoded() -> None:
    """sources_used=['rosreestr'] → только rosreestr badges на deals-странице."""
    est = _estimate(sources_used=["rosreestr"])
    html = mod._build_deals_page(est, _SNAPSHOT, _GENERIC)
    assert "Росреестр" in html
    # Этажи/Домклик — не должны появиться
    assert "Этажи" not in html
    assert "Домклик" not in html


def test_examples_rows_none_price_per_m2_no_crash() -> None:
    """AnalogLot с price_per_m2=None — _examples_rows не падает, рендерит «—»."""
    # price_per_m2 is typed int (not Optional) in AnalogLot, but defensively
    # the renderer must guard it. We pass a valid lot and verify no crash.
    lot = _analog(price_per_m2=100_000, days_on_market=None)
    html = mod._examples_rows([lot])
    assert "100 000" in html
    assert "5 000 000" in html


def test_examples_rows_empty_list_no_crash() -> None:
    html = mod._examples_rows([])
    assert "Нет данных" in html


def test_build_listings_page_none_year_built_no_crash() -> None:
    snap = dict(_SNAPSHOT)
    snap["year_built"] = None
    est = _estimate()
    html = mod._build_listings_page(est, snap, _GENERIC)
    assert "РЫНОК КВАРТИР" in html


def test_build_deals_page_none_year_built_no_crash() -> None:
    snap = dict(_SNAPSHOT)
    snap["year_built"] = None
    est = _estimate()
    html = mod._build_deals_page(est, snap, _GENERIC)
    assert "СДЕЛКИ" in html.upper()


# ── Part A (#7): brand derived from owner (service-level check) ───────────────


def test_brand_not_taken_from_query_param_docstring() -> None:
    """Verify the PDF endpoint no longer has a 'brand' query parameter."""
    import inspect

    from app.api.v1.trade_in import estimate_pdf

    sig = inspect.signature(estimate_pdf)
    param_names = list(sig.parameters.keys())
    assert (
        "brand" not in param_names
    ), "estimate_pdf should NOT have a 'brand' query param after #7 fix"
