"""Tests for cian_detail.py — Stage 5 of CianScraper v1."""

from __future__ import annotations

from pathlib import Path
from unittest.mock import AsyncMock, MagicMock

import pytest

from app.services.scrapers.cian_detail import (
    DetailEnrichment,
    _extract_agent_profile,
    _extract_ceiling_height,
    _extract_price_changes,
    _extract_repair_state,
    _extract_repair_type,
    _parse_views,
    fetch_detail,
    save_detail_enrichment,
)

_FIXTURE_DIR = Path(__file__).parent / "fixtures"

# ── _parse_views ──────────────────────────────────────────────────────────────


def test_parse_views_space_formatted():
    assert _parse_views("1 234") == 1234


def test_parse_views_plain():
    assert _parse_views("567") == 567


def test_parse_views_none():
    assert _parse_views(None) is None


def test_parse_views_invalid():
    assert _parse_views("invalid") is None


def test_parse_views_comma_formatted():
    assert _parse_views("1,234") == 1234


# ── _extract_repair_state — структурное поле в приоритете, fallback на текст (#622) ──


def test_repair_state_structured_wins_over_description():
    """repairType присутствует → используется он, описание игнорируется."""
    offer = {
        "repairType": "cosmetic",  # → standard
        "description": "Шикарный дизайнерский ремонт",  # инференс дал бы excellent
    }
    assert _extract_repair_state(offer) == "standard"


def test_repair_state_falls_back_to_description_when_structured_missing():
    """repairType отсутствует → инференс из описания."""
    offer = {
        "repairType": None,
        "description": "Сделан свежий евроремонт, заезжай и живи",
    }
    assert _extract_repair_state(offer) == "good"


def test_repair_state_none_when_both_missing():
    """Нет structured и нет сигнала в тексте → None (не фабрикуем)."""
    offer = {"description": "Светлая квартира рядом с парком"}
    assert _extract_repair_state(offer) is None


# ── _extract_ceiling_height — offer.building.ceilingHeight, не offer.ceilingHeight (#1791) ──


def test_ceiling_height_from_building_node():
    """На detail-странице высота лежит в offer.building.ceilingHeight."""
    offer = {"ceilingHeight": None, "building": {"ceilingHeight": "3.0"}}
    assert _extract_ceiling_height(offer) == 3.0


def test_ceiling_height_falls_back_to_top_level():
    """Если building.ceilingHeight отсутствует — fallback на offer.ceilingHeight."""
    offer = {"ceilingHeight": "2.75", "building": {}}
    assert _extract_ceiling_height(offer) == 2.75


def test_ceiling_height_none_when_absent_everywhere():
    assert _extract_ceiling_height({"building": {}}) is None
    assert _extract_ceiling_height({}) is None


# ── _extract_repair_type / state — offer.decoration fallback (#1791) ──────────


def test_repair_type_prefers_repair_type_then_decoration():
    assert _extract_repair_type({"repairType": "euro", "decoration": "cosmetic"}) == "euro"
    assert _extract_repair_type({"repairType": None, "decoration": "preFine"}) == "preFine"
    assert _extract_repair_type({}) is None


def test_repair_state_from_decoration_when_repair_type_missing():
    """offer.decoration='preFine' → needs_repair (через нормализатор)."""
    offer = {"repairType": None, "decoration": "preFine", "description": ""}
    assert _extract_repair_state(offer) == "needs_repair"


# ── _extract_price_changes ────────────────────────────────────────────────────


def test_extract_price_changes_normalizes():
    """curl_cffi flat format: {changeTime, price, diffPercent}."""
    offer = {
        "priceChanges": [
            {"changeTime": "2026-04-01T00:00:00Z", "price": 5000000, "diffPercent": -2.5},
            {"changeTime": "2026-04-10T00:00:00Z", "price": 4900000, "diffPercent": -2.0},
        ]
    }
    result = _extract_price_changes(offer, {}, {})
    assert len(result) == 2
    assert result[0]["change_time"] == "2026-04-01T00:00:00Z"
    assert result[0]["price_rub"] == 5000000
    assert result[0]["diff_percent"] == -2.5


