"""Тесты для insight CRUD (#948 Part A, ТЗ §7.13/§8.9).

Покрывает (зеркало test_custom_pois.py):
1.  POST /api/v1/insights → 201, created_by из X-Authenticated-User
2.  POST без title/body → 422 (Pydantic required-field validation)
3.  POST без X-Authenticated-User → 401
4.  GET  /api/v1/insights → InsightList (total/limit/offset/rows)
5.  GET  /api/v1/insights?<filters> → фильтры (district/cad_num/category/is_confidential)
       пробрасываются в сервис
6.  GET  /api/v1/insights/{id} → 200; 404 если нет
7.  PUT  /api/v1/insights/{id} → 200 updated; 404 если нет
8.  DELETE /api/v1/insights/{id} → 204; 404 если нет
9.  is_confidential хранится и фильтруется
10. Service-level: db.commit() в create/update/delete (#261 regression guard)

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

from __future__ import annotations

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

from fastapi.testclient import TestClient

from app.main import app
from app.schemas.insight import InsightCreate, InsightList, InsightOut, InsightUpdate

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

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


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


def _make_out(
    insight_id: int = 1,
    created_by: str = _USER,
    title: str = "Конкурент строит ЖК рядом",
    body: str = "Замечен котлован, забор ГК «Х».",
    category: str | None = "competition",
    is_confidential: bool = False,
    district: str | None = "Октябрьский",
    cad_num: str | None = None,
    lon: float | None = None,
    lat: float | None = None,
) -> InsightOut:
    return InsightOut(
        id=insight_id,
        created_by=created_by,
        title=title,
        body=body,
        category=category,
        is_confidential=is_confidential,
        district=district,
        cad_num=cad_num,
        lon=lon,
        lat=lat,
        source=None,
        created_at=_TS,
        updated_at=_TS,
    )


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


def test_create_insight_returns_201_and_sets_created_by() -> None:
    """POST /insights → 201; created_by берётся из X-Authenticated-User, не из тела."""
    expected = _make_out()
    with patch("app.api.v1.insights.create_insight", return_value=expected) as mock_create:
        client = TestClient(app)
        resp = client.post(
            "/api/v1/insights",
            json={
                "title": "Конкурент строит ЖК рядом",
                "body": "Замечен котлован, забор ГК «Х».",
                "category": "competition",
                "district": "Октябрьский",
            },
            headers=_AUTH,
        )
    assert resp.status_code == 201, resp.text
    body = resp.json()
    assert body["title"] == "Конкурент строит ЖК рядом"
    assert body["created_by"] == _USER
    assert body["is_confidential"] is False
    mock_create.assert_called_once()
    # created_by пробрасывается из заголовка во 2-й позиционный аргумент сервиса.
    call_args = mock_create.call_args
    assert call_args[0][1] == _USER


def test_create_confidential_insight_stores_flag() -> None:
    """is_confidential=True в теле → пробрасывается в payload сервиса."""
    expected = _make_out(is_confidential=True)
    with patch("app.api.v1.insights.create_insight", return_value=expected) as mock_create:
        client = TestClient(app)
        resp = client.post(
            "/api/v1/insights",
            json={"title": "Инсайдер", "body": "Закрытая инфа", "is_confidential": True},
            headers=_AUTH,
        )
    assert resp.status_code == 201, resp.text
    assert resp.json()["is_confidential"] is True
    payload: InsightCreate = mock_create.call_args[0][2]
    assert payload.is_confidential is True


def test_create_insight_missing_title_returns_422() -> None:
    """title обязателен → 422."""
    client = TestClient(app)
    resp = client.post(
        "/api/v1/insights",
        json={"body": "только тело без заголовка"},
        headers=_AUTH,
    )
    assert resp.status_code == 422, resp.text


def test_create_insight_missing_body_returns_422() -> None:
    """body обязателен → 422."""
    client = TestClient(app)
    resp = client.post(
        "/api/v1/insights",
        json={"title": "только заголовок"},
        headers=_AUTH,
    )
    assert resp.status_code == 422, resp.text


def test_create_insight_empty_title_returns_422() -> None:
    """Пустой title (min_length=1) → 422."""
    client = TestClient(app)
    resp = client.post(
        "/api/v1/insights",
        json={"title": "", "body": "непустое тело"},
        headers=_AUTH,
    )
    assert resp.status_code == 422, resp.text


def test_create_insight_bad_category_returns_422() -> None:
    """category вне enum → 422 (Literal)."""
    client = TestClient(app)
    resp = client.post(
        "/api/v1/insights",
        json={"title": "t", "body": "b", "category": "totally-unknown"},
        headers=_AUTH,
    )
    assert resp.status_code == 422, resp.text


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


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


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


def test_list_insights_passes_filters_to_service() -> None:
    """Фильтры district/cad_num/category/is_confidential → в сервис как kwargs."""
    listing = InsightList(total=0, limit=50, offset=0, rows=[])
    with patch("app.api.v1.insights.list_insights", return_value=listing) as mock_list:
        client = TestClient(app)
        resp = client.get(
            "/api/v1/insights",
            params={
                "district": "Октябрьский",
                "cad_num": _CAD,
                "category": "risk",
                "is_confidential": "true",
            },
            headers=_AUTH,
        )
    assert resp.status_code == 200, resp.text
    kwargs = mock_list.call_args.kwargs
    assert kwargs["district"] == "Октябрьский"
    assert kwargs["cad_num"] == _CAD
    assert kwargs["category"] == "risk"
    assert kwargs["is_confidential"] is True


def test_list_insights_filter_confidential_false() -> None:
    """is_confidential=false тоже пробрасывается (не путается с «не задано»)."""
    listing = InsightList(total=0, limit=50, offset=0, rows=[])
    with patch("app.api.v1.insights.list_insights", return_value=listing) as mock_list:
        client = TestClient(app)
        resp = client.get(
            "/api/v1/insights", params={"is_confidential": "false"}, headers=_AUTH
        )
    assert resp.status_code == 200, resp.text
    assert mock_list.call_args.kwargs["is_confidential"] is False


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


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


def test_get_insight_not_found_returns_404() -> None:
    with patch("app.api.v1.insights.get_insight", return_value=None):
        client = TestClient(app)
        resp = client.get("/api/v1/insights/999", headers=_AUTH)
    assert resp.status_code == 404, resp.text


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


def test_put_insight_returns_updated() -> None:
    updated = _make_out(title="Обновлено", is_confidential=True)
    with patch("app.api.v1.insights.update_insight", return_value=updated):
        client = TestClient(app)
        resp = client.put(
            "/api/v1/insights/1",
            json={"title": "Обновлено", "is_confidential": True},
            headers=_AUTH,
        )
    assert resp.status_code == 200, resp.text
    body = resp.json()
    assert body["title"] == "Обновлено"
    assert body["is_confidential"] is True


def test_put_insight_not_found_returns_404() -> None:
    with patch("app.api.v1.insights.update_insight", return_value=None):
        client = TestClient(app)
        resp = client.put(
            "/api/v1/insights/999", json={"title": "x"}, headers=_AUTH
        )
    assert resp.status_code == 404, resp.text


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


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


def test_delete_insight_returns_204() -> None:
    with patch("app.api.v1.insights.delete_insight", return_value=True):
        client = TestClient(app)
        resp = client.delete("/api/v1/insights/1", headers=_AUTH)
    assert resp.status_code == 204, resp.text


def test_delete_insight_not_found_returns_404() -> None:
    with patch("app.api.v1.insights.delete_insight", return_value=False):
        client = TestClient(app)
        resp = client.delete("/api/v1/insights/999", headers=_AUTH)
    assert resp.status_code == 404, resp.text


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


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


def _make_row_data(
    insight_id: int = 1,
    created_by: str = _USER,
    title: str = "T",
    body: str = "B",
    category: str | None = "competition",
    is_confidential: bool = False,
    district: str | None = "Октябрьский",
    cad_num: str | None = None,
    lon: float | None = None,
    lat: float | None = None,
) -> dict[str, Any]:
    return {
        "id": insight_id,
        "created_by": created_by,
        "title": title,
        "body": body,
        "category": category,
        "is_confidential": is_confidential,
        "district": district,
        "cad_num": cad_num,
        "lon": lon,
        "lat": lat,
        "source": None,
        "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_insight_commits_and_sets_created_by() -> None:
    """create_insight вызывает db.commit() и мапит created_by — regression #261."""
    from app.services.insights import create_insight

    db = _make_db_with_row(_make_row_data(is_confidential=True))
    payload = InsightCreate(title="T", body="B", is_confidential=True)

    result = create_insight(db, _USER, payload)

    db.commit.assert_called_once()
    assert result.id == 1
    assert result.created_by == _USER
    assert result.is_confidential is True


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

    db = _make_db_with_row(_make_row_data(title="new"))
    payload = InsightUpdate(title="new")

    result = update_insight(db, insight_id=1, payload=payload)

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


