"""Tests for the daily asking→sold ratio refresh (#648 Stage 4).

recompute_asking_to_sold_ratios is SQL-heavy, so most assertions are static: we read the
emitted SQL via .text and check the TRUE-MIRROR shape (DELETE WHERE district='' BEFORE the
re-derivation INSERT), that the derivation reuses the exact 080 logic (deals + listings, the
trailing-12mo window, the 30/30 threshold, the global -1 fallback row), and the psycopg-v3
cast discipline (no :param::type). We also assert the migration 082 contents.

Plus one cheap behavioural test: a fake db (monkeypatched .execute) drives the counter logic
without a real Postgres.

Static style mirrors tests/test_listing_source_snapshot.py.
"""

import inspect
import os
import re
from pathlib import Path
from typing import Any

import pytest

# Importing app.services.scheduler / app.tasks pulls app.core.config.Settings → needs
# DATABASE_URL. Stub it BEFORE app imports (as in test_scheduler.py) — these tests are
# static / fake-db; no live database is touched.
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")

from app.tasks import asking_to_sold_ratio as ratio_mod

_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
_MIGRATION_080 = _SQL_DIR / "080_asking_to_sold_ratios.sql"
_MIGRATION_082 = _SQL_DIR / "082_scrape_schedules_seed_ratio_refresh.sql"

# Emitted SQL text (SQLAlchemy text() clause → .text gives the raw string).
_DELETE_SQL = str(ratio_mod._DELETE_SQL.text)
_REDERIVE_SQL = str(ratio_mod._REDERIVE_SQL.text)
_COUNTERS_SQL = str(ratio_mod._COUNTERS_SQL.text)
_ALL_SQL = _DELETE_SQL + "\n" + _REDERIVE_SQL + "\n" + _COUNTERS_SQL
_TASK_SRC = inspect.getsource(ratio_mod.recompute_asking_to_sold_ratios)


# ── TRUE-MIRROR: DELETE-then-INSERT ───────────────────────────────────────────


def test_refresh_deletes_district_rows_first() -> None:
    """True-mirror: DELETE FROM asking_to_sold_ratios WHERE district='' before the INSERT.

    The 080 seed used ON CONFLICT DO UPDATE which leaves stale per-rooms rows for buckets
    that drop below 30/30 on a later run. The refresh must DELETE first instead.
    """
    flat_del = re.sub(r"\s+", " ", _DELETE_SQL).strip()
    assert "DELETE FROM asking_to_sold_ratios" in flat_del
    assert "WHERE district = ''" in flat_del
    # The re-derivation is an INSERT (no ON CONFLICT — DELETE precedes it).
    assert "INSERT INTO asking_to_sold_ratios" in _REDERIVE_SQL
    assert "ON CONFLICT" not in _REDERIVE_SQL


def test_task_body_runs_delete_before_insert_in_one_txn() -> None:
    """Task executes DELETE then re-derive INSERT, commits ONCE (atomic refresh)."""
    body = _TASK_SRC
    del_pos = body.index("_DELETE_SQL")
    ins_pos = body.index("_REDERIVE_SQL")
    commit_pos = body.index("db.commit()")
    # DELETE executed before INSERT, both before the single commit (one transaction).
    assert del_pos < ins_pos < commit_pos
    # Exactly one commit — never an intermediate commit that could leave the table empty.
    assert body.count("db.commit()") == 1


# ── Derivation matches 080 (refresh == re-seed) ───────────────────────────────


def test_rederivation_references_deals_and_listings() -> None:
    assert "FROM deals" in _REDERIVE_SQL
    assert "FROM listings" in _REDERIVE_SQL
    assert "source = 'rosreestr'" in _REDERIVE_SQL
    assert "is_active" in _REDERIVE_SQL


def test_rederivation_uses_12_month_window() -> None:
    """Trailing-12mo deal window — same as the 080 seed."""
    assert "deal_date >= CURRENT_DATE - INTERVAL '12 months'" in _REDERIVE_SQL
    # window_months literal 12 also persisted into the rows.
    flat = re.sub(r"\s+", " ", _REDERIVE_SQL)
    assert "12 AS window_months" in flat