def test_extract_price_changes_handles_empty_offer():
    assert _extract_price_changes({}, {}, {}) == []


def test_extract_price_changes_handles_none_value():
    assert _extract_price_changes({"priceChanges": None}, {}, {}) == []


def test_extract_price_changes_falls_back_to_state():
    state = {
        "priceChanges": [
            {"changeTime": "2026-03-01T00:00:00Z", "price": 6000000, "diffPercent": 0},
        ]
    }
    result = _extract_price_changes({}, {}, state)
    assert len(result) == 1
    assert result[0]["price_rub"] == 6000000


def test_extract_price_changes_skips_non_dict_entries():
    offer = {"priceChanges": [{"changeTime": "2026-04-01T00:00:00Z", "price": 5000000}, "bad"]}
    result = _extract_price_changes(offer, {}, {})
    assert len(result) == 1


def test_extract_price_changes_offer_data_browser_format():
    """Browser-rendered HTML: offerData.priceChanges with nested priceData.price."""
    offer_data = {
        "priceChanges": [
            {
                "changeTime": "2026-06-05T15:32:41Z",
                "priceData": {"currency": "rur", "price": 13300000},
            },
            {
                "changeTime": "2026-06-03T08:05:04Z",
                "priceData": {"currency": "rur", "price": 13600000},
            },
        ]
    }
    result = _extract_price_changes({}, offer_data, {})
    assert len(result) == 2
    assert result[0]["change_time"] == "2026-06-05T15:32:41Z"
    assert result[0]["price_rub"] == 13300000
    assert result[1]["price_rub"] == 13600000


def test_extract_price_changes_offer_wins_over_offer_data():
    """offer.priceChanges takes priority over offerData.priceChanges (flat format wins)."""
    offer = {
        "priceChanges": [
            {"changeTime": "2026-04-01T00:00:00Z", "price": 5000000},
        ]
    }
    offer_data = {
        "priceChanges": [
            {"changeTime": "2026-06-01T00:00:00Z", "priceData": {"price": 9000000}},
        ]
    }
    result = _extract_price_changes(offer, offer_data, {})
    assert len(result) == 1
    assert result[0]["price_rub"] == 5000000


# ── _extract_agent_profile ────────────────────────────────────────────────────


def test_extract_agent_profile_from_agent_key():
    offer = {
        "agent": {
            "id": 12345,
            "companyName": "Новосёл",
            "isPro": True,
            "skills": [{"id": 1, "name": "Продажа"}],
            "accountType": "specialist",
        }
    }
    profile = _extract_agent_profile(offer)
    assert profile is not None
    assert profile["ext_agent_id"] == 12345
    assert profile["company_name"] == "Новосёл"
    assert profile["is_pro"] is True
    assert profile["account_type"] == "specialist"


def test_extract_agent_profile_from_user_key():
    offer = {
        "user": {
            "cianUserId": 99999,
            "companyName": "Агентство Икс",
            "isPro": False,
            "accountType": "agency",
        }
    }
    profile = _extract_agent_profile(offer)
    assert profile is not None
    assert profile["ext_agent_id"] == 99999
    assert profile["company_name"] == "Агентство Икс"


def test_extract_agent_profile_returns_none_when_no_agent():
    assert _extract_agent_profile({}) is None
    assert _extract_agent_profile({"agent": {}, "user": {}}) is None


# ── DetailEnrichment defaults ─────────────────────────────────────────────────


def test_dataclass_defaults():
    enrich = DetailEnrichment()
    assert enrich.cian_id is None
    assert enrich.price_changes == []
    assert enrich.raw_sister_states == {}
    assert enrich.bti_data is None
    assert enrich.agent_profile is None
    assert enrich.newbuilding_data is None


# ── fetch_detail integration (mocked) ────────────────────────────────────────


@pytest.mark.asyncio
async def test_fetch_detail_returns_none_on_no_state(monkeypatch):
    """If state extraction returns None → fetch_detail returns None gracefully."""
    monkeypatch.setattr(
        "app.services.scrapers.cian_detail.extract_state",
        lambda html, mfe, key: None,
    )

    response = MagicMock()
    response.status_code = 200
    response.text = "<html></html>"

    session = MagicMock()
    session.get = AsyncMock(return_value=response)
    session.close = AsyncMock()

    result = await fetch_detail("https://ekb.cian.ru/sale/flat/123/", session=session)
    assert result is None


