"""Тесты для own_planned_project CRUD (#1169 PR1, ТЗ §25.3).

Покрывает (зеркало test_insights.py):
1.  POST /api/v1/own-projects → 201, created_by из X-Authenticated-User (не из тела)
2.  POST без name → 422; bad obj_class → 422; price_min>max → 422; unit_mix вне [0,1] → 422
3.  POST без X-Authenticated-User → 401
4.  GET  /api/v1/own-projects → OwnPlannedProjectList (total/limit/offset/rows)
5.  GET  /api/v1/own-projects?<filters> → district/obj_class/created_by в сервис
6.  GET  /api/v1/own-projects/{id} → 200; 404 если нет
7.  PUT  /api/v1/own-projects/{id} → 200 updated; 404 если нет; 401 без заголовка
8.  DELETE /api/v1/own-projects/{id} → 204; 404 если нет; 401 без заголовка
9.  Service-level: db.commit() в create/update/delete (#261 regression guard)

Стратегия mock: сервисные функции патчим через unittest.mock.patch, DB — MagicMock
(как test_insights.py). settings.testing=True (conftest) отключает rbac_guard.
"""

from __future__ import annotations

from datetime import UTC, date, datetime
from typing import Any
from unittest.mock import MagicMock, patch

from fastapi.testclient import TestClient

from app.main import app
from app.schemas.own_project import (
    OwnPlannedProjectCreate,
    OwnPlannedProjectList,
    OwnPlannedProjectOut,
    OwnPlannedProjectUpdate,
)

# ── Константы ─────────────────────────────────────────────────────────────────

_USER = "analysttest"
_TS = datetime(2026, 6, 7, 10, 0, 0, tzinfo=UTC)
_AUTH = {"X-Authenticated-User": _USER}


# ── Helpers ────────────────────────────────────────────────────────────────────


def _make_out(
    project_id: int = 1,
    created_by: str = _USER,
    name: str = "ЖК Наш Будущий",
    district: str | None = "Октябрьский",
    obj_class: str | None = "Комфорт",
    planned_release_month: date | None = date(2027, 3, 1),
    price_min_per_m2: float | None = 140000.0,
    price_max_per_m2: float | None = 180000.0,
    unit_mix: dict[str, float] | None = None,
    lon: float | None = None,
    lat: float | None = None,
) -> OwnPlannedProjectOut:
    return OwnPlannedProjectOut(
        id=project_id,
        created_by=created_by,
        name=name,
        district=district,
        obj_class=obj_class,
        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,
        created_at=_TS,
        updated_at=_TS,
    )


# ── Tests: CREATE ────────────────────────────────────────────────────────────────


def test_create_own_project_returns_201_and_sets_created_by() -> None:
    """POST → 201; created_by берётся из X-Authenticated-User, не из тела."""
    expected = _make_out()
    with patch(
        "app.api.v1.own_projects.create_own_project", return_value=expected
    ) as mock_create:
        client = TestClient(app)
        resp = client.post(
            "/api/v1/own-projects",
            json={
                "name": "ЖК Наш Будущий",
                "district": "Октябрьский",
                "obj_class": "Комфорт",
                "planned_release_month": "2027-03-15",
                "price_min_per_m2": 140000,
                "price_max_per_m2": 180000,
            },
            headers=_AUTH,
        )
    assert resp.status_code == 201, resp.text
    body = resp.json()
    assert body["name"] == "ЖК Наш Будущий"
    assert body["created_by"] == _USER
    mock_create.assert_called_once()
    # created_by пробрасывается из заголовка во 2-й позиционный аргумент сервиса.
    assert mock_create.call_args[0][1] == _USER


def test_create_own_project_with_unit_mix() -> None:
    """unit_mix в теле → пробрасывается в payload сервиса."""
    mix = {"studio": 0.3, "1k": 0.4, "2k": 0.2, "3k": 0.1}
    expected = _make_out(unit_mix=mix)
    with patch(
        "app.api.v1.own_projects.create_own_project", return_value=expected
    ) as mock_create:
        client = TestClient(app)
        resp = client.post(
            "/api/v1/own-projects",
            json={"name": "ЖК Микс", "unit_mix": mix},
            headers=_AUTH,
        )
    assert resp.status_code == 201, resp.text
    payload: OwnPlannedProjectCreate = mock_create.call_args[0][2]
    assert payload.unit_mix == mix


