"""Unit tests for rosreestr_poll service (#888).

Coverage:
  (a) _next_quarter: boundary cases (Q4 → Q1 of next year, mid-year).
  (b) latest_loaded_quarter: derives correct (year, quarter) from a known deal_date.
  (c) rosreestr_dataset_url: cp1251 percent-encoding matches the real portal URL.
  (d) check_new_quarter_available — new /data-sets/ autoindex flow:
      - real application/zip + large Content-Length → True
      - soft-404 regression: file found but Content-Type text/html → False
        (this is the exact bug that broke the old /opendata/ flat-URL poller)
      - tiny Content-Length (placeholder/error file) → False
      - quarter folder missing from autoindex → False (not yet published)
      - dataset file missing from quarter folder → False
      - network error / timeout → False, never raises
  (e) poll_rosreestr_new_quarter orchestration: available=True/False end-to-end,
      no DB data fallback, network error propagation.
"""

from __future__ import annotations

import os
import sys
from datetime import date
from unittest.mock import AsyncMock, MagicMock, patch
from urllib.parse import quote

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

# WeasyPrint stub — not installed in CI without GTK
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)

import pytest  # noqa: E402

from app.services.rosreestr_poll import (  # noqa: E402
    _next_quarter,
    _period_start_to_quarter,
    check_new_quarter_available,
    latest_loaded_quarter,
    poll_rosreestr_new_quarter,
    rosreestr_dataset_url,
)

# ---------------------------------------------------------------------------
# cp1251 href fixtures — built the same way the real portal encodes folder/file
# names (percent-encoded cp1251 bytes), so tests exercise the actual decode path.
# ---------------------------------------------------------------------------


def _cp1251_href(name: str) -> str:
    return quote(name.encode("cp1251"), safe="")


def _autoindex_html(*folder_names: str) -> str:
    """Build a fake Apache-autoindex-style HTML listing for /data-sets/."""
    links = ['<a href="../">../</a>']
    for name in folder_names:
        href = _cp1251_href(name) + "/"
        links.append(f'<a href="{href}">{name}/</a>')
    return "<html><body><pre>" + "\n".join(links) + "</pre></body></html>"


def _folder_html(*file_names: str) -> str:
    """Build a fake Apache-autoindex-style HTML listing for a quarter folder."""
    links = ['<a href="../">../</a>']
    for name in file_names:
        href = _cp1251_href(name)
        links.append(f'<a href="{href}">{name}</a>')
    return "<html><body><pre>" + "\n".join(links) + "</pre></body></html>"


_SDELKI_FILE_2026_Q1 = "dataset_СДЕЛКИ_r-r_01-92_y_2026_q_1.csv.zip"
_KADASTR_FILE_2026_Q1 = "dataset_КАДАСТРСТОИМОСТЬ_r-r_01-92_y_2026_q_1.csv.zip"


def _mock_response(*, status_code: int = 200, text: str = "", headers: dict | None = None):
    resp = MagicMock()
    resp.status_code = status_code
    resp.text = text
    resp.headers = headers or {}
    return resp


# ---------------------------------------------------------------------------
# (a) _next_quarter
# ---------------------------------------------------------------------------


def test_next_quarter_mid_year() -> None:
    assert _next_quarter(2026, 1) == (2026, 2)
    assert _next_quarter(2026, 2) == (2026, 3)
    assert _next_quarter(2026, 3) == (2026, 4)


def test_next_quarter_year_rollover() -> None:
    assert _next_quarter(2026, 4) == (2027, 1)
    assert _next_quarter(2024, 4) == (2025, 1)


def test_period_start_to_quarter_from_date() -> None:
    assert _period_start_to_quarter(date(2026, 1, 1)) == (2026, 1)
    assert _period_start_to_quarter(date(2026, 4, 1)) == (2026, 2)
    assert _period_start_to_quarter(date(2026, 7, 15)) == (2026, 3)
    assert _period_start_to_quarter(date(2026, 10, 31)) == (2026, 4)
    assert _period_start_to_quarter(date(2025, 12, 31)) == (2025, 4)