@pytest.mark.asyncio
async def test_fetch_detail_returns_none_on_non_200(monkeypatch):
    """Non-200 HTTP response → returns None, no exception raised."""
    response = MagicMock()
    response.status_code = 403
    response.text = ""

    session = MagicMock()
    session.get = AsyncMock(return_value=response)
    session.close = AsyncMock()

    result = await fetch_detail("https://ekb.cian.ru/sale/flat/456/", session=session)
    assert result is None


@pytest.mark.asyncio
async def test_fetch_detail_extracts_core_fields(monkeypatch):
    """Verify core fields are extracted correctly from mocked state."""
    fake_state = {
        "offerData": {
            "offer": {
                "cianId": 327830237,
                "windowsViewType": "yardAndStreet",
                "separateWcsCount": 1,
                "combinedWcsCount": 0,
                "ceilingHeight": 2.65,
                "repairType": "cosmetic",
                "priceChanges": [
                    {
                        "changeTime": "2026-04-01T00:00:00Z",
                        "price": 5500000,
                        "diffPercent": -1.8,
                    }
                ],
                "agent": {
                    "id": 7654321,
                    "companyName": "Первичка Плюс",
                    "isPro": True,
                    "skills": [],
                    "accountType": "specialist",
                },
            },
            "stats": {
                "totalViewsFormattedString": "1 042",
                "todayViewsFormattedString": "7",
            },
        }
    }

    monkeypatch.setattr(
        "app.services.scrapers.cian_detail.extract_state",
        lambda html, mfe, key: fake_state if key == "defaultState" else None,
    )
    monkeypatch.setattr(
        "app.services.scrapers.cian_detail.extract_all_states",
        lambda html: {},
    )

    response = MagicMock()
    response.status_code = 200
    response.text = "<html></html>"

    session = MagicMock()
    session.get = AsyncMock(return_value=response)
    session.close = AsyncMock()

    result = await fetch_detail("https://ekb.cian.ru/sale/flat/327830237/", session=session)
    assert result is not None
    assert result.cian_id == 327830237
    assert result.windows_view_type == "yardAndStreet"
    assert result.separate_wcs_count == 1
    assert result.combined_wcs_count == 0
    assert result.ceiling_height == 2.65
    assert result.repair_type == "cosmetic"
    assert result.views_total == 1042
    assert result.views_today == 7
    assert len(result.price_changes) == 1
    assert result.price_changes[0]["price_rub"] == 5500000
    assert result.agent_profile is not None
    assert result.agent_profile["ext_agent_id"] == 7654321
    assert result.agent_profile["company_name"] == "Первичка Плюс"


@pytest.mark.asyncio
async def test_fetch_detail_extracts_bti_from_sister_states(monkeypatch):
    """BTI sister state is captured into bti_data."""
    fake_state = {
        "offerData": {
            "offer": {
                "cianId": 111111,
            }
        }
    }
    fake_all_states = {
        "frontend-offer-card": {
            "bti": {
                "houseData": {
                    "seriesName": "П-44Т",
                    "floorsCount": 17,
                    "flatCount": 120,
                }
            }
        }
    }

    monkeypatch.setattr(
        "app.services.scrapers.cian_detail.extract_state",
        lambda html, mfe, key: fake_state if key == "defaultState" else None,
    )
    monkeypatch.setattr(
        "app.services.scrapers.cian_detail.extract_all_states",
        lambda html: fake_all_states,
    )

    response = MagicMock()
    response.status_code = 200
    response.text = "<html></html>"

    session = MagicMock()
    session.get = AsyncMock(return_value=response)
    session.close = AsyncMock()

    result = await fetch_detail("https://ekb.cian.ru/sale/flat/111111/", session=session)
    assert result is not None
    assert result.bti_data is not None
    assert result.bti_data["seriesName"] == "П-44Т"
    assert "bti" in result.raw_sister_states


