"""Тесты review-инструмента авто-маппингов (feat/mapping-review-page).

Mock DB (unittest.mock) — не требует реального PostgreSQL. Покрывает:
- list_mapping_review: обогащение (domrf comm/dev + objective devs) + эхо total;
- approve_mapping: dict обновлённой строки; None (404) на несуществующий id;
- reject_mapping: dict удалённой строки (DELETE) + логирование; None на 404;
- endpoints approve/reject через TestClient: 200 + 404.
"""

from __future__ import annotations

from datetime import UTC, datetime
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock

import pytest
from fastapi.testclient import TestClient

from app.core.db import get_db
from app.main import app
from app.services.etl.mapping_review import (
    approve_mapping,
    list_mapping_review,
    reject_mapping,
)

_NOW = datetime(2026, 7, 3, 12, 0, tzinfo=UTC)


def _row(**kw: Any) -> SimpleNamespace:
    """Row-заглушка с attribute-доступом (как SQLAlchemy Row)."""
    defaults: dict[str, Any] = {
        "id": 1,
        "objective_complex_name": "Малевич",
        "objective_project_id": 111,
        "objective_group": "Екатеринбург",
        "domrf_obj_id": 5001,
        "match_method": "auto_core_dev_v5",
        "match_score": 0.90,
        "is_reviewed": False,
        "note": "#2177 step2",
        "created_at": _NOW,
        "domrf_comm_name": 'ЖК "Малевич"',
        "domrf_dev_name": "Брусника",
        "objective_developers": ["Брусника", "СЗ Малевич"],
    }
    defaults.update(kw)
    return SimpleNamespace(**defaults)


def _mock_db_for_list(rows: list[SimpleNamespace], total: int) -> MagicMock:
    """MagicMock Session: первый execute (list) → .all(); второй (count) → scalar_one()."""
    list_res = MagicMock()
    list_res.all.return_value = rows
    count_res = MagicMock()
    count_res.scalar_one.return_value = total

    db = MagicMock()
    db.execute.side_effect = [list_res, count_res]
    return db


# ── list_mapping_review: обогащение + total ──────────────────────────────────


def test_list_enriches_and_counts() -> None:
    """rows обогащены (domrf comm/dev + objective devs), total эхо-ится."""
    rows = [
        _row(id=1, match_score=0.80, is_reviewed=False),
        _row(
            id=2,
            match_score=0.95,
            is_reviewed=False,
            domrf_obj_id=None,
            domrf_comm_name=None,
            domrf_dev_name=None,
            objective_developers=None,
        ),
    ]
    db = _mock_db_for_list(rows, total=14)

    out = list_mapping_review(db, only_unreviewed=True, limit=100, offset=0)

    assert out["total"] == 14
    assert out["limit"] == 100
    assert out["offset"] == 0
    assert out["only_unreviewed"] is True
    assert len(out["rows"]) == 2

    first = out["rows"][0]
    assert first["id"] == 1
    assert first["match_score"] == 0.80
    assert first["domrf_comm_name"] == 'ЖК "Малевич"'
    assert first["domrf_dev_name"] == "Брусника"
    assert first["objective_developers"] == ["Брусника", "СЗ Малевич"]
    assert first["created_at"] == _NOW.isoformat()

    # Строка без domrf-обогащения: None-поля + пустой список застройщиков.
    second = out["rows"][1]
    assert second["domrf_obj_id"] is None
    assert second["domrf_comm_name"] is None
    assert second["objective_developers"] == []


def test_list_passes_filter_and_paging_params() -> None:
    """only_unreviewed / limit / offset передаются в SQL-биндинги."""
    db = _mock_db_for_list([_row()], total=1)

    list_mapping_review(db, only_unreviewed=False, limit=25, offset=50)

    # Первый execute — list-SQL с биндингами страницы.
    list_call = db.execute.call_args_list[0]
    params = list_call.args[1]
    assert params == {"only_unreviewed": False, "limit": 25, "offset": 50}

    # Второй execute — count-SQL с тем же фильтром.
    count_call = db.execute.call_args_list[1]
    assert count_call.args[1] == {"only_unreviewed": False}


def test_list_score_none_serialized_as_null() -> None:
    """match_score=None (например legacy без скора) → None, не падает на float()."""
    db = _mock_db_for_list([_row(match_score=None)], total=1)
    out = list_mapping_review(db)
    assert out["rows"][0]["match_score"] is None


# ── approve_mapping ──────────────────────────────────────────────────────────


