"""Tests for the Flowlines memory retrieval module."""

from __future__ import annotations

import json
from io import BytesIO
from unittest.mock import MagicMock, patch
from urllib.error import HTTPError, URLError

import pytest

from flowlines._memory import (
    FlowlinesMemoryError,
    _build_url,
    afetch_memory,
    fetch_memory,
)


class TestBuildUrl:
    """Tests for _build_url()."""

    def test_required_params_only(self) -> None:
        """Builds URL with only user_id."""
        url = _build_url("https://api.flowlines.ai", user_id="u1")
        assert url == "https://api.flowlines.ai/v1/get-memory?user_id=u1"

    def test_all_params(self) -> None:
        """Builds URL with all parameters."""
        url = _build_url(
            "https://api.flowlines.ai",
            user_id="u1",
            session_id="s1",
            agent_id="a1",
            view="summary",
        )
        assert "user_id=u1" in url
        assert "session_id=s1" in url
        assert "agent_id=a1" in url
        assert "view=summary" in url
        assert url.startswith("https://api.flowlines.ai/v1/get-memory?")

    def test_trailing_slash_stripped(self) -> None:
        """Trailing slash on the endpoint is stripped."""
        url = _build_url("https://api.flowlines.ai/", user_id="u1")
        assert url.startswith("https://api.flowlines.ai/v1/get-memory?")

    def test_url_encoding(self) -> None:
        """Special characters are URL-encoded."""
        url = _build_url("https://api.flowlines.ai", user_id="user with spaces")
        assert "user+with+spaces" in url or "user%20with%20spaces" in url

    def test_none_params_excluded(self) -> None:
        """None parameters are not included in the URL."""
        url = _build_url(
            "https://api.flowlines.ai",
            user_id="u1",
            session_id=None,
            agent_id=None,
            view=None,
        )
        assert "session_id" not in url
        assert "agent_id" not in url
        assert "view" not in url

    def test_session_id_only(self) -> None:
        """Builds URL with user_id and session_id."""
        url = _build_url(
            "https://api.flowlines.ai",
            user_id="u1",
            session_id="s1",
        )
        assert "user_id=u1" in url
        assert "session_id=s1" in url
        assert "agent_id" not in url
        assert "view" not in url