def test_period_start_to_quarter_from_string() -> None:
    assert _period_start_to_quarter("2026-01-15") == (2026, 1)
    assert _period_start_to_quarter("2026-07-01") == (2026, 3)


# ---------------------------------------------------------------------------
# (c) rosreestr_dataset_url — cp1251 percent-encoding must match the real portal.
# ---------------------------------------------------------------------------


def test_rosreestr_dataset_url_matches_verified_real_encoding() -> None:
    """Encoding must byte-match the live-verified rosreestr.gov.ru/data-sets/ URL."""
    url = rosreestr_dataset_url(2026, 1)
    assert url == (
        "https://rosreestr.gov.ru/data-sets/"
        "1%20%EA%E2%E0%F0%F2%E0%EB%202026%E3./"
        "dataset_%D1%C4%C5%CB%CA%C8_r-r_01-92_y_2026_q_1.csv.zip"
    )


def test_rosreestr_dataset_url_contains_year_quarter() -> None:
    url = rosreestr_dataset_url(2026, 2)
    assert "2026" in url
    assert "q_2" in url


# ---------------------------------------------------------------------------
# Fake DB helpers
# ---------------------------------------------------------------------------


class _FakeRow:
    """Mimics a DB row with __getitem__."""

    def __init__(self, val: object) -> None:
        self._val = val

    def __getitem__(self, idx: int) -> object:
        return self._val


class _FakeDB:
    """Minimal fake Session returning a configurable deal_date via execute().fetchone()."""

    def __init__(self, max_deal_date: date | None) -> None:
        self._row = _FakeRow(max_deal_date) if max_deal_date is not None else None

    def execute(self, stmt: object, params: object = None) -> MagicMock:
        mock = MagicMock()
        mock.fetchone.return_value = self._row
        return mock

    def commit(self) -> None:
        pass


# ---------------------------------------------------------------------------
# (b) latest_loaded_quarter
# ---------------------------------------------------------------------------


def test_latest_loaded_quarter_returns_correct_year_quarter() -> None:
    """Q1 2026 deal_date → (2026, 1)."""
    db = _FakeDB(date(2026, 1, 31))
    result = latest_loaded_quarter(db)  # type: ignore[arg-type]
    assert result == (2026, 1)


def test_latest_loaded_quarter_q4() -> None:
    """Dec deal_date → Q4."""
    db = _FakeDB(date(2025, 12, 15))
    result = latest_loaded_quarter(db)  # type: ignore[arg-type]
    assert result == (2025, 4)


def test_latest_loaded_quarter_returns_none_when_empty() -> None:
    """No rows → None."""
    db = _FakeDB(None)
    result = latest_loaded_quarter(db)  # type: ignore[arg-type]
    assert result is None


# ---------------------------------------------------------------------------
# (d) check_new_quarter_available — /data-sets/ autoindex flow
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_check_available_true_for_real_zip() -> None:
    """Folder found, file found, HEAD returns application/zip + large Content-Length → True."""
    index_html = _autoindex_html("1 квартал 2026г.", "4 квартал 2025г.")
    folder_html = _folder_html(_SDELKI_FILE_2026_Q1, _KADASTR_FILE_2026_Q1)

    mock_client = AsyncMock()
    mock_client.get.side_effect = [
        _mock_response(text=index_html),
        _mock_response(text=folder_html),
    ]
    mock_client.head.return_value = _mock_response(
        headers={"content-type": "application/zip", "content-length": "9500000"}
    )

    result = await check_new_quarter_available(mock_client, 2026, 1)  # type: ignore[arg-type]

    assert result is True
    assert mock_client.get.call_count == 2
    assert mock_client.head.call_count == 1


@pytest.mark.asyncio
async def test_check_available_false_soft_404_text_html() -> None:
    """Regression guard: HTTP 200 with Content-Type text/html (Bitrix soft-404) → False.

    This is the exact failure mode that broke the old /opendata/ flat-URL poller —
    it treated bare HTTP 200 as availability. Even if a matching file href is found,
    a non-zip Content-Type must never be treated as available.
    """
    index_html = _autoindex_html("1 квартал 2026г.")
    folder_html = _folder_html(_SDELKI_FILE_2026_Q1)

    mock_client = AsyncMock()
    mock_client.get.side_effect = [
        _mock_response(text=index_html),
        _mock_response(text=folder_html),
    ]
    mock_client.head.return_value = _mock_response(
        status_code=200,
        headers={"content-type": "text/html; charset=utf-8", "content-length": "1234"},
    )

    result = await check_new_quarter_available(mock_client, 2026, 1)  # type: ignore[arg-type]

    assert result is False