def test_rederivation_applies_3030_threshold_and_ppm_band() -> None:
    assert "d.n_deals >= 30" in _REDERIVE_SQL
    assert "a.n_listings >= 30" in _REDERIVE_SQL
    # ppm² bounds are now bind parameters (not hardcoded literals) — prevents SQL injection
    # and makes the ceiling configurable via settings.asking_ratio_ppm2_max (#767).
    assert "price_per_m2 BETWEEN :ppm2_min AND :ppm2_max" in _REDERIVE_SQL
    assert "LEAST(GREATEST(rooms, 0), 4)" in _REDERIVE_SQL
    assert "percentile_cont(0.5)" in _REDERIVE_SQL


def test_rederivation_writes_global_fallback_row() -> None:
    """Global -1 fallback row (basis='global_fallback') always written when ask>0."""
    assert "-1                                        AS rooms_bucket" in _REDERIVE_SQL or (
        "-1" in _REDERIVE_SQL and "rooms_bucket" in _REDERIVE_SQL
    )
    assert "'global_fallback'" in _REDERIVE_SQL
    assert "'per_rooms'" in _REDERIVE_SQL
    assert "deal_global" in _REDERIVE_SQL
    assert "ask_global" in _REDERIVE_SQL


def test_rederivation_cte_blocks_match_080() -> None:
    """The CTE names are byte-for-byte the 080 derivation blocks."""
    for cte in ("deal_side", "ask_side", "per_bucket", "deal_global", "ask_global", "global_row"):
        assert f"{cte} AS" in _REDERIVE_SQL, f"missing CTE {cte!r}"


def test_rederivation_scopes_sold_side_to_asking_city() -> None:
    """#C2: SOLD-сторона (deal_side + deal_global) скоупится на город asking-стороны (ЕКБ).

    Миграция 177 залила ДКП по всей обл.66, а asking (listings) — только ЕКБ. Без скоупа
    sold-медиана смешивала дешёвую область → ratio 0.877→0.62, «выкупная» −29%. Оба
    deal-CTE (per-rooms + global) должны нести предикат; ask-стороны — НЕ трогаем.
    """
    assert ratio_mod._ASKING_CITY_PATTERN == "%Екатеринбург%"
    # Оба deal-CTE (deal_side + deal_global) скоупятся — ровно 2 вхождения.
    assert _REDERIVE_SQL.count("AND city ILIKE :asking_city") == 2
    # ask-стороны (listings) НЕ фильтруются по городу (в listings нет колонки city).
    _a = _REDERIVE_SQL.index("ask_side AS")
    _b = _REDERIVE_SQL.index("per_bucket AS")
    assert "asking_city" not in _REDERIVE_SQL[_a:_b]


def _strip_sql(s: str) -> str:
    """Drop -- line comments and collapse whitespace — leaves only the executable SQL.

    Comments aren't load-bearing; the derivation MUST match 080 in its executable form
    (the CTE expressions / filters), which is what 'refresh == re-seed' means.
    """
    no_comments = re.sub(r"--[^\n]*", "", s)
    return re.sub(r"\s+", " ", no_comments).strip()


def test_migration_080_derivation_is_subset_of_refresh_sql() -> None:
    """Refresh derivation == 080 seed derivation (the WITH...SELECT before ON CONFLICT).

    Strip -- comments + normalise whitespace, then confirm the 080 CTE body
    (deal_side … per_bucket UNION ALL) is present verbatim in the refresh SQL — so
    refresh has exact re-seed semantics on the EXECUTABLE derivation.

    ppm² bounds are compared after normalisation: the 080 seed uses literal integers
    (30000 / 600000) while the refresh uses bind params (:ppm2_min / :ppm2_max) —
    both are replaced with the token PPM2_BAND_PLACEHOLDER before the subset check.

    #1186: the refresh now adds the novostroyki guard predicate to each ask_* CTE;
    it is normalised away here so the 080 seed (no guard) still matches.
    """
    seed_sql = _MIGRATION_080.read_text("utf-8")
    # Extract the WITH … (up to the ON CONFLICT) from the seed.
    with_idx = seed_sql.index("WITH")
    onconflict_idx = seed_sql.index("ON CONFLICT (rooms_bucket, district) DO UPDATE")
    seed_derivation = seed_sql[with_idx:onconflict_idx]

    def _normalise_ppm2(s: str) -> str:
        """Replace ppm² bound tokens with a common placeholder for cross-comparison."""
        # Seed uses literal integers; refresh uses bind params.
        s = re.sub(r"30000 AND 600000", "PPM2_BAND_PLACEHOLDER", s)
        s = re.sub(r":ppm2_min AND :ppm2_max", "PPM2_BAND_PLACEHOLDER", s)
        return s

    def _drop_segment_guard(s: str) -> str:
        """Remove the #1186 novostroyki guard predicate (absent in the 080 seed)."""
        return re.sub(
            r"AND\s*\((?:\w+\.)?listing_segment\s+IS\s+NULL\s+"
            r"OR\s+(?:\w+\.)?listing_segment\s*=\s*'vtorichka'\)",
            "",
            s,
        )

    def _drop_city_guard(s: str) -> str:
        """Remove the #C2 EKB city-scope predicate on the SOLD side (absent in the 080 seed)."""
        return re.sub(r"AND\s+city\s+ILIKE\s+:asking_city", "", s)

    def _norm(s: str) -> str:
        return _strip_sql(_normalise_ppm2(_drop_city_guard(_drop_segment_guard(s))))

    assert _norm(seed_derivation) in _norm(_REDERIVE_SQL)


