"""Unit tests for app.services.location_coef (#2045 BE-3, LocationDrawer).

No live Postgres needed — DB is a minimal fake returning queued results (mirrors the
convention in tests/tasks/test_cadastral_geo_match.py). Covers:
  - pure functions: _category_weight, _score_to_coef, normalization constants
  - compute_location_coef: weighted top-N scoring, empty-mirror graceful fallback,
    no-POI-in-radius (legit zero-score, NOT "unavailable")
"""

from __future__ import annotations

import os
from typing import Any

# psycopg v3 driver required; stub DATABASE_URL before any app import (settings needs a DSN).
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")

from app.services import location_coef as lc

# ── Pure functions ──────────────────────────────────────────────────────────


def test_category_weight_known_categories() -> None:
    assert lc._category_weight("metro_stop") == 6.0
    assert lc._category_weight("school") == 5.0
    assert lc._category_weight("kindergarten") == 4.5
    assert lc._category_weight("hospital") == 4.0
    assert lc._category_weight("shop_mall") == 4.0
    assert lc._category_weight("shop_supermarket") == 3.5
    assert lc._category_weight("bus_stop") == 4.5
    assert lc._category_weight("park") == 3.5
    assert lc._category_weight("pharmacy") == 2.5
    assert lc._category_weight("tram_stop") == 2.0
    assert lc._category_weight("shop_small") == 2.0


def test_category_weight_unknown_and_none_fall_back_to_default() -> None:
    assert lc._category_weight("unknown_category") == 1.0
    assert lc._category_weight(None) == 1.0


def test_top7_weight_sum_matches_ptica() -> None:
    """Same category set as Site Finder → identical top-7 weight-sum constant (31.5)."""
    assert lc._TOP7_WEIGHT_SUM == 31.5


def test_max_straight_score_normalized_at_reference_distance_not_zero() -> None:
    """Audit R2 #7a: denominator uses _REF_DISTANCE_M (100m), not an unreachable d=0 max.

    Old d=0 normalization was _TOP7_WEIGHT_SUM / 100 == 0.315 — the theoretical max with all
    top-7 categories AT the doorstep. That compressed realistic scores (~25-30/100 for even a
    strong central address) into the bottom third of the range. The new normalization halves
    the max (denominator distance+100 doubles from 100 to 200 at REF=100m), doubling realistic
    scores instead.
    """
    assert lc._REF_DISTANCE_M == 100.0
    assert abs(lc._MAX_STRAIGHT_SCORE - 0.1575) < 1e-9


def test_score_to_coef_bounds() -> None:
    assert lc._score_to_coef(0.0) == 0.95
    assert lc._score_to_coef(100.0) == 1.05


def test_score_to_coef_midpoint() -> None:
    assert lc._score_to_coef(50.0) == 1.0


def test_score_to_coef_is_monotonic() -> None:
    scores = [0.0, 10.0, 25.0, 50.0, 75.0, 90.0, 100.0]
    coefs = [lc._score_to_coef(s) for s in scores]
    assert coefs == sorted(coefs)


# ── compute_location_coef with a fake DB ─────────────────────────────────────


class _FakeResult:
    def __init__(self, *, scalar_value: Any = None, mapping_rows: list[dict] | None = None):
        self._scalar_value = scalar_value
        self._mapping_rows = mapping_rows or []

    def scalar(self) -> Any:
        return self._scalar_value

    def mappings(self) -> Any:
        class _Mappings:
            def __init__(self, rows: list[dict]) -> None:
                self._rows = rows

            def all(self) -> list[dict]:
                return self._rows

        return _Mappings(self._mapping_rows)


class _FakeDB:
    """Minimal Session stand-in: execute() returns queued results in order."""

    def __init__(self, results: list[_FakeResult]) -> None:
        self._results = list(results)
        self.executed: list[Any] = []

    def execute(self, clause: Any, params: dict | None = None) -> _FakeResult:
        self.executed.append((clause, params))
        return self._results.pop(0)


def test_compute_location_coef_empty_mirror_returns_unavailable() -> None:
    """osm_poi_ekb_local not yet refreshed (count=0) → unavailable, no fabricated factors."""
    db = _FakeDB([_FakeResult(scalar_value=0)])
    result = lc.compute_location_coef(db, lat=56.84, lon=60.6)
    assert result.coef == 1.0
    assert result.factors == []
    assert result.geo_source == "unavailable"
    # Only the count probe ran — no nearest-POI query issued against an empty mirror.
    assert len(db.executed) == 1


def test_compute_location_coef_no_poi_in_radius_is_legit_zero_score() -> None:
    """Mirror populated (count>0) but nothing within radius → coef floor, NOT unavailable."""
    db = _FakeDB(
        [
            _FakeResult(scalar_value=500),  # mirror has rows elsewhere
            _FakeResult(mapping_rows=[]),  # nothing near this point
        ]
    )
    result = lc.compute_location_coef(db, lat=56.84, lon=60.6)
    assert result.factors == []
    assert result.geo_source == "osm_poi_ekb"
    assert result.coef == lc._score_to_coef(0.0) == 0.95


