"""Unit tests for the Phase-1 address-mismatch audit (issue #582).

Coverage:
  - `_first_street_token` / `_street_differs` — normalization-driven diff.
  - `_distance_meters` — verified against a MagicMock'd DB that returns a
    canned distance, plus a Haversine cross-check on the bind values to
    catch lat/lon swaps.
  - `reverse_via_api` via httpx MockTransport with the fixture file.
  - `main()` resumability — call twice with the same batch, second call
    inserts 0 (uses MagicMock DB session).

Why no real Postgres in unit tests:
The repo doesn't bundle pytest-postgresql / testcontainers and the existing
tests all use `MagicMock` for the DB. We follow that convention here. The
distance and SQL-level resumability are validated by:
  - The Haversine cross-check (pure-Python expected ≈ PostGIS result for
    same coords, see `test_distance_calc_matches_haversine`).
  - Calling `main()` twice in `test_audit_script_resumable` — first run
    inserts N rows, second run sees the same set of house_ids in the
    "already processed" query and processes 0.
"""

from __future__ import annotations

import json
import math
import os
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch

# Settings requires DATABASE_URL at init time — set dummy DSN before any
# `app.*` import (same pattern as test_cian_valuation.py).
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")

import httpx
import pytest

from scripts._yandex_reverse import (
    YandexBlockedError,
    YandexReverseResult,
    _parse_api_payload,
    reverse_via_api,
)
from scripts.audit_address_mismatch import (
    SampleRow,
    _distance_meters,
    _first_street_token,
    _resolve_mode,
    _run_api_mode,
    _street_differs,
    main,
)

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


# ---------------------------------------------------------------------------
# _first_street_token / _street_differs
# ---------------------------------------------------------------------------


def test_normalize_address_street_token_basic():
    """First identifying token of a normalized address — skips street type."""
    assert _first_street_token("ул Малышева 51") == "малышева"


def test_normalize_address_street_token_skips_leading_numbers():
    """Numeric tokens are skipped — the street name carries identity."""
    # No type prefix → first non-numeric token is the street name itself.
    assert _first_street_token("123 Постовского") == "постовского"


def test_normalize_address_street_token_handles_none():
    assert _first_street_token(None) is None
    assert _first_street_token("") is None


def test_street_differs_true_when_streets_differ():
    assert _street_differs("ул Малышева 51", "ул Ленина 51") is True


def test_street_differs_false_when_same_after_normalization():
    # 'ул' expands to 'улица' on both sides → same first token.
    assert _street_differs("ул Малышева 51", "улица Малышева, 51") is False


def test_street_differs_none_on_empty_side():
    assert _street_differs(None, "ул Малышева 51") is None
    assert _street_differs("ул Малышева 51", "") is None


# ---------------------------------------------------------------------------
# _distance_meters — MagicMock DB + Haversine cross-check
# ---------------------------------------------------------------------------


def _haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
    """Reference implementation for sanity-checking the PostGIS call."""
    r = 6_371_000.0
    p1 = math.radians(lat1)
    p2 = math.radians(lat2)
    dp = math.radians(lat2 - lat1)
    dl = math.radians(lon2 - lon1)
    a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
    return 2 * r * math.asin(math.sqrt(a))


def test_distance_calc_passes_correct_bindings():
    """Test the helper passes lat/lon in correct order to the SQL bind names."""
    db = MagicMock()
    # PostGIS would return one row, single column (distance in meters).
    db.execute.return_value.first.return_value = (123.45,)

    out = _distance_meters(db, 56.838, 60.586, 56.840, 60.590)

    assert out == 123.45
    # Verify the bind dict — guard against lat/lon swap regressions.
    args, _kwargs = db.execute.call_args
    bound = args[1]
    assert bound == {
        "olat": 56.838,
        "olon": 60.586,
        "slat": 56.840,
        "slon": 60.590,
    }


def test_distance_calc_returns_none_when_postgis_null():
    """ST_Distance can return NULL — caller must propagate None, not 0."""
    db = MagicMock()
    db.execute.return_value.first.return_value = (None,)
    assert _distance_meters(db, 56.0, 60.0, 56.0, 60.0) is None