# ── Counters query ────────────────────────────────────────────────────────────


def test_counters_query_returns_three_counters() -> None:
    assert "rows_written" in _COUNTERS_SQL
    assert "per_rooms_rows" in _COUNTERS_SQL
    assert "used_global_fallback" in _COUNTERS_SQL
    assert "FILTER (WHERE basis = 'per_rooms')" in _COUNTERS_SQL
    assert "FILTER (WHERE rooms_bucket = -1)" in _COUNTERS_SQL
    assert "FROM asking_to_sold_ratios" in _COUNTERS_SQL


# ── psycopg v3 cast discipline ────────────────────────────────────────────────


def test_sql_uses_no_double_colon_bind_casts() -> None:
    """psycopg v3: never :param::type. Literal ::-casts on expressions are fine."""
    assert not re.search(r":\w+::", _ALL_SQL)


# ── Task return / finalisation contract ───────────────────────────────────────


def test_task_returns_counters_and_finalises_run() -> None:
    assert "mark_done" in _TASK_SRC
    assert "mark_failed" in _TASK_SRC
    assert "db.rollback()" in _TASK_SRC
    assert "raise" in _TASK_SRC  # re-raise on failure — no silent swallow


# ── Migration 082 ─────────────────────────────────────────────────────────────


def test_migration_082_exists() -> None:
    assert _MIGRATION_082.is_file(), f"missing migration: {_MIGRATION_082}"


def test_migration_082_seeds_schedule_enabled_true_window_6_7() -> None:
    sql = _MIGRATION_082.read_text("utf-8")
    assert "'asking_to_sold_ratio_refresh'" in sql
    assert "'{}'::jsonb" in sql
    assert "ON CONFLICT (source) DO NOTHING" in sql
    # enabled=true (SAFE — pure internal DB), window 6..7 UTC (after rosreestr 04:00-06:00).
    assert "true,                                        -- SAFE" in sql
    insert_block = sql.split("INSERT INTO scrape_schedules")[1]
    assert "false" not in insert_block
    # window_start_hour=6, window_end_hour=7 — assert the two literals appear post-INSERT.
    flat = re.sub(r"[ \t]+", " ", insert_block)
    assert "\n 6,\n 7,\n" in flat or ("6," in flat and "7," in flat)
    # next_run_at = tomorrow + 06:00 UTC.
    assert "make_interval(hours => 6)" in sql
    assert "BEGIN;" in sql and "COMMIT;" in sql


def test_migration_082_updates_table_comment() -> None:
    sql = _MIGRATION_082.read_text("utf-8")
    assert "COMMENT ON TABLE scrape_schedules" in sql
    assert "asking_to_sold_ratio_refresh (#648)" in sql


def test_migration_082_uses_psycopg_safe_sql() -> None:
    """Migration is plain DDL (no bind params), but guard against accidental :x::type."""
    sql = _MIGRATION_082.read_text("utf-8")
    assert not re.search(r":\w+::", sql)


# ── Cheap behavioural test: counter logic via fake db ─────────────────────────