def test_compute_location_coef_weights_and_ranks_top_n() -> None:
    """Nearer + higher-weight-category POI ranks above farther/lower-weight ones."""
    rows = [
        {"name": "Школа №1", "category": "school", "distance_m": 300.0},
        {"name": "ТЦ Мега", "category": "shop_mall", "distance_m": 900.0},
        {"name": "Метро Ботаническая", "category": "metro_stop", "distance_m": 150.0},
        {"name": "Аптека", "category": "pharmacy", "distance_m": 50.0},
    ]
    db = _FakeDB([_FakeResult(scalar_value=1000), _FakeResult(mapping_rows=rows)])
    result = lc.compute_location_coef(db, lat=56.84, lon=60.6, top_n=7)

    assert result.geo_source == "osm_poi_ekb"
    assert len(result.factors) == 4
    # metro_stop (weight 6.0) at 150m beats school (5.0) at 300m despite being closer only
    # marginally — sanity check the ranking is weight-driven, not distance-only.
    assert result.factors[0].poi_type == "metro_stop"
    # Weights strictly descending (sorted DESC by weight before slicing to top_n).
    weights = [f.weight for f in result.factors]
    assert weights == sorted(weights, reverse=True)
    # coef must land inside the documented [0.95, 1.05] MVP range.
    assert 0.95 <= result.coef <= 1.05


def test_compute_location_coef_strong_central_fixture_lands_near_one() -> None:
    """Audit R2 #7a: a realistic strong central-EKB POI profile now reads as ~1.0, not ~0.98.

    Fixture mirrors a real strong central address audit: near transit/kindergarten/pharmacy,
    but school/metro/mall noticeably farther (a typical quarter profile, NOT everything at
    the doorstep). Under the OLD d=0 normalization this fixture scores ~28/100 → coef ~0.978
    (verified separately against the pre-fix formula). After re-centering on _REF_DISTANCE_M
    it should land close to 1.0 (within the documented ±0.01 calibration target).
    """
    rows = [
        {"name": "Остановка", "category": "bus_stop", "distance_m": 80.0},
        {"name": "Аптека", "category": "pharmacy", "distance_m": 120.0},
        {"name": "Супермаркет", "category": "shop_supermarket", "distance_m": 180.0},
        {"name": "Детсад №5", "category": "kindergarten", "distance_m": 220.0},
        {"name": "Школа №10", "category": "school", "distance_m": 350.0},
        {"name": "Метро Геологическая", "category": "metro_stop", "distance_m": 500.0},
        {"name": "ТЦ Гринвич", "category": "shop_mall", "distance_m": 700.0},
    ]
    db = _FakeDB([_FakeResult(scalar_value=len(rows)), _FakeResult(mapping_rows=rows)])
    result = lc.compute_location_coef(db, lat=56.838, lon=60.605, top_n=7)

    assert result.geo_source == "osm_poi_ekb"
    assert len(result.factors) == 7
    assert abs(result.coef - 1.0) <= 0.01


def test_compute_location_coef_weak_fixture_stays_well_below_one() -> None:
    """A sparse/poor location (only far, low-weight POI) must still stay well below 1.0."""
    rows = [
        {"name": "Магазинчик", "category": "shop_small", "distance_m": 950.0},
        {"name": "Прочее", "category": "some_unknown_tag", "distance_m": 1100.0},
    ]
    db = _FakeDB([_FakeResult(scalar_value=len(rows)), _FakeResult(mapping_rows=rows)])
    result = lc.compute_location_coef(db, lat=56.838, lon=60.605, top_n=7)

    assert result.geo_source == "osm_poi_ekb"
    assert result.coef < 0.98


def test_compute_location_coef_limits_to_top_n() -> None:
    """More than top_n candidates → only top_n factors surface in the response."""
    rows = [
        {"name": f"POI {i}", "category": "shop_small", "distance_m": float(100 + i * 10)}
        for i in range(20)
    ]
    db = _FakeDB([_FakeResult(scalar_value=20), _FakeResult(mapping_rows=rows)])
    result = lc.compute_location_coef(db, lat=56.84, lon=60.6, top_n=7)
    assert len(result.factors) == 7


def test_compute_location_coef_unknown_category_uses_default_weight() -> None:
    rows = [{"name": "Неизвестный POI", "category": "some_new_osm_tag", "distance_m": 200.0}]
    db = _FakeDB([_FakeResult(scalar_value=1), _FakeResult(mapping_rows=rows)])
    result = lc.compute_location_coef(db, lat=56.84, lon=60.6)
    assert len(result.factors) == 1
    expected_weight = (1.0 / (200.0 + 100.0)) * lc.CATEGORY_WEIGHTS["default"]
    assert abs(result.factors[0].weight - round(expected_weight, 6)) < 1e-9


def test_compute_location_coef_passes_radius_param() -> None:
    """radius_m is forwarded as a bound param (psycopg v3 CAST discipline, no :p::type)."""
    db = _FakeDB([_FakeResult(scalar_value=1), _FakeResult(mapping_rows=[])])
    lc.compute_location_coef(db, lat=56.84, lon=60.6, radius_m=1500)
    _clause, params = db.executed[1]
    assert params is not None
    assert params["radius_m"] == 1500


def test_no_psycopg_v3_colon_colon_cast() -> None:
    """psycopg v3: never :param::type — must use CAST(:param AS type)."""
    import re

    assert not re.search(r":\w+::", str(lc._NEAREST_POI_SQL.text))