@pytest.mark.asyncio
async def test_fetch_detail_extracts_newbuilding_link(monkeypatch):
    """Newbuilding reference is captured from offer.newbuilding."""
    fake_state = {
        "offerData": {
            "offer": {
                "cianId": 222222,
                "newbuilding": {
                    "id": 54016,
                    "name": "Екатерининский парк",
                    "url": "https://zhk-ekaterininskiy-park-ekb-i.cian.ru/",
                },
            }
        }
    }

    monkeypatch.setattr(
        "app.services.scrapers.cian_detail.extract_state",
        lambda html, mfe, key: fake_state if key == "defaultState" else None,
    )
    monkeypatch.setattr(
        "app.services.scrapers.cian_detail.extract_all_states",
        lambda html: {},
    )

    response = MagicMock()
    response.status_code = 200
    response.text = "<html></html>"

    session = MagicMock()
    session.get = AsyncMock(return_value=response)
    session.close = AsyncMock()

    result = await fetch_detail("https://ekb.cian.ru/sale/flat/222222/", session=session)
    assert result is not None
    assert result.newbuilding_data is not None
    assert result.newbuilding_data["id"] == 54016


# ── kitchen_area_m2 extraction ────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_fetch_detail_extracts_kitchen_area(monkeypatch):
    """kitchenArea из offer → kitchen_area_m2 в DetailEnrichment."""
    fake_state = {
        "offerData": {
            "offer": {
                "cianId": 333333,
                "kitchenArea": 9.5,
            }
        }
    }

    monkeypatch.setattr(
        "app.services.scrapers.cian_detail.extract_state",
        lambda html, mfe, key: fake_state if key == "defaultState" else None,
    )
    monkeypatch.setattr(
        "app.services.scrapers.cian_detail.extract_all_states",
        lambda html: {},
    )

    response = MagicMock()
    response.status_code = 200
    response.text = "<html></html>"

    session = MagicMock()
    session.get = AsyncMock(return_value=response)
    session.close = AsyncMock()

    result = await fetch_detail("https://ekb.cian.ru/sale/flat/333333/", session=session)
    assert result is not None
    assert result.kitchen_area_m2 == 9.5


@pytest.mark.asyncio
async def test_fetch_detail_kitchen_area_none_when_missing(monkeypatch):
    """Если kitchenArea отсутствует в offer — kitchen_area_m2 остаётся None."""
    fake_state = {
        "offerData": {
            "offer": {
                "cianId": 444444,
            }
        }
    }

    monkeypatch.setattr(
        "app.services.scrapers.cian_detail.extract_state",
        lambda html, mfe, key: fake_state if key == "defaultState" else None,
    )
    monkeypatch.setattr(
        "app.services.scrapers.cian_detail.extract_all_states",
        lambda html: {},
    )

    response = MagicMock()
    response.status_code = 200
    response.text = "<html></html>"

    session = MagicMock()
    session.get = AsyncMock(return_value=response)
    session.close = AsyncMock()

    result = await fetch_detail("https://ekb.cian.ru/sale/flat/444444/", session=session)
    assert result is not None
    assert result.kitchen_area_m2 is None


@pytest.mark.asyncio
async def test_fetch_detail_extracts_description(monkeypatch):
    """offer.description → DetailEnrichment.description (для persist в listings)."""
    fake_state = {
        "offerData": {
            "offer": {
                "cianId": 555555,
                "description": "Продаётся уютная квартира с видом на парк.",
            }
        }
    }

    monkeypatch.setattr(
        "app.services.scrapers.cian_detail.extract_state",
        lambda html, mfe, key: fake_state if key == "defaultState" else None,
    )
    monkeypatch.setattr(
        "app.services.scrapers.cian_detail.extract_all_states",
        lambda html: {},
    )

    response = MagicMock()
    response.status_code = 200
    response.text = "<html></html>"

    session = MagicMock()
    session.get = AsyncMock(return_value=response)
    session.close = AsyncMock()

    result = await fetch_detail("https://ekb.cian.ru/sale/flat/555555/", session=session)
    assert result is not None
    assert result.description == "Продаётся уютная квартира с видом на парк."


# ── save_detail_enrichment — kitchen_area_m2 persisted ───────────────────────