def test_service_update_insight_confidential_false_triggers_update() -> None:
    """is_confidential=False — это изменение (False is not None) → commit."""
    from app.services.insights import update_insight

    db = _make_db_with_row(_make_row_data(is_confidential=False))
    payload = InsightUpdate(is_confidential=False)

    update_insight(db, insight_id=1, payload=payload)

    db.commit.assert_called_once()


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

    db = _make_db_with_row(_make_row_data())
    update_insight(db, insight_id=1, payload=InsightUpdate())

    db.commit.assert_not_called()


def test_service_update_insight_no_commit_when_not_found() -> None:
    """update_insight НЕ коммитит если insight не найден."""
    from app.services.insights import update_insight

    db = _make_db_with_row(None)
    result = update_insight(db, insight_id=999, payload=InsightUpdate(title="x"))

    db.commit.assert_not_called()
    assert result is None


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

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

    assert delete_insight(db, insight_id=1) is True
    db.commit.assert_called_once()


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

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

    assert delete_insight(db, insight_id=999) is False
    db.commit.assert_not_called()


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

    db = MagicMock()
    row = _make_row_data(1, is_confidential=True)
    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_insights(db, district="Октябрьский", is_confidential=True, limit=10, offset=0)

    assert isinstance(out, InsightList)
    assert out.total == 1
    assert out.limit == 10
    assert len(out.rows) == 1
    assert out.rows[0].is_confidential is True

    # Первый execute — SELECT с WHERE по district + is_confidential, psycopg v3 CAST.
    select_sql = str(db.execute.call_args_list[0].args[0])
    assert "WHERE" in select_sql
    assert "district = :district" in select_sql
    assert "is_confidential = CAST(:is_confidential AS boolean)" 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["is_confidential"] is True
