"""Tests for cian_session — cookie management service."""

from __future__ import annotations

import json
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from app.services.cian_session import (
    CIAN_REQUIRED_COOKIES,
    VERIFY_BAN_SENTINEL,
    VERIFY_MARKUP_CHANGED_SENTINEL,
    VERIFY_SOURCE_UNAVAILABLE_SENTINEL,
    _classify_verify_response,
    load_session,
    mark_session_invalid,
    save_session,
    verify_session,
)

# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture()
def mock_db() -> MagicMock:
    db = MagicMock()
    # Default: no row found (load_session → None)
    db.execute.return_value.mappings.return_value.first.return_value = None
    return db


# ---------------------------------------------------------------------------
# CIAN_REQUIRED_COOKIES
# ---------------------------------------------------------------------------


def test_required_cookies_set_not_empty() -> None:
    assert isinstance(CIAN_REQUIRED_COOKIES, set)
    assert len(CIAN_REQUIRED_COOKIES) >= 5


def test_required_cookies_contains_key_names() -> None:
    assert "_cian_visitor_session_id" in CIAN_REQUIRED_COOKIES
    assert "_ym_uid" in CIAN_REQUIRED_COOKIES
    assert "_cian_uid" in CIAN_REQUIRED_COOKIES
    assert "tlsr_id" in CIAN_REQUIRED_COOKIES
    assert "cf_clearance" in CIAN_REQUIRED_COOKIES


def test_required_cookies_includes_real_auth() -> None:
    """Real Cian DevTools probe (2026-05-23) — these cookies must pass filter."""
    real_cookies_from_browser = {
        "DMIR_AUTH",
        "_CIAN_GK",
        "_yasc",
        "_ym_uid",
        "_ym_d",
        "_ym_isad",
        "_ym_visorc",
        "uxfb_card_satisfaction",
    }
    missing = real_cookies_from_browser - CIAN_REQUIRED_COOKIES
    assert not missing, f"Filter missing real cookies: {missing}"


# ---------------------------------------------------------------------------
# save_session
# ---------------------------------------------------------------------------


def test_save_session_calls_pgp_sym_encrypt(mock_db: MagicMock) -> None:
    save_session(mock_db, account_user_id=12345, cookies={"_ym_uid": "abc"})
    args, _ = mock_db.execute.call_args
    sql_text = str(args[0])
    assert "pgp_sym_encrypt" in sql_text
    assert "cian_session_cookies" in sql_text


def test_save_session_uses_cast_not_colon_colon(mock_db: MagicMock) -> None:
    """Verify psycopg v3 compatibility — no ::bigint syntax in SQL."""
    save_session(mock_db, account_user_id=99, cookies={"csrftoken": "x"})
    args, _ = mock_db.execute.call_args
    sql_text = str(args[0])
    # CAST(:x AS type) is required; ::type would fail with psycopg v3 named params
    assert "CAST(" in sql_text
    assert "::bigint" not in sql_text


def test_save_session_commits(mock_db: MagicMock) -> None:
    save_session(mock_db, account_user_id=12345, cookies={"_ym_uid": "abc"})
    assert mock_db.commit.called


def test_save_session_on_conflict_do_update(mock_db: MagicMock) -> None:
    save_session(mock_db, account_user_id=1, cookies={"_ym_uid": "v"})
    args, _ = mock_db.execute.call_args
    sql_text = str(args[0])
    assert "ON CONFLICT" in sql_text
    assert "DO UPDATE" in sql_text


def test_save_session_passes_correct_params(mock_db: MagicMock) -> None:
    save_session(mock_db, account_user_id=777, cookies={"_ga": "GA1.2.x"}, ttl_days=14)
    _, kwargs = mock_db.execute.call_args
    params = kwargs if kwargs else mock_db.execute.call_args[0][1]
    # params могут передаваться как второй позиционный аргумент
    call_args = mock_db.execute.call_args
    params = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("params", {})
    assert params["uid"] == 777
    assert params["ttl_days"] == 14
    cookies_payload = json.loads(params["cookies_json"])
    assert cookies_payload == {"_ga": "GA1.2.x"}


# ---------------------------------------------------------------------------
# load_session
# ---------------------------------------------------------------------------


def test_load_session_returns_none_when_empty(mock_db: MagicMock) -> None:
    result = load_session(mock_db)
    assert result is None