def test_save_detail_enrichment_writes_kitchen_area():
    """save_detail_enrichment передаёт kitchen_area_m2 в UPDATE listings."""
    db = MagicMock()

    # Мокаем fetchone для snapshot-запроса — price_rub=None → snapshot не пишется
    db.execute.return_value.fetchone.return_value = None

    enrichment = DetailEnrichment(
        windows_view_type=None,
        separate_wcs_count=None,
        combined_wcs_count=None,
        ceiling_height=None,
        repair_type=None,
        repair_state=None,
        kitchen_area_m2=8.3,
        views_total=None,
        views_today=None,
    )

    save_detail_enrichment(db, listing_id=999, enrichment=enrichment)

    # Проверяем что UPDATE listings вызван с параметром ka=8.3
    update_call_params = db.execute.call_args_list[0][0][1]
    assert update_call_params["ka"] == 8.3


def test_save_detail_enrichment_kitchen_area_none_passthrough():
    """Если kitchen_area_m2 = None — параметр ka=None, COALESCE сохраняет существующее."""
    db = MagicMock()
    db.execute.return_value.fetchone.return_value = None

    enrichment = DetailEnrichment(kitchen_area_m2=None)

    save_detail_enrichment(db, listing_id=1001, enrichment=enrichment)

    update_call_params = db.execute.call_args_list[0][0][1]
    assert update_call_params["ka"] is None


# ── save_detail_enrichment — description + detail_enriched_at stamp ───────────


def test_save_detail_enrichment_writes_description():
    """save_detail_enrichment передаёт description в UPDATE listings."""
    db = MagicMock()
    db.execute.return_value.fetchone.return_value = None

    enrichment = DetailEnrichment(description="Светлая квартира рядом с парком")

    save_detail_enrichment(db, listing_id=1002, enrichment=enrichment)

    update_call_params = db.execute.call_args_list[0][0][1]
    assert update_call_params["descr"] == "Светлая квартира рядом с парком"


def test_save_detail_enrichment_stamps_detail_enriched_at():
    """UPDATE listings содержит detail_enriched_at = NOW() — иначе строка
    переобогащается каждый run (detail_enriched_at IS NULL filter)."""
    db = MagicMock()
    db.execute.return_value.fetchone.return_value = None

    enrichment = DetailEnrichment(kitchen_area_m2=8.3)

    save_detail_enrichment(db, listing_id=1003, enrichment=enrichment)

    update_sql = str(db.execute.call_args_list[0][0][0])
    assert "detail_enriched_at = NOW()" in update_sql


# ── fetch_detail browser_fetcher param ───────────────────────────────────────


@pytest.mark.asyncio
async def test_fetch_detail_browser_fetcher_uses_browser_html(monkeypatch):
    """browser_fetcher provided → HTML comes from BrowserFetcher.fetch, not curl_cffi.

    Simulates the browser-rendered format where priceChanges is at offerData level
    with nested priceData.price (verified on prod 2026-06-16).
    """
    fake_state = {
        "offerData": {
            "offer": {
                "cianId": 328647045,
            },
            # Browser-rendered: priceChanges at offerData level, priceData.price format
            "priceChanges": [
                {
                    "changeTime": "2026-06-05T15:32:41Z",
                    "priceData": {"currency": "rur", "price": 13300000},
                },
                {
                    "changeTime": "2026-06-03T08:05:04Z",
                    "priceData": {"currency": "rur", "price": 13600000},
                },
            ],
        }
    }

    monkeypatch.setattr(
        "app.services.scrapers.cian_detail.extract_state",
        lambda html, mfe, key: fake_state if key == "defaultState" else None,
    )
    monkeypatch.setattr(
        "app.services.scrapers.cian_detail.extract_all_states",
        lambda html: {},
    )

    # Mock BrowserFetcher: fetch() returns HTML tagged so we can verify it was called.
    mock_bf = MagicMock()
    mock_bf.fetch = AsyncMock(return_value="<html>browser-rendered</html>")

    result = await fetch_detail(
        "https://ekb.cian.ru/sale/flat/328647045/",
        browser_fetcher=mock_bf,
    )

    assert result is not None
    assert result.cian_id == 328647045
    # price_changes populated from browser-rendered HTML via offerData.priceChanges
    assert len(result.price_changes) == 2
    assert result.price_changes[0]["price_rub"] == 13300000
    assert result.price_changes[0]["change_time"] == "2026-06-05T15:32:41Z"
    assert result.price_changes[1]["price_rub"] == 13600000
    # BrowserFetcher.fetch was called with the offer URL
    mock_bf.fetch.assert_called_once_with("https://ekb.cian.ru/sale/flat/328647045/")


