"""Тесты get_own_portfolio — унифицированный источник «наших проектов» (#1169 PR1).

Покрывает:
1.  own_developer_ids пусто → current не запрашивается; только future-строки
2.  own_developer_ids пусто И future пуст → [] (graceful, §25.3 деградирует на None)
3.  current: мокнутые domrf-строки → OwnProject(source='current') + None-not-0
4.  future: мокнутые own_planned_project-строки → OwnProject(source='future')
5.  ready_dt / planned_release_month нормализуются к 1-му числу месяца
6.  current-запрос параметризуется списком own_developer_ids (ANY(:ids))
7.  current-SQL использует CAST(... AS integer[]), не ::int[] (psycopg v3)
8.  сбой current-запроса не валит future (graceful per-source)

Mock-стратегия: DB — MagicMock; own_developer_ids патчим на singleton settings
(сервис читает его при вызове). domrf/own_planned_project запросы различаем по
db.execute.side_effect (current первым, future вторым).
"""

from __future__ import annotations

from datetime import date
from decimal import Decimal
from typing import Any
from unittest.mock import MagicMock

from app.services.site_finder import own_portfolio
from app.services.site_finder.own_portfolio import (
    OwnProject,
    _query_current,
    _query_future,
    get_own_portfolio,
)


def _row(data: dict[str, Any]) -> MagicMock:
    m = MagicMock()
    m.__getitem__ = lambda self, k: data[k]
    return m


def _result(rows: list[MagicMock]) -> MagicMock:
    """execute(...).mappings().all() → rows."""
    res = MagicMock()
    res.mappings.return_value.all.return_value = rows
    return res


def _current_row(
    obj_id: int = 100,
    comm_name: str | None = "ЖК Текущий",
    obj_class: str | None = "Комфорт",
    district_name: str | None = "Октябрьский",
    ready_dt: date | None = date(2025, 11, 20),
    price_per_m2_min: Any = Decimal("130000"),
    price_per_m2_max: Any = Decimal("160000"),
    latitude: Any = Decimal("56.84"),
    longitude: Any = Decimal("60.61"),
) -> MagicMock:
    return _row(
        {
            "obj_id": obj_id,
            "comm_name": comm_name,
            "obj_class": obj_class,
            "district_name": district_name,
            "ready_dt": ready_dt,
            "price_per_m2_min": price_per_m2_min,
            "price_per_m2_max": price_per_m2_max,
            "latitude": latitude,
            "longitude": longitude,
        }
    )


def _future_row(
    project_id: int = 1,
    name: str = "ЖК Будущий",
    obj_class: str | None = "Бизнес",
    district: str | None = "Верх-Исетский",
    planned_release_month: date | None = date(2027, 6, 1),
    price_min_per_m2: Any = Decimal("180000"),
    price_max_per_m2: Any = Decimal("220000"),
    unit_mix: dict[str, float] | None = None,
    lon: Any = None,
    lat: Any = None,
) -> MagicMock:
    return _row(
        {
            "id": project_id,
            "name": name,
            "obj_class": obj_class,
            "district": district,
            "planned_release_month": planned_release_month,
            "price_min_per_m2": price_min_per_m2,
            "price_max_per_m2": price_max_per_m2,
            "unit_mix": unit_mix,
            "lon": lon,
            "lat": lat,
        }
    )


# ── empty own_developer_ids → future only / [] ──────────────────────────────────


def test_empty_developer_ids_skips_current_returns_future_only(monkeypatch) -> None:
    """own_developer_ids пусто → current НЕ запрашивается; только future-строки."""
    monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [])
    db = MagicMock()
    # Единственный execute — future-запрос (current не идёт в БД).
    db.execute.return_value = _result([_future_row()])

    out = get_own_portfolio(db)

    assert db.execute.call_count == 1  # только future
    assert len(out) == 1
    assert out[0].source == "future"
    assert out[0].name == "ЖК Будущий"


def test_empty_developer_ids_and_no_future_returns_empty(monkeypatch) -> None:
    """own_developer_ids пусто И future пуст → [] (graceful, не crash)."""
    monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [])
    db = MagicMock()
    db.execute.return_value = _result([])

    out = get_own_portfolio(db)

    assert out == []