def test_load_session_decodes_json(mock_db: MagicMock) -> None:
    mock_row = {
        "account_user_id": 12345,
        "cookies_json": json.dumps({"_ym_uid": "abc", "_cian_uid": "def"}),
        "expires_at_estimate": None,
    }
    mock_db.execute.return_value.mappings.return_value.first.return_value = mock_row
    result = load_session(mock_db)
    assert result == {"_ym_uid": "abc", "_cian_uid": "def"}


def test_load_session_updates_last_used_at(mock_db: MagicMock) -> None:
    mock_row = {
        "account_user_id": 42,
        "cookies_json": json.dumps({"session-id": "s"}),
        "expires_at_estimate": None,
    }
    mock_db.execute.return_value.mappings.return_value.first.return_value = mock_row
    load_session(mock_db)
    # Должно быть минимум 2 вызова execute: SELECT + UPDATE last_used_at
    assert mock_db.execute.call_count >= 2


# ---------------------------------------------------------------------------
# mark_session_invalid
# ---------------------------------------------------------------------------


def test_mark_session_invalid_updates_table(mock_db: MagicMock) -> None:
    mark_session_invalid(mock_db, account_user_id=12345)
    args, _ = mock_db.execute.call_args
    sql = str(args[0])
    assert "last_invalid_at" in sql
    assert "cian_session_cookies" in sql


def test_mark_session_invalid_commits(mock_db: MagicMock) -> None:
    mark_session_invalid(mock_db, account_user_id=99)
    assert mock_db.commit.called


def test_mark_session_invalid_uses_cast(mock_db: MagicMock) -> None:
    mark_session_invalid(mock_db, account_user_id=5)
    args, _ = mock_db.execute.call_args
    sql = str(args[0])
    assert "CAST(" in sql
    assert "::bigint" not in sql


# ---------------------------------------------------------------------------
# _classify_verify_response (pure unit tests — no I/O)
# ---------------------------------------------------------------------------


def test_classify_403_returns_ban_sentinel() -> None:
    """HTTP 403 TLS/bot ban → VERIFY_BAN_SENTINEL (not None, not 'expired')."""
    result = _classify_verify_response(403, None)
    assert result is VERIFY_BAN_SENTINEL
    assert result is not None  # must NOT collapse into expired-cookies signal


def test_classify_401_returns_none() -> None:
    """HTTP 401 Unauthorized → None (genuinely expired cookies)."""
    result = _classify_verify_response(401, None)
    assert result is None


def test_classify_200_not_authenticated_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
    """200 with isAuthenticated=false → None (expired)."""
    monkeypatch.setattr(
        "app.services.cian_session.extract_state",
        lambda html, mfe, key: {"user": {"isAuthenticated": False, "userId": None}},
    )
    result = _classify_verify_response(200, "<html></html>")
    assert result is None


def test_classify_200_authenticated_returns_state(monkeypatch: pytest.MonkeyPatch) -> None:
    """200 with isAuthenticated=true → state dict."""
    expected = {"user": {"isAuthenticated": True, "userId": 99}}
    monkeypatch.setattr(
        "app.services.cian_session.extract_state",
        lambda html, mfe, key: expected,
    )
    result = _classify_verify_response(200, "<html>x</html>")
    assert result == expected


