"""Unit tests for ppm2-tier asking->sold ratio lookup (#928).

Tests for the _get_asking_sold_ratio helper with tier_aware_ratio_enabled flag.

Five scenarios:
1. flag OFF  -> uses old asking_to_sold_ratios table (byte-identical to pre-#928).
2. flag ON, anchor in low band  -> returns shrunk tier ratio, basis startswith 'per_rooms_tier'.
3. flag ON, no tier row for that cell  -> fallback to 'all' (basis 'per_rooms_all').
4. flag ON, no 'all' row either  -> global (-1) fallback (basis 'global_all').
5. flag ON, no tables / empty  -> (None, None), no raise.

Static callers audit: _get_asking_sold_ratio gained anchor_ppm2 param (default None),
so all existing call sites using positional (db, rooms) continue to work unchanged.
"""

from __future__ import annotations

import os
from typing import Any
from unittest.mock import MagicMock, patch

# Settings requires DATABASE_URL at init time. Set dummy DSN before any app import.
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")


class _FakeRow:
    """Stand-in for a SQLAlchemy Row."""

    def __init__(self, **kw: Any) -> None:
        for k, v in kw.items():
            setattr(self, k, v)


def _clear_ratio_cache() -> None:
    from app.services import estimator

    estimator._asking_sold_ratio_cache.clear()


# ── helper: build a MagicMock db returning consecutive fetchone() results ─────


def _db_sequence(rows: list[Any]) -> MagicMock:
    """MagicMock db whose execute().fetchone() calls return `rows` in order."""
    db = MagicMock()
    db.execute.return_value.fetchone.side_effect = rows
    return db


# ── 1. Flag OFF -> old table, ignores anchor_ppm2 ────────────────────────────


def test_flag_off_uses_old_table_ignores_anchor_ppm2() -> None:
    """Flag OFF: _get_asking_sold_ratio(db, 2, anchor_ppm2=90000) hits asking_to_sold_ratios."""
    from app.core.config import settings
    from app.services.estimator import _get_asking_sold_ratio

    _clear_ratio_cache()
    db = _db_sequence([_FakeRow(ratio=0.80, basis="per_rooms")])

    with patch.object(settings, "tier_aware_ratio_enabled", False):
        ratio, basis = _get_asking_sold_ratio(db, 2, anchor_ppm2=90_000.0)

    assert ratio == 0.80
    assert basis == "per_rooms"
    # Only one DB query (old path, no tier tables touched)
    assert db.execute.call_count == 1
    # The query must target asking_to_sold_ratios (old table)
    sql_str = str(db.execute.call_args_list[0].args[0])
    assert "asking_to_sold_ratios" in sql_str
    assert "tiered" not in sql_str


def test_flag_off_no_anchor_ppm2_still_works() -> None:
    """Flag OFF with no anchor_ppm2 (positional call) still works as before."""
    from app.core.config import settings
    from app.services.estimator import _get_asking_sold_ratio

    _clear_ratio_cache()
    db = _db_sequence([_FakeRow(ratio=0.82, basis="per_rooms")])

    with patch.object(settings, "tier_aware_ratio_enabled", False):
        ratio, basis = _get_asking_sold_ratio(db, 1)

    assert ratio == 0.82
    assert basis == "per_rooms"


def test_flag_off_cache_key_is_legacy_not_none() -> None:
    """Flag OFF must cache under (bucket, '_legacy'), NOT (bucket, None).

    This prevents key collision with the flag-ON Step-3 'all' fallback which also
    caches under (bucket, None). If the process flips tier_aware_ratio_enabled ON
    without restart, the '_legacy' entry is not accidentally served as the 'all' row.
    """
    from app.core.config import settings
    from app.services import estimator
    from app.services.estimator import _get_asking_sold_ratio

    _clear_ratio_cache()
    bucket = min(max(2, 0), 4)
    db = _db_sequence([_FakeRow(ratio=0.77, basis="per_rooms")])

    with patch.object(settings, "tier_aware_ratio_enabled", False):
        _get_asking_sold_ratio(db, 2)

    # Flag-OFF entry must be under "_legacy", not None.
    assert (bucket, "_legacy") in estimator._asking_sold_ratio_cache
    assert (bucket, None) not in estimator._asking_sold_ratio_cache


# ── 2. Flag ON, anchor in low band -> shrunk tier ratio ──────────────────────


def test_flag_on_low_band_returns_shrunk_tier_ratio() -> None:
    """Flag ON, anchor < t33 -> tier='low', shrunk = tier*w + all*(1-w)."""
    from app.core.config import settings
    from app.services.estimator import _get_asking_sold_ratio

    _clear_ratio_cache()
    # DB calls sequence:
    #   1. bounds (t33=130000, t66=165000)
    #   2. tier row (ratio=0.91, n_deals=200)
    #   3. 'all' row (ratio=0.80)
    db = _db_sequence(
        [
            _FakeRow(t33=130_000, t66=165_000),  # bounds
            _FakeRow(ratio=0.91, n_deals=200),  # tier row
            _FakeRow(ratio=0.80),  # 'all' row
        ]
    )

    k = 150
    w = 200 / (200 + k)
    expected_ratio = 0.91 * w + 0.80 * (1.0 - w)

    with (
        patch.object(settings, "tier_aware_ratio_enabled", True),
        patch.object(settings, "tier_ratio_shrink_k", k),
    ):
        ratio, basis = _get_asking_sold_ratio(db, 2, anchor_ppm2=90_000.0)

    assert ratio is not None
    assert abs(ratio - expected_ratio) < 1e-9
    assert basis is not None
    assert basis.startswith("per_rooms_tier")
    assert "low" in basis