class _FakeMappingResult:
    def __init__(self, row: dict[str, Any] | None) -> None:
        self._row = row

    def mappings(self) -> "_FakeMappingResult":
        return self

    def first(self) -> dict[str, Any] | None:
        return self._row


class _FakeDB:
    """Minimal stand-in for a SQLAlchemy Session — records execute() calls.

    Execute call order:
      1. DELETE asking_to_sold_ratios
      2. REDERIVE INSERT asking_to_sold_ratios
      3. COUNTERS SELECT (returns counters_row)
    """

    def __init__(
        self,
        counters_row: dict[str, Any],
    ) -> None:
        self._counters_row = counters_row
        self.executed: list[Any] = []
        self.committed = False
        self.rolled_back = False

    def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeMappingResult:
        self.executed.append((stmt, params))
        # 3rd call is the counters SELECT (asking_to_sold_ratios).
        if len(self.executed) == 3:
            return _FakeMappingResult(self._counters_row)
        return _FakeMappingResult(None)

    def commit(self) -> None:
        self.committed = True

    def rollback(self) -> None:
        self.rolled_back = True


def test_counter_logic_with_fake_db(monkeypatch: pytest.MonkeyPatch) -> None:
    """recompute_asking_to_sold_ratios maps the counters row into its return + marks done.

    #2002: tiered refresh removed — counters are the 3 legacy keys, FakeDB has 3
    execute() calls (DELETE + REDERIVE INSERT + COUNTERS SELECT).
    """
    marked: dict[str, Any] = {}
    monkeypatch.setattr(
        ratio_mod.runs_mod,
        "mark_done",
        lambda _db, run_id, counters: marked.update(run_id=run_id, counters=dict(counters)),
    )
    monkeypatch.setattr(ratio_mod.runs_mod, "mark_failed", lambda *a, **k: None)

    db = _FakeDB(
        counters_row={"rows_written": 4, "per_rooms_rows": 3, "used_global_fallback": 1},
    )
    out = ratio_mod.recompute_asking_to_sold_ratios(db, run_id=99)  # type: ignore[arg-type]

    expected = {
        "rows_written": 4,
        "per_rooms_rows": 3,
        "used_global_fallback": 1,
    }
    assert out == expected
    assert db.committed is True
    # Three statements: DELETE + REDERIVE INSERT + COUNTERS SELECT (legacy per-rooms).
    assert len(db.executed) == 3
    assert marked["run_id"] == 99
    assert marked["counters"] == expected


def test_counter_logic_failure_path_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None:
    """On execute error: rollback + mark_failed + re-raise (no silent swallow)."""
    failed: dict[str, Any] = {}
    monkeypatch.setattr(ratio_mod.runs_mod, "mark_done", lambda *a, **k: None)
    monkeypatch.setattr(
        ratio_mod.runs_mod,
        "mark_failed",
        lambda _db, run_id, err, counters: failed.update(run_id=run_id, err=err),
    )

    class _BoomDB(_FakeDB):
        def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeMappingResult:
            raise RuntimeError("boom")

    db = _BoomDB(counters_row={})
    with pytest.raises(RuntimeError, match="boom"):
        ratio_mod.recompute_asking_to_sold_ratios(db, run_id=7)  # type: ignore[arg-type]
    assert db.rolled_back is True
    assert failed["run_id"] == 7


# ── Migration 098 band / settings-default consistency ────────────────────────


def test_migration_098_band_matches_settings_default() -> None:
    """Migration 098 seed hardcodes ppm² band [30000, 1_200_000].

    These literals MUST stay in sync with:
      - app/tasks/asking_to_sold_ratio._PPM2_MIN  (lower bound)
      - app.core.config.Settings.asking_ratio_ppm2_max default (upper bound)

    If either default changes, the one-time migration seed diverges from the
    daily-refresh output — this test is the regression guard.
    """
    from app.core.config import Settings
    from app.tasks.asking_to_sold_ratio import _PPM2_MIN

    # Lower bound — matches the 30000 literal in migration 098.
    assert (
        _PPM2_MIN == 30_000
    ), f"_PPM2_MIN changed ({_PPM2_MIN}); update migration 098 seed literals to match"
    # Upper bound — matches the 1200000 literal in migration 098.
    assert (
        Settings().asking_ratio_ppm2_max == 1_200_000
    ), "asking_ratio_ppm2_max default changed; update migration 098 seed literals to match"