def test_classify_200_state_missing_returns_markup_changed_sentinel(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """audit-scrapers finding 4: HTTP 200 но extract_state не нашёл auth-state
    (Cian сменил вёрстку/MFE-схему header-frontend) → VERIFY_MARKUP_CHANGED_SENTINEL,
    НЕ None. Раньше это конфлировалось с "cookies expired" (реальный логаут)."""
    monkeypatch.setattr(
        "app.services.cian_session.extract_state",
        lambda html, mfe, key: None,
    )
    result = _classify_verify_response(200, "<html></html>")
    assert result is VERIFY_MARKUP_CHANGED_SENTINEL
    assert result is not None  # НЕ должно триггерить cookie-refresh alert


def test_classify_5xx_returns_source_unavailable_sentinel() -> None:
    """audit-scrapers finding 4: HTTP 500 (источник недоступен) →
    VERIFY_SOURCE_UNAVAILABLE_SENTINEL, НЕ None (cookies тут ни при чём)."""
    result = _classify_verify_response(500, None)
    assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL
    assert result is not None


def test_classify_502_returns_source_unavailable_sentinel() -> None:
    """Любой non-200/403/401 статус (напр. 502 bad gateway) — источник недоступен."""
    result = _classify_verify_response(502, None)
    assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL


def test_classify_status_200_html_none_returns_source_unavailable_sentinel() -> None:
    """Defensive: status=200 но html=None (не должно случаться в проде, но
    classifier не должен молча вернуть None='expired') → source-unavailable."""
    result = _classify_verify_response(200, None)
    assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL


def test_classify_403_is_distinct_from_401() -> None:
    """Ban and expired must not collapse to the same return value."""
    ban = _classify_verify_response(403, None)
    expired = _classify_verify_response(401, None)
    assert ban is not expired
    assert ban is not None
    assert expired is None


def test_classify_all_four_outcomes_are_mutually_distinct() -> None:
    """audit-scrapers finding 4: expired (401) / ban (403) / source-unavailable (5xx)
    / markup-changed (200+extract_state=None) — четыре РАЗНЫХ сигнала, ни один не
    коллапсирует в другой. Только expired (None) должен триггерить re-login alert."""
    expired = _classify_verify_response(401, None)
    ban = _classify_verify_response(403, None)
    source_down = _classify_verify_response(500, None)

    with pytest.MonkeyPatch.context() as mp:
        mp.setattr("app.services.cian_session.extract_state", lambda html, mfe, key: None)
        markup_changed = _classify_verify_response(200, "<html></html>")

    outcomes = [expired, ban, source_down, markup_changed]
    # None встречается ровно один раз (только expired) — остальные три truthy sentinel'а
    # и все различны между собой (identity, не equality — это разные dict-объекты).
    assert outcomes.count(None) == 1
    assert expired is None
    non_none = [o for o in outcomes if o is not None]
    assert len(non_none) == 3
    assert len({id(o) for o in non_none}) == 3


# ---------------------------------------------------------------------------
# verify_session (async) — integration with curl_cffi mock
# ---------------------------------------------------------------------------


def _make_cffi_resp(status_code: int, text: str = "") -> MagicMock:
    resp = MagicMock()
    resp.status_code = status_code
    resp.text = text
    return resp


@pytest.mark.asyncio
async def test_verify_session_403_ban_not_treated_as_expired(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """HTTP 403 from curl_cffi → VERIFY_BAN_SENTINEL, never None."""
    mock_session = AsyncMock()
    mock_session.__aenter__ = AsyncMock(return_value=mock_session)
    mock_session.__aexit__ = AsyncMock(return_value=None)
    mock_session.get = AsyncMock(return_value=_make_cffi_resp(403))

    with patch("app.services.cian_session.AsyncSession", return_value=mock_session):
        result = await verify_session({"DMIR_AUTH": "x"})

    assert result is VERIFY_BAN_SENTINEL
    assert result is not None  # must NOT trigger cookie-refresh alert


@pytest.mark.asyncio
async def test_verify_session_401_is_expired(monkeypatch: pytest.MonkeyPatch) -> None:
    """HTTP 401 → None (genuine expiry)."""
    mock_session = AsyncMock()
    mock_session.__aenter__ = AsyncMock(return_value=mock_session)
    mock_session.__aexit__ = AsyncMock(return_value=None)
    mock_session.get = AsyncMock(return_value=_make_cffi_resp(401))

    with patch("app.services.cian_session.AsyncSession", return_value=mock_session):
        result = await verify_session({"DMIR_AUTH": "x"})

    assert result is None


@pytest.mark.asyncio
async def test_verify_session_authenticated(monkeypatch: pytest.MonkeyPatch) -> None:
    """200 + isAuthenticated=true → returns state dict."""
    expected_state = {"user": {"isAuthenticated": True, "userId": 102963817}}
    monkeypatch.setattr(
        "app.services.cian_session.extract_state",
        lambda html, mfe, key: expected_state,
    )

    mock_session = AsyncMock()
    mock_session.__aenter__ = AsyncMock(return_value=mock_session)
    mock_session.__aexit__ = AsyncMock(return_value=None)
    mock_session.get = AsyncMock(return_value=_make_cffi_resp(200, "<html>x</html>"))

    with patch("app.services.cian_session.AsyncSession", return_value=mock_session):
        result = await verify_session({"DMIR_AUTH": "x", "_CIAN_GK": "y"})

    assert result is not None
    assert result["user"]["isAuthenticated"] is True
    assert result["user"]["userId"] == 102963817


@pytest.mark.asyncio
async def test_verify_session_not_authenticated_returns_none(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """200 + isAuthenticated=false → None (expired)."""
    monkeypatch.setattr(
        "app.services.cian_session.extract_state",
        lambda html, mfe, key: {"user": {"isAuthenticated": False, "userId": None}},
    )

    mock_session = AsyncMock()
    mock_session.__aenter__ = AsyncMock(return_value=mock_session)
    mock_session.__aexit__ = AsyncMock(return_value=None)
    mock_session.get = AsyncMock(return_value=_make_cffi_resp(200, "<html></html>"))

    with patch("app.services.cian_session.AsyncSession", return_value=mock_session):
        result = await verify_session({"DMIR_AUTH": "x"})

    assert result is None


@pytest.mark.asyncio
async def test_verify_session_state_missing_returns_markup_changed_sentinel(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """audit-scrapers finding 4: 200 + extract_state returns None (markup changed)
    → VERIFY_MARKUP_CHANGED_SENTINEL, НЕ None. Раньше ложно триггерило "cookies
    expired, please re-upload" для реальной причины "Cian сменил вёрстку"."""
    monkeypatch.setattr(
        "app.services.cian_session.extract_state",
        lambda html, mfe, key: None,
    )

    mock_session = AsyncMock()
    mock_session.__aenter__ = AsyncMock(return_value=mock_session)
    mock_session.__aexit__ = AsyncMock(return_value=None)
    mock_session.get = AsyncMock(return_value=_make_cffi_resp(200, "<html></html>"))

    with patch("app.services.cian_session.AsyncSession", return_value=mock_session):
        result = await verify_session({"DMIR_AUTH": "x"})

    assert result is VERIFY_MARKUP_CHANGED_SENTINEL
    assert result is not None


@pytest.mark.asyncio
async def test_verify_session_5xx_returns_source_unavailable_sentinel() -> None:
    """audit-scrapers finding 4: HTTP 500 → VERIFY_SOURCE_UNAVAILABLE_SENTINEL,
    НЕ None. Источник временно недоступен — cookies тут ни при чём, вызывающий
    не должен помечать сессию invalid / просить re-upload."""
    mock_session = AsyncMock()
    mock_session.__aenter__ = AsyncMock(return_value=mock_session)
    mock_session.__aexit__ = AsyncMock(return_value=None)
    mock_session.get = AsyncMock(return_value=_make_cffi_resp(500))

    with patch("app.services.cian_session.AsyncSession", return_value=mock_session):
        result = await verify_session({"DMIR_AUTH": "x"})

    assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL
    assert result is not None


@pytest.mark.asyncio
async def test_verify_session_network_error_returns_source_unavailable_sentinel() -> None:
    """audit-scrapers finding 4: сетевой/транспортный сбой (timeout, connection
    reset и т.п.) → VERIFY_SOURCE_UNAVAILABLE_SENTINEL, НЕ None. Раньше generic
    except возвращал None — конфлировал сетевой сбой с протухшими cookies."""
    mock_session = AsyncMock()
    mock_session.__aenter__ = AsyncMock(return_value=mock_session)
    mock_session.__aexit__ = AsyncMock(return_value=None)
    mock_session.get = AsyncMock(side_effect=ConnectionError("connection reset by peer"))

    with patch("app.services.cian_session.AsyncSession", return_value=mock_session):
        result = await verify_session({"DMIR_AUTH": "x"})

    assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL
    assert result is not None


@pytest.mark.asyncio
async def test_verify_session_uses_chrome120_impersonate() -> None:
    """curl_cffi AsyncSession must be constructed with impersonate='chrome120'."""
    mock_session = AsyncMock()
    mock_session.__aenter__ = AsyncMock(return_value=mock_session)
    mock_session.__aexit__ = AsyncMock(return_value=None)
    mock_session.get = AsyncMock(return_value=_make_cffi_resp(403))

    with patch("app.services.cian_session.AsyncSession", return_value=mock_session) as mock_cls:
        await verify_session({"DMIR_AUTH": "x"})

    _, kwargs = mock_cls.call_args
    assert kwargs.get("impersonate") == "chrome120"
