"""DSM 7.x API client 테스트 — httpx mock으로 외부 호출 차단."""
import asyncio
from unittest.mock import patch, MagicMock

import pytest
import httpx

from app.dsm_client import create_share_link, DSMError


@pytest.fixture(autouse=True)
def _no_backoff(monkeypatch):
    """retry 백오프 sleep 제거 — 테스트 속도."""
    from app import dsm_client
    monkeypatch.setattr(dsm_client, "DSM_BACKOFF_SEC", 0.0)


@pytest.fixture(autouse=True)
def _dsm_env(monkeypatch):
    monkeypatch.setenv("DSM_HOST", "https://test-nas:5001")
    monkeypatch.setenv("DSM_USER", "test-user")
    monkeypatch.setenv("DSM_PASS", "test-pass")
    from app import dsm_client
    monkeypatch.setattr(dsm_client, "DSM_HOST", "https://test-nas:5001")
    monkeypatch.setattr(dsm_client, "DSM_USER", "test-user")
    monkeypatch.setattr(dsm_client, "DSM_PASS", "test-pass")


def _make_response(json_data, status_code=200):
    """httpx.Response mock."""
    mock = MagicMock(spec=httpx.Response)
    mock.json.return_value = json_data
    mock.status_code = status_code
    mock.raise_for_status = MagicMock()
    return mock


def test_create_share_link_login_logout():
    """login → Sharing.create → logout 순서가 보장되어야 한다."""
    call_order = []

    async def fake_get(self, url, *, params=None, **kw):
        api = (params or {}).get("api", "")
        method = (params or {}).get("method", "")
        call_order.append(f"{api}.{method}")
        if api == "SYNO.API.Auth" and method == "login":
            return _make_response({"success": True, "data": {"sid": "fake-sid"}})
        if api == "SYNO.API.Auth" and method == "logout":
            return _make_response({"success": True})
        if api == "SYNO.FileStation.Sharing" and method == "create":
            return _make_response({
                "success": True,
                "data": {"links": [{"url": "https://test-nas:5001/sharing/abc"}]},
            })
        return _make_response({"success": False, "error": "unexpected"})

    with patch.object(httpx.AsyncClient, "get", new=fake_get):
        url, expires_at = asyncio.run(create_share_link("/volume1/test/file.zip", expires_in_sec=3600))

    assert url == "https://test-nas:5001/sharing/abc"
    assert call_order == [
        "SYNO.API.Auth.login",
        "SYNO.FileStation.Sharing.create",
        "SYNO.API.Auth.logout",
    ]


def test_create_share_link_returns_url_and_expiry():
    """응답 파싱 — links[0].url 사용."""
    async def fake_get(self, url, *, params=None, **kw):
        method = (params or {}).get("method", "")
        if method == "login":
            return _make_response({"success": True, "data": {"sid": "sid"}})
        if method == "create":
            return _make_response({
                "success": True,
                "data": {"links": [{"url": "https://nas/sharing/xyz"}]},
            })
        return _make_response({"success": True})

    with patch.object(httpx.AsyncClient, "get", new=fake_get):
        url, expires_at = asyncio.run(create_share_link("/volume1/test/file.zip", expires_in_sec=7200))

    assert url == "https://nas/sharing/xyz"
    assert expires_at is not None


def test_dsm_login_failure_raises():
    """login API success=False → DSMError."""
    async def fake_get(self, url, *, params=None, **kw):
        return _make_response({"success": False, "error": {"code": 400}})

    with patch.object(httpx.AsyncClient, "get", new=fake_get):
        with pytest.raises(DSMError, match="login 실패"):
            asyncio.run(create_share_link("/volume1/test/file.zip"))


def test_dsm_share_failure_logs_out():
    """Sharing.create 실패해도 logout 호출 (try/finally)."""
    call_order = []

    async def fake_get(self, url, *, params=None, **kw):
        method = (params or {}).get("method", "")
        call_order.append(method)
        if method == "login":
            return _make_response({"success": True, "data": {"sid": "sid"}})
        if method == "create":
            return _make_response({"success": False, "error": {"code": 401}})
        if method == "logout":
            return _make_response({"success": True})
        return _make_response({"success": False})

    with patch.object(httpx.AsyncClient, "get", new=fake_get):
        with pytest.raises(DSMError, match="Sharing.create 실패"):
            asyncio.run(create_share_link("/volume1/test/file.zip"))

    assert "login" in call_order
    assert "logout" in call_order, "logout이 호출되지 않음 (finally 누락 의심)"