@pytest.mark.asyncio
async def test_check_available_false_tiny_content_length() -> None:
    """application/zip but tiny Content-Length (placeholder/error file) → False."""
    index_html = _autoindex_html("1 квартал 2026г.")
    folder_html = _folder_html(_SDELKI_FILE_2026_Q1)

    mock_client = AsyncMock()
    mock_client.get.side_effect = [
        _mock_response(text=index_html),
        _mock_response(text=folder_html),
    ]
    mock_client.head.return_value = _mock_response(
        headers={"content-type": "application/zip", "content-length": "512"}
    )

    result = await check_new_quarter_available(mock_client, 2026, 1)  # type: ignore[arg-type]

    assert result is False


@pytest.mark.asyncio
async def test_check_available_false_quarter_folder_missing() -> None:
    """Autoindex doesn't have the target quarter folder yet → False, no further GET/HEAD."""
    index_html = _autoindex_html("4 квартал 2025г.")  # 2026 Q1 not published yet

    mock_client = AsyncMock()
    mock_client.get.return_value = _mock_response(text=index_html)

    result = await check_new_quarter_available(mock_client, 2026, 1)  # type: ignore[arg-type]

    assert result is False
    assert mock_client.get.call_count == 1  # never fetched a (non-existent) folder
    mock_client.head.assert_not_called()


@pytest.mark.asyncio
async def test_check_available_false_dataset_file_missing_in_folder() -> None:
    """Quarter folder exists but has no dataset_СДЕЛКИ file (only КАДАСТРСТОИМОСТЬ) → False."""
    index_html = _autoindex_html("1 квартал 2026г.")
    folder_html = _folder_html(_KADASTR_FILE_2026_Q1)  # no СДЕЛКИ file

    mock_client = AsyncMock()
    mock_client.get.side_effect = [
        _mock_response(text=index_html),
        _mock_response(text=folder_html),
    ]

    result = await check_new_quarter_available(mock_client, 2026, 1)  # type: ignore[arg-type]

    assert result is False
    mock_client.head.assert_not_called()


@pytest.mark.asyncio
async def test_check_available_false_on_index_404() -> None:
    """Unexpected non-200 on the base autoindex → False, no raise."""
    mock_client = AsyncMock()
    mock_client.get.return_value = _mock_response(status_code=404, text="")

    result = await check_new_quarter_available(mock_client, 2026, 1)  # type: ignore[arg-type]

    assert result is False


@pytest.mark.asyncio
async def test_check_new_quarter_available_timeout_returns_false() -> None:
    """Timeout → returns False, no exception."""
    import httpx

    mock_client = AsyncMock()
    mock_client.get.side_effect = httpx.TimeoutException("timeout")

    result = await check_new_quarter_available(mock_client, 2026, 2)  # type: ignore[arg-type]
    assert result is False


@pytest.mark.asyncio
async def test_check_new_quarter_available_network_error_returns_false() -> None:
    """Network error mid-flow (folder GET) → returns False, no exception."""
    import httpx

    index_html = _autoindex_html("1 квартал 2026г.")

    mock_client = AsyncMock()
    mock_client.get.side_effect = [
        _mock_response(text=index_html),
        httpx.ConnectError("simulated network error"),
    ]

    result = await check_new_quarter_available(mock_client, 2026, 1)  # type: ignore[arg-type]
    assert result is False


# ---------------------------------------------------------------------------
# (e) poll_rosreestr_new_quarter — orchestration
# ---------------------------------------------------------------------------