def test_create_own_project_missing_name_returns_422() -> None:
    """name обязателен → 422."""
    client = TestClient(app)
    resp = client.post("/api/v1/own-projects", json={"district": "X"}, headers=_AUTH)
    assert resp.status_code == 422, resp.text


def test_create_own_project_bad_class_returns_422() -> None:
    """obj_class вне enum → 422 (Literal)."""
    client = TestClient(app)
    resp = client.post(
        "/api/v1/own-projects",
        json={"name": "X", "obj_class": "Люкс-Делюкс"},
        headers=_AUTH,
    )
    assert resp.status_code == 422, resp.text


def test_create_own_project_price_min_above_max_returns_422() -> None:
    """price_min_per_m2 > price_max_per_m2 → 422 (model_validator)."""
    client = TestClient(app)
    resp = client.post(
        "/api/v1/own-projects",
        json={"name": "X", "price_min_per_m2": 200000, "price_max_per_m2": 100000},
        headers=_AUTH,
    )
    assert resp.status_code == 422, resp.text


def test_create_own_project_negative_price_returns_422() -> None:
    """Отрицательная цена → 422 (ge=0)."""
    client = TestClient(app)
    resp = client.post(
        "/api/v1/own-projects",
        json={"name": "X", "price_min_per_m2": -5},
        headers=_AUTH,
    )
    assert resp.status_code == 422, resp.text


def test_create_own_project_unit_mix_out_of_range_returns_422() -> None:
    """Доля unit_mix вне [0,1] → 422 (field_validator)."""
    client = TestClient(app)
    resp = client.post(
        "/api/v1/own-projects",
        json={"name": "X", "unit_mix": {"studio": 1.5}},
        headers=_AUTH,
    )
    assert resp.status_code == 422, resp.text


def test_create_own_project_without_auth_header_returns_401() -> None:
    """Нет X-Authenticated-User → 401 (роутерный _require_user; rbac выключен в тестах)."""
    client = TestClient(app)
    resp = client.post("/api/v1/own-projects", json={"name": "X"})
    assert resp.status_code == 401, resp.text


# ── Tests: LIST ──────────────────────────────────────────────────────────────────


def test_list_own_projects_returns_envelope() -> None:
    """GET → OwnPlannedProjectList {total, limit, offset, rows}."""
    listing = OwnPlannedProjectList(
        total=2, limit=50, offset=0, rows=[_make_out(1), _make_out(2)]
    )
    with patch("app.api.v1.own_projects.list_own_projects", return_value=listing):
        client = TestClient(app)
        resp = client.get("/api/v1/own-projects", headers=_AUTH)
    assert resp.status_code == 200, resp.text
    body = resp.json()
    assert body["total"] == 2
    assert len(body["rows"]) == 2
    assert body["rows"][0]["id"] == 1


def test_list_own_projects_passes_filters_to_service() -> None:
    """Фильтры district/obj_class/created_by → в сервис как kwargs."""
    listing = OwnPlannedProjectList(total=0, limit=50, offset=0, rows=[])
    with patch(
        "app.api.v1.own_projects.list_own_projects", return_value=listing
    ) as mock_list:
        client = TestClient(app)
        resp = client.get(
            "/api/v1/own-projects",
            params={"district": "Октябрьский", "obj_class": "Бизнес", "created_by": _USER},
            headers=_AUTH,
        )
    assert resp.status_code == 200, resp.text
    kwargs = mock_list.call_args.kwargs
    assert kwargs["district"] == "Октябрьский"
    assert kwargs["obj_class"] == "Бизнес"
    assert kwargs["created_by"] == _USER


# ── Tests: GET one ───────────────────────────────────────────────────────────────


def test_get_own_project_returns_200() -> None:
    with patch("app.api.v1.own_projects.get_own_project", return_value=_make_out(7)):
        client = TestClient(app)
        resp = client.get("/api/v1/own-projects/7", headers=_AUTH)
    assert resp.status_code == 200, resp.text
    assert resp.json()["id"] == 7