# ── 3. Flag ON, no tier row -> fallback to 'all' ─────────────────────────────


def test_flag_on_no_tier_row_falls_back_to_all() -> None:
    """Flag ON, anchor in mid band, but no tier row for (bucket,mid) -> 'all' fallback."""
    from app.core.config import settings
    from app.services.estimator import _get_asking_sold_ratio

    _clear_ratio_cache()
    # DB calls:
    #   1. bounds (t33=130000, t66=165000)
    #   2. tier row -> None (cell not populated)
    #   3. 'all' row (ratio=0.80) -- used as both shrink-target AND fallback
    # Since tier_row is None, we skip shrink and fall through to step 3.
    db = _db_sequence(
        [
            _FakeRow(t33=130_000, t66=165_000),  # bounds
            None,  # tier row missing
            _FakeRow(ratio=0.80),  # 'all' row (shrink target query)
            _FakeRow(ratio=0.80),  # 'all' row (fallback step 3)
        ]
    )

    with (
        patch.object(settings, "tier_aware_ratio_enabled", True),
        patch.object(settings, "tier_ratio_shrink_k", 150),
    ):
        ratio, basis = _get_asking_sold_ratio(db, 2, anchor_ppm2=145_000.0)

    assert ratio == 0.80
    assert basis == "per_rooms_all"


# ── 4. Flag ON, no 'all' row -> global fallback ───────────────────────────────


def test_flag_on_no_all_row_falls_back_to_global() -> None:
    """Flag ON, bounds present, tier+all rows missing -> global -1 fallback."""
    from app.core.config import settings
    from app.services.estimator import _get_asking_sold_ratio

    _clear_ratio_cache()
    db = _db_sequence(
        [
            _FakeRow(t33=130_000, t66=165_000),  # bounds
            None,  # tier row -> None
            None,  # 'all' shrink-target -> None
            None,  # step-3 'all' fallback -> None
            _FakeRow(ratio=0.79),  # step-4 global (-1, all)
        ]
    )

    with patch.object(settings, "tier_aware_ratio_enabled", True):
        ratio, basis = _get_asking_sold_ratio(db, 2, anchor_ppm2=145_000.0)

    assert ratio == 0.79
    assert basis == "global_all"


# ── 5. Flag ON, no tables / empty -> (None, None), no raise ──────────────────


def test_flag_on_no_tables_returns_none_none() -> None:
    """Flag ON, db.execute raises (tables not migrated) -> (None, None), no raise."""
    from app.core.config import settings
    from app.services.estimator import _get_asking_sold_ratio

    _clear_ratio_cache()
    db = MagicMock()
    db.execute.side_effect = RuntimeError("relation asking_to_sold_tier_bounds does not exist")

    with patch.object(settings, "tier_aware_ratio_enabled", True):
        result = _get_asking_sold_ratio(db, 2, anchor_ppm2=90_000.0)

    assert result == (None, None)
    # rollback must be called on error
    db.rollback.assert_called()


# ── 6. Flag ON, no bounds row -> step-3 'all' fallback (skip tier entirely) ──


def test_flag_on_no_bounds_row_falls_back_to_all() -> None:
    """Flag ON, bounds row None -> skip tier, go straight to 'all' step 3."""
    from app.core.config import settings
    from app.services.estimator import _get_asking_sold_ratio

    _clear_ratio_cache()
    db = _db_sequence(
        [
            None,  # bounds -> None (bucket has no bound data)
            _FakeRow(ratio=0.80),  # step-3 'all' row
        ]
    )

    with patch.object(settings, "tier_aware_ratio_enabled", True):
        ratio, basis = _get_asking_sold_ratio(db, 2, anchor_ppm2=90_000.0)

    assert ratio == 0.80
    assert basis == "per_rooms_all"


# ── 7. Cache: (bucket, tier) key memoises per tier ───────────────────────────


def test_tier_cache_key_is_bucket_tier_tuple() -> None:
    """Second call with same (bucket, anchor in same tier) -> served from cache after bounds.

    Flow for second call:
      - cache_key starts as (bucket, None) -- miss
      - fetches bounds (1 DB query) -> determines tier_key="low"
      - cache_key = (bucket, "low") -- HIT (populated by first call)
      - returns cached ratio without further DB queries
    Total DB queries: first call (3) + second call (1 bounds) = 4.
    """
    from app.core.config import settings
    from app.services.estimator import _get_asking_sold_ratio

    _clear_ratio_cache()
    db = _db_sequence(
        [
            # First call: bounds + tier_row + all_row
            _FakeRow(t33=130_000, t66=165_000),
            _FakeRow(ratio=0.91, n_deals=200),
            _FakeRow(ratio=0.80),
            # Second call: bounds only (tier cache hit after that)
            _FakeRow(t33=130_000, t66=165_000),
        ]
    )

    with (
        patch.object(settings, "tier_aware_ratio_enabled", True),
        patch.object(settings, "tier_ratio_shrink_k", 150),
    ):
        first = _get_asking_sold_ratio(db, 2, anchor_ppm2=90_000.0)
        second = _get_asking_sold_ratio(db, 2, anchor_ppm2=95_000.0)  # same 'low' tier

    # Both calls return the same result (same tier).
    assert first == second
    # First call: 3 queries (bounds + tier_row + all_row).
    # Second call: 1 query (bounds) + cache hit on (bucket, "low").
    assert db.execute.call_count == 4