def _patched_client_for(index_html: str, folder_html: str, head_response) -> AsyncMock:
    mock_client = AsyncMock()
    mock_client.get.side_effect = [
        _mock_response(text=index_html),
        _mock_response(text=folder_html),
    ]
    mock_client.head.return_value = head_response
    mock_client.__aenter__ = AsyncMock(return_value=mock_client)
    mock_client.__aexit__ = AsyncMock(return_value=False)
    return mock_client


@pytest.mark.asyncio
async def test_poll_available_true_returns_correct_next_quarter() -> None:
    """If latest loaded is Q1 2026 and the Q2 2026 dataset is a real zip, available=True."""
    db = _FakeDB(date(2026, 3, 31))  # latest = Q1 2026 → next = Q2 2026

    index_html = _autoindex_html("2 квартал 2026г.")
    file_name = "dataset_СДЕЛКИ_r-r_01-92_y_2026_q_2.csv.zip"
    folder_html = _folder_html(file_name)
    head_response = _mock_response(
        headers={"content-type": "application/zip", "content-length": "9500000"}
    )

    mock_client = _patched_client_for(index_html, folder_html, head_response)

    with patch("app.services.rosreestr_poll.httpx.AsyncClient", return_value=mock_client):
        result = await poll_rosreestr_new_quarter(db)  # type: ignore[arg-type]

    assert result["available"] is True
    assert result["year"] == 2026
    assert result["quarter"] == 2
    assert result["latest_loaded_year"] == 2026
    assert result["latest_loaded_quarter"] == 1


@pytest.mark.asyncio
async def test_poll_available_false_when_folder_not_published() -> None:
    """Quarter folder not yet published (autoindex has no matching entry) → available=False."""
    db = _FakeDB(date(2026, 3, 15))  # Q1 2026 → next = Q2 2026

    index_html = _autoindex_html("1 квартал 2026г.")  # Q2 2026 missing

    mock_client = AsyncMock()
    mock_client.get.return_value = _mock_response(text=index_html)
    mock_client.__aenter__ = AsyncMock(return_value=mock_client)
    mock_client.__aexit__ = AsyncMock(return_value=False)

    with patch("app.services.rosreestr_poll.httpx.AsyncClient", return_value=mock_client):
        result = await poll_rosreestr_new_quarter(db)  # type: ignore[arg-type]

    assert result["available"] is False
    assert result["year"] == 2026
    assert result["quarter"] == 2


@pytest.mark.asyncio
async def test_poll_network_error_returns_false_no_raise() -> None:
    """Network error → available=False, no exception propagated."""
    import httpx

    db = _FakeDB(date(2026, 1, 20))  # Q1 2026

    mock_client = AsyncMock()
    mock_client.get.side_effect = httpx.ConnectError("simulated network error")
    mock_client.__aenter__ = AsyncMock(return_value=mock_client)
    mock_client.__aexit__ = AsyncMock(return_value=False)

    with patch("app.services.rosreestr_poll.httpx.AsyncClient", return_value=mock_client):
        # Must not raise
        result = await poll_rosreestr_new_quarter(db)  # type: ignore[arg-type]

    assert result["available"] is False


# ---------------------------------------------------------------------------
# (f) No DB data → fallback baseline checks 2026Q1
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_poll_fallback_when_no_db_data() -> None:
    """No rosreestr deals in DB → fallback 2025Q4 baseline, checks 2026Q1."""
    db = _FakeDB(None)

    index_html = _autoindex_html("4 квартал 2025г.")  # 2026 Q1 not yet published

    mock_client = AsyncMock()
    mock_client.get.return_value = _mock_response(text=index_html)
    mock_client.__aenter__ = AsyncMock(return_value=mock_client)
    mock_client.__aexit__ = AsyncMock(return_value=False)

    with patch("app.services.rosreestr_poll.httpx.AsyncClient", return_value=mock_client):
        result = await poll_rosreestr_new_quarter(db)  # type: ignore[arg-type]

    # Fallback: 2025Q4 → check 2026Q1
    assert result["year"] == 2026
    assert result["quarter"] == 1
    assert result["available"] is False
    # latest_loaded_year/quarter should be None (no DB data)
    assert result["latest_loaded_year"] is None
    assert result["latest_loaded_quarter"] is None