def test_get_own_project_not_found_returns_404() -> None:
    with patch("app.api.v1.own_projects.get_own_project", return_value=None):
        client = TestClient(app)
        resp = client.get("/api/v1/own-projects/999", headers=_AUTH)
    assert resp.status_code == 404, resp.text


# ── Tests: UPDATE ────────────────────────────────────────────────────────────────


def test_put_own_project_returns_updated() -> None:
    updated = _make_out(name="Переименовано")
    with patch("app.api.v1.own_projects.update_own_project", return_value=updated):
        client = TestClient(app)
        resp = client.put(
            "/api/v1/own-projects/1", json={"name": "Переименовано"}, headers=_AUTH
        )
    assert resp.status_code == 200, resp.text
    assert resp.json()["name"] == "Переименовано"


def test_put_own_project_not_found_returns_404() -> None:
    with patch("app.api.v1.own_projects.update_own_project", return_value=None):
        client = TestClient(app)
        resp = client.put("/api/v1/own-projects/999", json={"name": "x"}, headers=_AUTH)
    assert resp.status_code == 404, resp.text


def test_put_own_project_without_auth_returns_401() -> None:
    client = TestClient(app)
    resp = client.put("/api/v1/own-projects/1", json={"name": "x"})
    assert resp.status_code == 401, resp.text


# ── Tests: DELETE ────────────────────────────────────────────────────────────────


def test_delete_own_project_returns_204() -> None:
    with patch("app.api.v1.own_projects.delete_own_project", return_value=True):
        client = TestClient(app)
        resp = client.delete("/api/v1/own-projects/1", headers=_AUTH)
    assert resp.status_code == 204, resp.text


def test_delete_own_project_not_found_returns_404() -> None:
    with patch("app.api.v1.own_projects.delete_own_project", return_value=False):
        client = TestClient(app)
        resp = client.delete("/api/v1/own-projects/999", headers=_AUTH)
    assert resp.status_code == 404, resp.text


def test_delete_own_project_without_auth_returns_401() -> None:
    client = TestClient(app)
    resp = client.delete("/api/v1/own-projects/1")
    assert resp.status_code == 401, resp.text


# ── Service-level: commit + mapping (#261 regression guard) ─────────────────────


def _make_row_data(
    project_id: int = 1,
    created_by: str = _USER,
    name: str = "ЖК Наш",
    district: str | None = "Октябрьский",
    obj_class: str | None = "Комфорт",
    planned_release_month: date | None = date(2027, 3, 1),
    price_min_per_m2: Any = 140000,
    price_max_per_m2: Any = 180000,
    unit_mix: dict[str, float] | None = None,
    lon: float | None = None,
    lat: float | None = None,
) -> dict[str, Any]:
    return {
        "id": project_id,
        "created_by": created_by,
        "name": name,
        "district": district,
        "obj_class": obj_class,
        "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,
        "created_at": _TS,
        "updated_at": _TS,
    }


def _make_db_with_row(row_data: dict[str, Any] | None) -> MagicMock:
    """MagicMock-сессия: execute().mappings().first() → row_data mapping."""
    db = MagicMock()
    row_mock: MagicMock | None = None
    if row_data is not None:
        row_mock = MagicMock()
        row_mock.__getitem__ = lambda self, k: row_data[k]
        row_mock.get = lambda k, default=None: row_data.get(k, default)

    mapping_mock = MagicMock()
    mapping_mock.first.return_value = row_mock

    exec_mock = MagicMock()
    exec_mock.mappings.return_value = mapping_mock
    exec_mock.first.return_value = row_mock  # DELETE RETURNING id (не mappings)

    db.execute.return_value = exec_mock
    return db


def test_service_create_own_project_commits_and_sets_created_by() -> None:
    """create_own_project вызывает db.commit() и мапит created_by — regression #261."""
    from app.services.own_projects import create_own_project

    db = _make_db_with_row(_make_row_data())
    payload = OwnPlannedProjectCreate(name="ЖК Наш", obj_class="Комфорт")

    result = create_own_project(db, _USER, payload)

    db.commit.assert_called_once()
    assert result.id == 1
    assert result.created_by == _USER
    assert result.obj_class == "Комфорт"