# ── current (domrf) mapping ─────────────────────────────────────────────────────


def test_current_rows_mapped_to_own_project(monkeypatch) -> None:
    """Мокнутые domrf-строки → OwnProject(source='current') с корректным маппингом."""
    monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [12345])
    db = MagicMock()
    # current первым, future вторым (пустой).
    db.execute.side_effect = [_result([_current_row()]), _result([])]

    out = get_own_portfolio(db)

    assert len(out) == 1
    p = out[0]
    assert isinstance(p, OwnProject)
    assert p.source == "current"
    assert p.name == "ЖК Текущий"
    assert p.obj_class == "Комфорт"
    assert p.district == "Октябрьский"
    assert p.release_month == date(2025, 11, 1)  # ready_dt → 1-е число
    assert p.price_min_per_m2 == 130000.0
    assert p.price_max_per_m2 == 160000.0
    assert p.lat == 56.84
    assert p.lon == 60.61
    # current-проект не несёт квартирографии в этом запросе → None (None-not-0).
    assert p.unit_mix is None


def test_current_none_fields_stay_none(monkeypatch) -> None:
    """Отсутствующие поля domrf → None (None-not-0), не 0/«»."""
    monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [12345])
    db = MagicMock()
    row = _current_row(
        comm_name="ЖК Без Цены",
        obj_class=None,
        district_name=None,
        ready_dt=None,
        price_per_m2_min=None,
        price_per_m2_max=None,
        latitude=None,
        longitude=None,
    )
    db.execute.side_effect = [_result([row]), _result([])]

    out = get_own_portfolio(db)

    p = out[0]
    assert p.obj_class is None
    assert p.district is None
    assert p.release_month is None
    assert p.price_min_per_m2 is None
    assert p.price_max_per_m2 is None
    assert p.lat is None
    assert p.lon is None


def test_current_query_parametrised_with_developer_ids(monkeypatch) -> None:
    """current-запрос параметризуется списком own_developer_ids (ANY(:ids))."""
    monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [12345, 67890])
    db = MagicMock()
    db.execute.side_effect = [_result([]), _result([])]

    get_own_portfolio(db)

    current_params = db.execute.call_args_list[0].args[1]
    assert current_params["ids"] == [12345, 67890]


def test_current_sql_uses_cast_not_double_colon(monkeypatch) -> None:
    """current-SQL использует CAST(:ids AS integer[]), никогда :ids::int[] (psycopg v3)."""
    monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [12345])
    db = MagicMock()
    db.execute.side_effect = [_result([]), _result([])]

    get_own_portfolio(db)

    current_sql = str(db.execute.call_args_list[0].args[0])
    assert "CAST(" in current_sql
    assert "::" not in current_sql


# ── future (own_planned_project) mapping ────────────────────────────────────────


def test_future_rows_mapped_with_unit_mix(monkeypatch) -> None:
    """future-строки → OwnProject(source='future') с unit_mix-долями."""
    monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [])
    mix = {"studio": 0.3, "1k": 0.4, "2k": 0.3}
    db = MagicMock()
    db.execute.return_value = _result([_future_row(unit_mix=mix)])

    out = get_own_portfolio(db)

    p = out[0]
    assert p.source == "future"
    assert p.obj_class == "Бизнес"
    assert p.release_month == date(2027, 6, 1)
    assert p.price_min_per_m2 == 180000.0
    assert p.price_max_per_m2 == 220000.0
    assert p.unit_mix == mix


def test_future_release_month_normalised_to_first(monkeypatch) -> None:
    """planned_release_month с НЕ-первым числом → нормализуется к 1-му (тайминг §25.3)."""
    monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [])
    db = MagicMock()
    db.execute.return_value = _result([_future_row(planned_release_month=date(2027, 6, 17))])

    out = get_own_portfolio(db)

    assert out[0].release_month == date(2027, 6, 1)


# ── combined + graceful ─────────────────────────────────────────────────────────