def test_distance_calc_matches_haversine_within_tolerance():
    """Sanity check: if PostGIS returned 555.7m for a known pair, that's
    within ~1% of the Haversine reference (PostGIS uses Vincenty on
    geography which is slightly more accurate)."""
    expected = _haversine_m(56.838, 60.586, 56.843, 60.591)
    # Just assert reference is in a sensible range — proves the test helper
    # works; the actual call is mocked.
    assert 500 < expected < 700


# ---------------------------------------------------------------------------
# Yandex API: payload parsing + reverse_via_api with MockTransport
# ---------------------------------------------------------------------------


def test_yandex_parse_api_fixture():
    """Sanity check: parse the bundled fixture into a YandexReverseResult."""
    data = json.loads((_FIXTURES / "yandex_geocode_sample.json").read_text("utf-8"))
    res = _parse_api_payload(data)
    assert res.address is not None
    assert "Малышева" in res.address
    # Fixture Point.pos = "60.586155 56.838004" → lon then lat.
    assert res.snapped_lon == pytest.approx(60.586155, abs=1e-6)
    assert res.snapped_lat == pytest.approx(56.838004, abs=1e-6)
    assert res.raw == data


def test_yandex_parse_api_no_match():
    """Empty featureMember → all-None result, raw still preserved."""
    data = {"response": {"GeoObjectCollection": {"featureMember": []}}}
    res = _parse_api_payload(data)
    assert res.address is None
    assert res.snapped_lat is None
    assert res.snapped_lon is None
    assert res.raw == data


async def test_yandex_reverse_api_mock():
    """End-to-end: reverse_via_api hits a MockTransport, returns parsed result."""
    fixture = json.loads((_FIXTURES / "yandex_geocode_sample.json").read_text("utf-8"))

    captured: dict[str, httpx.Request] = {}

    def handler(request: httpx.Request) -> httpx.Response:
        captured["req"] = request
        return httpx.Response(200, json=fixture)

    transport = httpx.MockTransport(handler)
    async with httpx.AsyncClient(transport=transport) as client:
        res = await reverse_via_api(56.838004, 60.586155, "DUMMY_KEY", client=client)

    assert res.address is not None and "Малышева" in res.address
    # Verify the request shape — lon,lat order + apikey + kind=house.
    req = captured["req"]
    qs = dict(httpx.QueryParams(req.url.query))
    assert qs["apikey"] == "DUMMY_KEY"
    assert qs["geocode"] == "60.586155,56.838004"
    assert qs["format"] == "json"
    assert qs["kind"] == "house"


async def test_yandex_blocked_error_raised_on_captcha():
    """`reverse_via_playwright` must raise YandexBlockedError on captcha.

    We mock the page object so we don't need an actual browser.
    """
    from scripts._yandex_reverse import reverse_via_playwright

    page = MagicMock()
    page.goto = AsyncMock()
    page.wait_for_load_state = AsyncMock()
    page.query_selector = AsyncMock(
        side_effect=lambda sel: MagicMock() if sel == ".CheckboxCaptcha" else None
    )
    page.evaluate = AsyncMock(return_value=[None, None])

    with pytest.raises(YandexBlockedError):
        await reverse_via_playwright(56.838, 60.586, page)


# ---------------------------------------------------------------------------
# Mode resolver
# ---------------------------------------------------------------------------


def test_resolve_mode_auto_with_key():
    assert _resolve_mode("auto", "abc") == "api"


def test_resolve_mode_auto_without_key():
    assert _resolve_mode("auto", None) == "playwright"
    assert _resolve_mode("auto", "") == "playwright"


def test_resolve_mode_explicit_passes_through():
    assert _resolve_mode("api", None) == "api"
    assert _resolve_mode("playwright", "abc") == "playwright"


# ---------------------------------------------------------------------------
# Resumability — main() twice with same batch
# ---------------------------------------------------------------------------