def test_service_create_own_project_serialises_unit_mix() -> None:
    """unit_mix сериализуется в JSON-строку для CAST(:unit_mix AS jsonb)."""
    from app.services.own_projects import create_own_project

    mix = {"studio": 0.5, "1k": 0.5}
    db = _make_db_with_row(_make_row_data(unit_mix=mix))
    payload = OwnPlannedProjectCreate(name="ЖК Микс", unit_mix=mix)

    create_own_project(db, _USER, payload)

    # unit_mix-параметр INSERT — JSON-строка, не dict (psycopg CAST AS jsonb).
    insert_params = db.execute.call_args_list[0].args[1]
    assert insert_params["unit_mix"] == '{"studio": 0.5, "1k": 0.5}'


def test_service_create_own_project_uses_cast_not_double_colon() -> None:
    """INSERT использует CAST(:x AS type), никогда :x::type (psycopg v3)."""
    from app.services.own_projects import create_own_project

    db = _make_db_with_row(_make_row_data())
    create_own_project(db, _USER, OwnPlannedProjectCreate(name="X"))

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


def test_service_update_own_project_commits_when_fields_given() -> None:
    """update_own_project вызывает db.commit() при наличии изменяемых полей — #261."""
    from app.services.own_projects import update_own_project

    db = _make_db_with_row(_make_row_data(name="new"))
    result = update_own_project(db, project_id=1, payload=OwnPlannedProjectUpdate(name="new"))

    db.commit.assert_called_once()
    assert result is not None


def test_service_update_own_project_no_commit_when_payload_empty() -> None:
    """Пустой payload → только updated_at → UPDATE не выполняется, commit не зовётся."""
    from app.services.own_projects import update_own_project

    db = _make_db_with_row(_make_row_data())
    update_own_project(db, project_id=1, payload=OwnPlannedProjectUpdate())

    db.commit.assert_not_called()


def test_service_update_own_project_no_commit_when_not_found() -> None:
    """update_own_project НЕ коммитит если проект не найден."""
    from app.services.own_projects import update_own_project

    db = _make_db_with_row(None)
    result = update_own_project(db, project_id=999, payload=OwnPlannedProjectUpdate(name="x"))

    db.commit.assert_not_called()
    assert result is None


def test_service_delete_own_project_commits_when_found() -> None:
    """delete_own_project коммитит когда строка найдена — regression #261."""
    from app.services.own_projects import delete_own_project

    db = _make_db_with_row(_make_row_data())
    db.execute.return_value.first.return_value = MagicMock()  # RETURNING id truthy

    assert delete_own_project(db, project_id=1) is True
    db.commit.assert_called_once()


def test_service_delete_own_project_no_commit_when_not_found() -> None:
    """delete_own_project НЕ коммитит если строки нет."""
    from app.services.own_projects import delete_own_project

    db = _make_db_with_row(None)
    db.execute.return_value.first.return_value = None  # RETURNING id пусто

    assert delete_own_project(db, project_id=999) is False
    db.commit.assert_not_called()


def test_service_list_own_projects_builds_filtered_query() -> None:
    """list_own_projects собирает WHERE по фильтрам + возвращает list с total."""
    from app.services.own_projects import list_own_projects

    db = MagicMock()
    row = _make_row_data(1)
    row_mock = MagicMock()
    row_mock.__getitem__ = lambda self, k: row[k]

    rows_result = MagicMock()
    rows_result.mappings.return_value.all.return_value = [row_mock]
    count_result = MagicMock()
    count_result.scalar_one.return_value = 1
    db.execute.side_effect = [rows_result, count_result]

    out = list_own_projects(db, district="Октябрьский", obj_class="Комфорт", limit=10, offset=0)

    assert isinstance(out, OwnPlannedProjectList)
    assert out.total == 1
    assert out.limit == 10
    assert len(out.rows) == 1

    select_sql = str(db.execute.call_args_list[0].args[0])
    assert "WHERE" in select_sql
    assert "district = :district" in select_sql
    assert "obj_class = :obj_class" in select_sql
    assert "::" not in select_sql
    select_params = db.execute.call_args_list[0].args[1]
    assert select_params["district"] == "Октябрьский"
    assert select_params["obj_class"] == "Комфорт"