def test_approve_updates_and_returns_row() -> None:
    """approve → dict обновлённой строки, commit вызван."""
    updated = _row(is_reviewed=True, note="#2177 step2\napproved 2026-07-03")
    res = MagicMock()
    res.first.return_value = updated
    db = MagicMock()
    db.execute.return_value = res

    out = approve_mapping(db, mapping_id=1)

    assert out is not None
    assert out["id"] == 1
    assert out["is_reviewed"] is True
    assert "approved" in out["note"]
    db.commit.assert_called_once()
    db.rollback.assert_not_called()


def test_approve_missing_id_returns_none() -> None:
    """approve несуществующего id → None (404 в роутере), rollback, без commit."""
    res = MagicMock()
    res.first.return_value = None
    db = MagicMock()
    db.execute.return_value = res

    out = approve_mapping(db, mapping_id=999)

    assert out is None
    db.rollback.assert_called_once()
    db.commit.assert_not_called()


# ── reject_mapping (DELETE) ──────────────────────────────────────────────────


def test_reject_deletes_and_returns_row() -> None:
    """reject → dict удалённой строки (эхо), commit вызван (DELETE зафиксирован)."""
    deleted_row = _row(id=7, objective_complex_name="Отклонённый ЖК")
    res = MagicMock()
    res.first.return_value = deleted_row
    db = MagicMock()
    db.execute.return_value = res

    out = reject_mapping(db, mapping_id=7)

    assert out is not None
    assert out["id"] == 7
    assert out["objective_complex_name"] == "Отклонённый ЖК"
    assert out["match_method"] == "auto_core_dev_v5"
    db.commit.assert_called_once()
    db.rollback.assert_not_called()


def test_reject_missing_id_returns_none() -> None:
    """reject несуществующего id → None, rollback, без commit/DELETE-фиксации."""
    res = MagicMock()
    res.first.return_value = None
    db = MagicMock()
    db.execute.return_value = res

    out = reject_mapping(db, mapping_id=404)

    assert out is None
    db.rollback.assert_called_once()
    db.commit.assert_not_called()


# ── Endpoints (TestClient + get_db override) ─────────────────────────────────


@pytest.fixture()
def client() -> TestClient:
    return TestClient(app)


def _override_db(mock: MagicMock) -> None:
    def _get_db_override() -> Any:
        yield mock

    app.dependency_overrides[get_db] = _get_db_override


def test_endpoint_list_returns_payload(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
    """GET /mapping-review → 200 + payload из list_mapping_review."""
    _override_db(MagicMock())
    payload = {
        "rows": [{"id": 1, "match_score": 0.80}],
        "total": 14,
        "limit": 100,
        "offset": 0,
        "only_unreviewed": True,
    }
    monkeypatch.setattr(
        "app.api.v1.admin_etl.list_mapping_review",
        lambda db, only_unreviewed, limit, offset: payload,
    )
    r = client.get("/api/v1/admin/etl/mapping-review")
    assert r.status_code == 200
    assert r.json()["total"] == 14


def test_endpoint_approve_ok_and_404(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
    """POST approve → 200 при найденном id, 404 при None."""
    _override_db(MagicMock())

    monkeypatch.setattr(
        "app.api.v1.admin_etl.approve_mapping",
        lambda db, mapping_id: {"id": mapping_id, "is_reviewed": True},
    )
    ok = client.post("/api/v1/admin/etl/mapping-review/1/approve")
    assert ok.status_code == 200
    assert ok.json()["is_reviewed"] is True

    monkeypatch.setattr(
        "app.api.v1.admin_etl.approve_mapping",
        lambda db, mapping_id: None,
    )
    missing = client.post("/api/v1/admin/etl/mapping-review/999/approve")
    assert missing.status_code == 404


def test_endpoint_reject_ok_and_404(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
    """POST reject → 200 {status: deleted} при найденном, 404 при None."""
    _override_db(MagicMock())

    monkeypatch.setattr(
        "app.api.v1.admin_etl.reject_mapping",
        lambda db, mapping_id: {"id": mapping_id, "objective_complex_name": "X"},
    )
    ok = client.post("/api/v1/admin/etl/mapping-review/7/reject")
    assert ok.status_code == 200
    body = ok.json()
    assert body["status"] == "deleted"
    assert body["deleted"]["id"] == 7

    monkeypatch.setattr(
        "app.api.v1.admin_etl.reject_mapping",
        lambda db, mapping_id: None,
    )
    missing = client.post("/api/v1/admin/etl/mapping-review/404/reject")
    assert missing.status_code == 404