def _make_db_mock(initial_sample: list[dict], processed_ids: set[int]):
    """Build a MagicMock SQLAlchemy session that:
    - returns `initial_sample` for the sampling SQL (text() with limit_per_district)
    - returns `processed_ids` for the resume SQL (text() with batch only)
    - records INSERTs so the test can count them
    """
    inserted: list[dict] = []

    db = MagicMock()
    db.begin_nested.return_value.__enter__ = lambda self: self
    db.begin_nested.return_value.__exit__ = lambda self, *a: False

    def execute_side_effect(sql, params=None):
        sql_str = str(sql)
        result = MagicMock()
        if "FROM houses h" in sql_str or "houses_in_districts" in sql_str:
            result.mappings.return_value.all.return_value = initial_sample
        elif "FROM address_mismatch_audit" in sql_str and "house_id" in sql_str:
            # Resume query — returns list of (house_id,) tuples.
            result.all.return_value = [(hid,) for hid in processed_ids]
        elif "INSERT INTO address_mismatch_audit" in sql_str:
            inserted.append(dict(params))
            # Simulate ON CONFLICT DO NOTHING — track id locally for re-run.
            processed_ids.add(params["house_id"])
            result = MagicMock()
        elif "ST_Distance" in sql_str:
            result.first.return_value = (42.0,)
        else:
            result = MagicMock()
        return result

    db.execute.side_effect = execute_side_effect
    db.commit = MagicMock()
    db.rollback = MagicMock()
    db.close = MagicMock()
    return db, inserted


async def test_audit_script_resumable(monkeypatch):
    """Run main() twice with the same batch — second pass inserts 0."""
    sample = [
        {
            "id": 1,
            "address": "ул Малышева 51",
            "lat": 56.838,
            "lon": 60.586,
            "district": "Кировский",
        },
        {"id": 2, "address": "ул Ленина 5", "lat": 56.840, "lon": 60.600, "district": "Ленинский"},
    ]
    processed_ids: set[int] = set()
    db, inserted = _make_db_mock(sample, processed_ids)

    # Force API mode without needing a real key.
    monkeypatch.setenv("YANDEX_GEOCODER_API_KEY", "TEST_KEY")

    fake_result = YandexReverseResult(
        address="Россия, Екатеринбург, улица Малышева, 51",
        snapped_lat=56.838004,
        snapped_lon=60.586155,
        raw={"ok": True},
    )

    with (
        patch("scripts.audit_address_mismatch.SessionLocal", return_value=db),
        patch(
            "scripts.audit_address_mismatch.reverse_via_api",
            new=AsyncMock(return_value=fake_result),
        ),
    ):
        # First run — both rows processed.
        n1 = await main(["--batch", "test_batch_1", "--mode", "api"])
        assert n1 == 2
        assert len(inserted) == 2

        # Second run with same batch — nothing left to do.
        inserted.clear()
        n2 = await main(["--batch", "test_batch_1", "--mode", "api"])
        assert n2 == 0
        assert inserted == []


async def test_audit_script_api_mode_marks_error(monkeypatch):
    """When the reverse call raises, the row is still inserted with status=error."""
    sample = [
        {
            "id": 99,
            "address": "ул Малышева 51",
            "lat": 56.838,
            "lon": 60.586,
            "district": "Кировский",
        },
    ]
    processed_ids: set[int] = set()
    db, inserted = _make_db_mock(sample, processed_ids)

    monkeypatch.setenv("YANDEX_GEOCODER_API_KEY", "TEST_KEY")

    with (
        patch("scripts.audit_address_mismatch.SessionLocal", return_value=db),
        patch(
            "scripts.audit_address_mismatch.reverse_via_api",
            new=AsyncMock(side_effect=httpx.HTTPError("boom")),
        ),
    ):
        n = await main(["--batch", "err_batch", "--mode", "api"])
        assert n == 1

    assert len(inserted) == 1
    assert inserted[0]["audit_status"] == "error"
    assert "boom" in (inserted[0]["error_message"] or "")


# ---------------------------------------------------------------------------
# Internal _run_api_mode no-match path
# ---------------------------------------------------------------------------


async def test_api_mode_no_match_path():
    """If Yandex returns address=None, row goes in with status=no_match."""
    sample = [SampleRow(id=7, address="ул X 1", lat=56.0, lon=60.0, district="Кировский")]
    processed_ids: set[int] = set()
    db, inserted = _make_db_mock([], processed_ids)

    res = YandexReverseResult(address=None, snapped_lat=None, snapped_lon=None, raw={"empty": True})

    with patch(
        "scripts.audit_address_mismatch.reverse_via_api",
        new=AsyncMock(return_value=res),
    ):
        n = await _run_api_mode(db, sample, "b1", "key")

    assert n == 1
    assert inserted[0]["audit_status"] == "no_match"
    assert inserted[0]["snapped_address"] is None