class TestFetchMemory:
    """Tests for fetch_memory()."""

    @patch("flowlines._memory.urlopen")
    def test_success(self, mock_urlopen: MagicMock) -> None:
        """Returns JSON string of the memory dict on success."""
        memory_data = {"key": "value", "items": [1, 2, 3]}
        response_body = json.dumps({"memory": memory_data}).encode()
        mock_response = MagicMock()
        mock_response.read.return_value = response_body
        mock_urlopen.return_value = mock_response

        result = fetch_memory(
            api_key="test-key",
            api_endpoint="https://api.flowlines.ai",
            user_id="u1",
        )

        assert json.loads(result) == memory_data

    @patch("flowlines._memory.urlopen")
    def test_sends_api_key_header(self, mock_urlopen: MagicMock) -> None:
        """Sends x-flowlines-api-key header."""
        mock_response = MagicMock()
        mock_response.read.return_value = json.dumps({"memory": {}}).encode()
        mock_urlopen.return_value = mock_response

        fetch_memory(
            api_key="my-secret-key",
            api_endpoint="https://api.flowlines.ai",
            user_id="u1",
        )

        request = mock_urlopen.call_args[0][0]
        assert request.get_header("X-flowlines-api-key") == "my-secret-key"

    @patch("flowlines._memory.urlopen")
    def test_http_error_json_body(self, mock_urlopen: MagicMock) -> None:
        """Raises FlowlinesMemoryError with status_code on HTTP error (JSON body)."""
        error_body = json.dumps({"error": "Unauthorized"}).encode()
        mock_urlopen.side_effect = HTTPError(
            url="https://api.flowlines.ai/get-memory",
            code=401,
            msg="Unauthorized",
            hdrs=MagicMock(),
            fp=BytesIO(error_body),
        )

        with pytest.raises(FlowlinesMemoryError, match="HTTP 401") as exc_info:
            fetch_memory(
                api_key="bad-key",
                api_endpoint="https://api.flowlines.ai",
                user_id="u1",
            )

        assert exc_info.value.status_code == 401
        assert "Unauthorized" in str(exc_info.value)

    @patch("flowlines._memory.urlopen")
    def test_http_error_plain_text_body(self, mock_urlopen: MagicMock) -> None:
        """Raises FlowlinesMemoryError with plain text body on HTTP error."""
        mock_urlopen.side_effect = HTTPError(
            url="https://api.flowlines.ai/get-memory",
            code=500,
            msg="Internal Server Error",
            hdrs=MagicMock(),
            fp=BytesIO(b"Something went wrong"),
        )

        with pytest.raises(FlowlinesMemoryError, match="HTTP 500") as exc_info:
            fetch_memory(
                api_key="key",
                api_endpoint="https://api.flowlines.ai",
                user_id="u1",
            )

        assert exc_info.value.status_code == 500

    @patch("flowlines._memory.urlopen")
    def test_network_error(self, mock_urlopen: MagicMock) -> None:
        """Raises FlowlinesMemoryError with no status_code on network error."""
        mock_urlopen.side_effect = URLError("Connection refused")

        with pytest.raises(
            FlowlinesMemoryError, match="Connection refused"
        ) as exc_info:
            fetch_memory(
                api_key="key",
                api_endpoint="https://api.flowlines.ai",
                user_id="u1",
            )

        assert exc_info.value.status_code is None

    @patch("flowlines._memory.urlopen")
    def test_malformed_json(self, mock_urlopen: MagicMock) -> None:
        """Raises FlowlinesMemoryError on malformed JSON response."""
        mock_response = MagicMock()
        mock_response.read.return_value = b"not json"
        mock_urlopen.return_value = mock_response

        with pytest.raises(FlowlinesMemoryError, match="malformed JSON"):
            fetch_memory(
                api_key="key",
                api_endpoint="https://api.flowlines.ai",
                user_id="u1",
            )

    @patch("flowlines._memory.urlopen")
    def test_missing_memory_key(self, mock_urlopen: MagicMock) -> None:
        """Raises FlowlinesMemoryError when 'memory' key is missing."""
        mock_response = MagicMock()
        mock_response.read.return_value = json.dumps({"data": "something"}).encode()
        mock_urlopen.return_value = mock_response

        with pytest.raises(FlowlinesMemoryError, match="missing 'memory' key"):
            fetch_memory(
                api_key="key",
                api_endpoint="https://api.flowlines.ai",
                user_id="u1",
            )

    @patch("flowlines._memory.urlopen")
    def test_passes_all_params_to_url(self, mock_urlopen: MagicMock) -> None:
        """All parameters are included in the request URL."""
        mock_response = MagicMock()
        mock_response.read.return_value = json.dumps({"memory": {}}).encode()
        mock_urlopen.return_value = mock_response

        fetch_memory(
            api_key="key",
            api_endpoint="https://api.flowlines.ai",
            user_id="u1",
            session_id="s1",
            agent_id="a1",
            view="detailed",
        )

        request = mock_urlopen.call_args[0][0]
        url = request.full_url
        assert "user_id=u1" in url
        assert "session_id=s1" in url
        assert "agent_id=a1" in url
        assert "view=detailed" in url

    @patch("flowlines._memory.urlopen")
    def test_http_error_404_returns_none(self, mock_urlopen: MagicMock) -> None:
        """Returns None without raising on HTTP 404."""
        mock_urlopen.side_effect = HTTPError(
            url="https://api.flowlines.ai/get-memory",
            code=404,
            msg="Not Found",
            hdrs=MagicMock(),
            fp=BytesIO(b'{"error": "Not found"}'),
        )

        result = fetch_memory(
            api_key="key",
            api_endpoint="https://api.flowlines.ai",
            user_id="u1",
        )

        assert result is None


class TestAfetchMemory:
    """Tests for afetch_memory()."""

    @patch("flowlines._memory.fetch_memory")
    async def test_delegates_to_fetch_memory(self, mock_fetch: MagicMock) -> None:
        """afetch_memory() delegates to fetch_memory via asyncio.to_thread."""
        mock_fetch.return_value = '{"key": "value"}'

        result = await afetch_memory(
            api_key="key",
            api_endpoint="https://api.flowlines.ai",
            user_id="u1",
            session_id="s1",
            agent_id="a1",
            view="summary",
        )

        assert result == '{"key": "value"}'
        mock_fetch.assert_called_once_with(
            api_key="key",
            api_endpoint="https://api.flowlines.ai",
            user_id="u1",
            session_id="s1",
            agent_id="a1",
            view="summary",
        )

    @patch("flowlines._memory.fetch_memory")
    async def test_propagates_error(self, mock_fetch: MagicMock) -> None:
        """afetch_memory() propagates FlowlinesMemoryError from fetch_memory."""
        mock_fetch.side_effect = FlowlinesMemoryError("test error", status_code=500)

        with pytest.raises(FlowlinesMemoryError, match="test error"):
            await afetch_memory(
                api_key="key",
                api_endpoint="https://api.flowlines.ai",
                user_id="u1",
            )


class TestFlowlinesMemoryError:
    """Tests for FlowlinesMemoryError."""

    def test_with_status_code(self) -> None:
        """FlowlinesMemoryError stores status_code."""
        err = FlowlinesMemoryError("error", status_code=401)
        assert str(err) == "error"
        assert err.status_code == 401

    def test_without_status_code(self) -> None:
        """FlowlinesMemoryError defaults status_code to None."""
        err = FlowlinesMemoryError("error")
        assert err.status_code is None

    def test_is_exception(self) -> None:
        """FlowlinesMemoryError is an Exception."""
        assert issubclass(FlowlinesMemoryError, Exception)
