"""Unit-тесты для app.services.job_settings.update (UPSERT поведение).

Покрывают issue #1223: PUT /admin/jobs/settings/{job_type} для несуществующей
строки молча терял изменения. После фикса update() выполняет INSERT, если
UPDATE затронул 0 строк.

DB здесь полностью замокана — мы проверяем, что service:
  1. Делает UPDATE сначала и фиксирует rowcount.
  2. При rowcount == 0 делает INSERT с переданными колонками.
  3. При rowcount > 0 НЕ делает INSERT (PATCH-семантика сохранена).
  4. Защищён от гонки UPDATE→INSERT через ON CONFLICT DO NOTHING.
"""

from __future__ import annotations

from typing import Any
from unittest.mock import MagicMock

import pytest

from app.services import job_settings as svc


class _ExecRecorder:
    """Имитация db.execute() с задаваемым rowcount и записью всех вызовов."""

    def __init__(self, plan: list[int]) -> None:
        # plan: список rowcount-ов для последовательных execute() вызовов.
        # get_one() в конце update() тоже бьёт execute() — этот вызов покрывается
        # последним элементом или дефолтным MagicMock (см. __call__).
        self._plan = list(plan)
        self.calls: list[tuple[str, dict[str, Any]]] = []

    def __call__(self, stmt: Any, params: Any = None) -> Any:
        sql = str(stmt)
        self.calls.append((sql, dict(params) if isinstance(params, dict) else {}))
        result = MagicMock()
        # rowcount только для DML; для SELECT/get_one — не важен.
        result.rowcount = self._plan.pop(0) if self._plan else 0
        # get_one() ожидает .mappings().first() → None (→ fallback).
        result.mappings.return_value.first.return_value = None
        return result


@pytest.fixture
def _patched_get_one(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
    """get_one() имеет свою БД-логику; патчим, чтобы не мешала assert-ам на execute()."""
    mock = MagicMock(return_value={"job_type": "x", "stub": True})
    monkeypatch.setattr(svc, "get_one", mock)
    return mock


def _count_statements(calls: list[tuple[str, dict[str, Any]]], keyword: str) -> int:
    return sum(1 for sql, _ in calls if keyword in sql)


def test_update_existing_row_only_updates(_patched_get_one: MagicMock) -> None:
    """Когда UPDATE затронул 1 строку — INSERT НЕ выполняется (regression guard)."""
    db = MagicMock()
    exec_rec = _ExecRecorder(plan=[1])  # UPDATE → 1 row affected
    db.execute = exec_rec

    svc.update("scrape_kn", db, rate_ms=500, updated_by="admin-ui")

    assert _count_statements(exec_rec.calls, "UPDATE job_settings") == 1
    assert _count_statements(exec_rec.calls, "INSERT INTO job_settings") == 0
    db.commit.assert_called_once()


def test_update_missing_row_inserts_with_provided_fields(
    _patched_get_one: MagicMock,
) -> None:
    """Issue #1223: PUT для несуществующей строки должен сохранять изменения.

    UPDATE возвращает rowcount=0, после чего service делает INSERT с теми же
    колонками, что были в SET-списке.
    """
    db = MagicMock()
    exec_rec = _ExecRecorder(plan=[0, 1])  # UPDATE 0, INSERT 1
    db.execute = exec_rec

    svc.update(
        "new_job_type",
        db,
        enabled=False,
        queue_name="celery",
        cron_schedule="*/5 * * * *",
        rate_ms=1000,
        max_retries=3,
        max_concurrency=2,
        extra_config={"key": "value"},
        updated_by="admin-ui",
    )

    # Должен быть ровно один UPDATE и ровно один INSERT.
    assert _count_statements(exec_rec.calls, "UPDATE job_settings") == 1
    assert _count_statements(exec_rec.calls, "INSERT INTO job_settings") == 1

    insert_sql, insert_params = next(
        (sql, params) for sql, params in exec_rec.calls if "INSERT" in sql
    )
    assert "ON CONFLICT (job_type) DO NOTHING" in insert_sql
    assert insert_params["jt"] == "new_job_type"
    assert insert_params["enabled"] is False
    assert insert_params["queue_name"] == "celery"
    assert insert_params["cron_schedule"] == "*/5 * * * *"
    assert insert_params["rate_ms"] == 1000
    assert insert_params["max_retries"] == 3
    assert insert_params["max_concurrency"] == 2
    assert insert_params["updated_by"] == "admin-ui"
    # extra_config сериализуется в JSON-строку — приводится через CAST.
    assert "CAST(:extra_config AS jsonb)" in insert_sql
    assert '"key": "value"' in insert_params["extra_config"]

    db.commit.assert_called_once()


def test_update_missing_row_minimal_payload(_patched_get_one: MagicMock) -> None:
    """INSERT для пустого payload (только updated_by из роута) — лишь job_type/updated_by.

    Остальные колонки должны получить таблицы-дефолты — мы их не упоминаем в
    INSERT, проверяем что они НЕ попали в params.
    """
    db = MagicMock()
    exec_rec = _ExecRecorder(plan=[0, 1])
    db.execute = exec_rec

    svc.update("brand_new", db, updated_by="admin-ui")

    insert_sql, insert_params = next(
        (sql, params) for sql, params in exec_rec.calls if "INSERT" in sql
    )
    assert insert_params == {"jt": "brand_new", "updated_by": "admin-ui"}
    # В INSERT не должно быть колонок, которые мы не передали.
    assert "enabled" not in insert_sql
    assert "queue_name" not in insert_sql
    assert "extra_config" not in insert_sql


def test_update_insert_race_falls_back_to_second_update(
    _patched_get_one: MagicMock,
) -> None:
    """Гонка UPDATE→INSERT: параллельный PUT вставил строку между нашими запросами.

    INSERT ... ON CONFLICT DO NOTHING вернёт rowcount=0; service должен повторить
    UPDATE, чтобы наши изменения не потерялись.
    """
    db = MagicMock()
    # UPDATE→0, INSERT→0 (конфликт), second UPDATE→1.
    exec_rec = _ExecRecorder(plan=[0, 0, 1])
    db.execute = exec_rec

    svc.update("racing_jt", db, enabled=True, updated_by="admin-ui")

    assert _count_statements(exec_rec.calls, "UPDATE job_settings") == 2
    assert _count_statements(exec_rec.calls, "INSERT INTO job_settings") == 1
    db.commit.assert_called_once()


def test_update_cron_empty_string_becomes_null(_patched_get_one: MagicMock) -> None:
    """cron_schedule='' → NULL и при UPDATE, и при INSERT (manual-only режим)."""
    db = MagicMock()
    # UPDATE→0 → попадаем в INSERT-ветку.
    exec_rec = _ExecRecorder(plan=[0, 1])
    db.execute = exec_rec

    svc.update("nspd_geo", db, cron_schedule="", updated_by="admin-ui")

    update_params = exec_rec.calls[0][1]
    insert_params = exec_rec.calls[1][1]
    assert update_params["cron_schedule"] is None
    assert insert_params["cron_schedule"] is None


def test_update_rate_ms_zero_becomes_null(_patched_get_one: MagicMock) -> None:
    """rate_ms=0 → NULL (no throttle)."""
    db = MagicMock()
    exec_rec = _ExecRecorder(plan=[0, 1])
    db.execute = exec_rec

    svc.update("nspd_geo", db, rate_ms=0, updated_by="admin-ui")

    update_params = exec_rec.calls[0][1]
    insert_params = exec_rec.calls[1][1]
    assert update_params["rate_ms"] is None
    assert insert_params["rate_ms"] is None