def test_retry_on_5xx_then_success(monkeypatch):
    """첫 호출 5xx → retry → 두 번째 200으로 성공."""
    from app import dsm_client
    monkeypatch.setattr(dsm_client, "DSM_MAX_RETRIES", 3)

    login_calls = {"n": 0}

    async def fake_get(self, url, *, params=None, **kw):
        method = (params or {}).get("method", "")
        if method == "login":
            login_calls["n"] += 1
            if login_calls["n"] == 1:
                return _make_response({}, status_code=503)
            return _make_response({"success": True, "data": {"sid": "sid-after-retry"}})
        if method == "create":
            return _make_response({
                "success": True,
                "data": {"links": [{"url": "https://nas/sharing/retry"}]},
            })
        return _make_response({"success": True})

    with patch.object(httpx.AsyncClient, "get", new=fake_get):
        url, _ = asyncio.run(create_share_link("/volume1/x.zip"))

    assert url == "https://nas/sharing/retry"
    assert login_calls["n"] == 2, "5xx 응답에 대해 retry가 동작해야 함"


def test_retry_exhausts_on_persistent_5xx(monkeypatch):
    """5xx가 MAX_RETRIES 동안 계속되면 DSMError로 raise."""
    from app import dsm_client
    monkeypatch.setattr(dsm_client, "DSM_MAX_RETRIES", 2)

    login_calls = {"n": 0}

    async def fake_get(self, url, *, params=None, **kw):
        method = (params or {}).get("method", "")
        if method == "login":
            login_calls["n"] += 1
            return _make_response({}, status_code=503)
        return _make_response({"success": True})

    with patch.object(httpx.AsyncClient, "get", new=fake_get):
        with pytest.raises(DSMError, match="재시도"):
            asyncio.run(create_share_link("/volume1/x.zip"))

    assert login_calls["n"] == 2, f"MAX_RETRIES만큼 시도해야 함 (실제: {login_calls['n']})"


def test_retry_on_transport_error_then_success(monkeypatch):
    """httpx.ConnectError → retry → 성공."""
    from app import dsm_client
    monkeypatch.setattr(dsm_client, "DSM_MAX_RETRIES", 3)

    login_calls = {"n": 0}

    async def fake_get(self, url, *, params=None, **kw):
        method = (params or {}).get("method", "")
        if method == "login":
            login_calls["n"] += 1
            if login_calls["n"] == 1:
                raise httpx.ConnectError("connection refused")
            return _make_response({"success": True, "data": {"sid": "sid"}})
        if method == "create":
            return _make_response({
                "success": True,
                "data": {"links": [{"url": "https://nas/sharing/tr"}]},
            })
        return _make_response({"success": True})

    with patch.object(httpx.AsyncClient, "get", new=fake_get):
        url, _ = asyncio.run(create_share_link("/volume1/x.zip"))

    assert url == "https://nas/sharing/tr"
    assert login_calls["n"] == 2


def test_no_retry_on_4xx(monkeypatch):
    """4xx (영구 오류)는 retry 없이 즉시 raise_for_status."""
    from app import dsm_client
    monkeypatch.setattr(dsm_client, "DSM_MAX_RETRIES", 3)

    login_calls = {"n": 0}

    def _raise_4xx():
        raise httpx.HTTPStatusError(
            "client error",
            request=MagicMock(),
            response=MagicMock(status_code=403),
        )

    async def fake_get(self, url, *, params=None, **kw):
        login_calls["n"] += 1
        resp = MagicMock(spec=httpx.Response)
        resp.status_code = 403
        resp.json.return_value = {}
        resp.raise_for_status = _raise_4xx
        return resp

    with patch.object(httpx.AsyncClient, "get", new=fake_get):
        with pytest.raises(httpx.HTTPStatusError):
            asyncio.run(create_share_link("/volume1/x.zip"))

    assert login_calls["n"] == 1, "4xx는 retry 없이 즉시 raise"