def test_combined_current_and_future(monkeypatch) -> None:
    """current + future объединяются (current впереди future)."""
    monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [12345])
    db = MagicMock()
    db.execute.side_effect = [_result([_current_row()]), _result([_future_row()])]

    out = get_own_portfolio(db)

    assert len(out) == 2
    assert out[0].source == "current"
    assert out[1].source == "future"


def test_current_query_failure_does_not_break_future(monkeypatch) -> None:
    """Сбой current-запроса → current=[], future всё равно считается (graceful per-source)."""
    monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [12345])
    db = MagicMock()
    db.execute.side_effect = [RuntimeError("boom on domrf"), _result([_future_row()])]

    out = get_own_portfolio(db)

    assert len(out) == 1
    assert out[0].source == "future"


def test_future_query_failure_returns_empty_gracefully(monkeypatch) -> None:
    """Сбой future-запроса (нет таблицы до миграции 148) → [] для future, не crash."""
    monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [])
    db = MagicMock()
    db.execute.side_effect = RuntimeError("relation own_planned_project does not exist")

    out = get_own_portfolio(db)

    assert out == []


# ── SAVEPOINT regression (#2464 cluster A) ───────────────────────────────────────
#
# db shared с caller (get_own_portfolio в цепочке §25.3 / analyze). Раньше
# _query_current/_query_future ловили db.execute-сбой в bare ``except Exception``
# без SAVEPOINT/rollback — на реальном Postgres это отравляет db shared с caller:
# следующий db.execute на этой же сессии падает "current transaction is aborted".
# Фикс — db.execute обёрнут в ``with db.begin_nested():``. MagicMock не эмулирует
# реальный aborted-transaction Postgres — тесты проверяют (1) graceful fallback при
# сбое, (2) что begin_nested() реально вызван вокруг db.execute, (3) что db остаётся
# usable для следующего вызова на той же mock-сессии.


class TestQueryCurrentSavepoint:
    def test_uses_begin_nested_savepoint(self, monkeypatch) -> None:
        monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [12345])
        db = MagicMock()
        db.execute.return_value = _result([])
        _query_current(db)
        assert db.begin_nested.call_count == 1

    def test_query_failure_degrades_to_empty_list(self, monkeypatch) -> None:
        monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [12345])
        db = MagicMock()
        db.execute.side_effect = RuntimeError("simulated DB failure")
        assert _query_current(db) == []

    def test_session_usable_after_failure(self, monkeypatch) -> None:
        """db.execute сбоит один раз, но db остаётся usable для следующего вызова
        на этой же сессии (имитирует _query_current → _query_future в
        get_own_portfolio на одной shared Session)."""
        monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [12345])
        db = MagicMock()
        db.execute.side_effect = RuntimeError("boom")
        assert _query_current(db) == []

        db.execute.side_effect = None
        next_result = _result([{"marker": "ok"}])
        db.execute.return_value = next_result
        assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]


class TestQueryFutureSavepoint:
    def test_uses_begin_nested_savepoint(self) -> None:
        db = MagicMock()
        db.execute.return_value = _result([])
        _query_future(db)
        assert db.begin_nested.call_count == 1

    def test_query_failure_degrades_to_empty_list(self) -> None:
        db = MagicMock()
        db.execute.side_effect = RuntimeError("simulated DB failure")
        assert _query_future(db) == []

    def test_session_usable_after_failure(self) -> None:
        db = MagicMock()
        db.execute.side_effect = RuntimeError("boom")
        assert _query_future(db) == []

        db.execute.side_effect = None
        next_result = _result([{"marker": "ok"}])
        db.execute.return_value = next_result
        assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]


def test_get_own_portfolio_current_failure_savepoint_isolated(monkeypatch) -> None:
    """End-to-end (via get_own_portfolio): current db.execute сбоит → SAVEPOINT
    (не bare rollback) → future по-прежнему считается на той же сессии."""
    monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [12345])
    db = MagicMock()
    db.execute.side_effect = [RuntimeError("boom on domrf"), _result([_future_row()])]

    out = get_own_portfolio(db)

    assert db.begin_nested.call_count >= 1
    assert len(out) == 1
    assert out[0].source == "future"