@pytest.mark.asyncio
async def test_fetch_detail_browser_fetcher_no_curl_session_used(monkeypatch):
    """When browser_fetcher is given, curl_cffi AsyncSession is NOT instantiated."""
    fake_state = {"offerData": {"offer": {"cianId": 555555}}}

    monkeypatch.setattr(
        "app.services.scrapers.cian_detail.extract_state",
        lambda html, mfe, key: fake_state if key == "defaultState" else None,
    )
    monkeypatch.setattr(
        "app.services.scrapers.cian_detail.extract_all_states",
        lambda html: {},
    )

    # Patch AsyncSession so we can assert it was NOT called.
    mock_session_cls = MagicMock()
    monkeypatch.setattr(
        "app.services.scrapers.cian_detail.AsyncSession",
        mock_session_cls,
    )

    mock_bf = MagicMock()
    mock_bf.fetch = AsyncMock(return_value="<html></html>")

    result = await fetch_detail(
        "https://ekb.cian.ru/sale/flat/555555/",
        browser_fetcher=mock_bf,
    )

    assert result is not None
    mock_session_cls.assert_not_called()


@pytest.mark.asyncio
async def test_fetch_detail_browser_fetcher_returns_none_on_exception(monkeypatch):
    """If BrowserFetcher.fetch raises, fetch_detail returns None (no crash)."""
    mock_bf = MagicMock()
    mock_bf.fetch = AsyncMock(side_effect=RuntimeError("browser service unavailable"))

    result = await fetch_detail(
        "https://ekb.cian.ru/sale/flat/999999/",
        browser_fetcher=mock_bf,
    )

    assert result is None


@pytest.mark.asyncio
async def test_fetch_detail_browser_fetcher_state_missing_returns_none(monkeypatch):
    """browser HTML parsed but no defaultState → fetch_detail returns None."""
    monkeypatch.setattr(
        "app.services.scrapers.cian_detail.extract_state",
        lambda html, mfe, key: None,
    )

    mock_bf = MagicMock()
    mock_bf.fetch = AsyncMock(return_value="<html>no state here</html>")

    result = await fetch_detail(
        "https://ekb.cian.ru/sale/flat/777777/",
        browser_fetcher=mock_bf,
    )

    assert result is None


# ── Real-card fixture (#1791) — снята с www.cian.ru/sale/flat/330982715/ ──────
# Регрессия на field-drift: ceiling/kitchen/description должны извлекаться из
# актуальной structure detail-страницы (defaultState, concat-формат).


@pytest.mark.asyncio
async def test_fetch_detail_real_fixture_extracts_drifted_fields():
    """На реальной карточке ceiling берётся из offer.building.ceilingHeight,
    kitchen/description — из offer top-level. До #1791 ceiling был 0%."""
    html = (_FIXTURE_DIR / "cian_flat_330982715.html").read_text(encoding="utf-8")

    response = MagicMock()
    response.status_code = 200
    response.text = html

    session = MagicMock()
    session.get = AsyncMock(return_value=response)
    session.close = AsyncMock()

    result = await fetch_detail("https://www.cian.ru/sale/flat/330982715/", session=session)

    assert result is not None
    # ceiling_height — РАНЬШЕ был None (читали offer.ceilingHeight=null);
    # теперь из offer.building.ceilingHeight=3.0
    assert result.ceiling_height == 3.0
    # kitchen_area_m2 — offer.kitchenArea='14.5' (строка → float)
    assert result.kitchen_area_m2 == 14.5
    # description — непустой текст объявления
    assert result.description is not None
    assert len(result.description) > 50
    assert result.cian_id is not None
