"""Unit tests for the Bannerlord GABS client and observer.

All tests are offline — no real TCP connection is made.  Sockets are
mocked or substituted with in-process fakes.

Refs: #1093 (M1 Observer), #1091 (Epic)
"""

from __future__ import annotations

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

import pytest

from integrations.bannerlord.gabs_client import GabsClient, GabsError

# ── GabsClient unit tests ─────────────────────────────────────────────────────


def _make_response(result: object = None, error: dict | None = None, req_id: int = 1) -> bytes:
    """Encode a JSON-RPC 2.0 response as newline-delimited bytes."""
    resp: dict = {"jsonrpc": "2.0", "id": req_id}
    if error is not None:
        resp["error"] = error
    else:
        resp["result"] = result
    return (json.dumps(resp) + "\n").encode()


def _mock_socket(response_bytes: bytes) -> MagicMock:
    """Return a MagicMock socket that yields *response_bytes* from recv()."""
    sock = MagicMock(spec=socket.socket)
    # First recv returns the full response, subsequent calls return b"" (EOF)
    sock.recv.side_effect = [response_bytes, b""]
    return sock


class TestGabsClientCall:
    def test_successful_call_returns_result(self, tmp_path):
        """call() returns the result field on a successful JSON-RPC response."""
        expected = {"day": 42, "season": "spring"}
        response = _make_response(result=expected)

        with patch("socket.create_connection") as mock_conn:
            mock_conn.return_value = _mock_socket(response)
            client = GabsClient()
            result = client.call("core/get_game_state")

        assert result == expected

    def test_rpc_error_raises_gabs_error(self):
        """call() raises GabsError when the server returns an error object."""
        error = {"code": -32601, "message": "Method not found"}
        response = _make_response(error=error)

        with patch("socket.create_connection") as mock_conn:
            mock_conn.return_value = _mock_socket(response)
            client = GabsClient()
            with pytest.raises(GabsError, match="Method not found"):
                client.call("unknown/method")

    def test_tcp_failure_raises_gabs_error(self):
        """call() raises GabsError when TCP connection is refused."""
        with patch("socket.create_connection", side_effect=OSError("Connection refused")):
            client = GabsClient()
            with pytest.raises(GabsError, match="TCP connect"):
                client.call("ping")

    def test_malformed_json_raises_gabs_error(self):
        """call() raises GabsError when the server sends invalid JSON."""
        with patch("socket.create_connection") as mock_conn:
            bad_sock = MagicMock(spec=socket.socket)
            bad_sock.recv.return_value = b"not valid json\n"
            mock_conn.return_value = bad_sock
            client = GabsClient()
            with pytest.raises(GabsError, match="Malformed JSON"):
                client.call("ping")

    def test_connection_closed_early_raises_gabs_error(self):
        """call() raises GabsError when the server closes without sending \\n."""
        with patch("socket.create_connection") as mock_conn:
            bad_sock = MagicMock(spec=socket.socket)
            # recv never sends a newline; returns empty bytes on second call
            bad_sock.recv.side_effect = [b"partial", b""]
            mock_conn.return_value = bad_sock
            client = GabsClient()
            with pytest.raises(GabsError, match="closed before response"):
                client.call("ping")

    def test_socket_is_closed_after_call(self):
        """The socket is closed even after a successful call."""
        response = _make_response(result="pong")
        mock_sock = _mock_socket(response)

        with patch("socket.create_connection", return_value=mock_sock):
            GabsClient().call("ping")

        mock_sock.close.assert_called_once()

    def test_socket_is_closed_after_error(self):
        """The socket is closed even when the server returns a JSON-RPC error."""
        error = {"code": -1, "message": "fail"}
        response = _make_response(error=error)
        mock_sock = _mock_socket(response)

        with patch("socket.create_connection", return_value=mock_sock):
            with pytest.raises(GabsError):
                GabsClient().call("something")

        mock_sock.close.assert_called_once()


class TestGabsClientHighLevel:
    def _patched_client(self, method_results: dict) -> GabsClient:
        """Return a GabsClient whose call() is stubbed with *method_results*."""
        client = GabsClient()
        client.call = MagicMock(side_effect=lambda m, **_: method_results.get(m))
        return client

    def test_ping_returns_true_on_success(self):
        client = GabsClient()
        client.call = MagicMock(return_value=None)
        assert client.ping() is True

    def test_ping_returns_false_on_gabs_error(self):
        client = GabsClient()
        client.call = MagicMock(side_effect=GabsError("timeout"))
        assert client.ping() is False

    def test_get_game_state_returns_dict(self):
        client = GabsClient()
        client.call = MagicMock(return_value={"day": 1, "season": "autumn"})
        result = client.get_game_state()
        assert result["day"] == 1

    def test_get_game_state_returns_empty_dict_on_non_dict(self):
        client = GabsClient()
        client.call = MagicMock(return_value=None)
        assert client.get_game_state() == {}

    def test_get_player_returns_dict(self):
        client = GabsClient()
        client.call = MagicMock(return_value={"name": "Timmy", "level": 5})
        result = client.get_player()
        assert result["name"] == "Timmy"

    def test_list_kingdoms_returns_list(self):
        client = GabsClient()
        client.call = MagicMock(return_value=[{"name": "Empire"}, {"name": "Vlandia"}])
        result = client.list_kingdoms()
        assert len(result) == 2

    def test_list_kingdoms_returns_empty_list_on_non_list(self):
        client = GabsClient()
        client.call = MagicMock(return_value=None)
        assert client.list_kingdoms() == []


# ── BannerlordObserver unit tests ─────────────────────────────────────────────


class TestBannerlordObserver:
    def test_journal_header_created_on_first_run(self, tmp_path):
        """ensure_journal_header creates the file if it does not exist."""
        from integrations.bannerlord.observer import BannerlordObserver

        journal = tmp_path / "test_journal.md"
        observer = BannerlordObserver(journal_path=str(journal))
        observer._ensure_journal_header()

        assert journal.exists()
        content = journal.read_text()
        assert "Bannerlord Journal" in content
        assert "#1091" in content

    def test_journal_header_not_overwritten(self, tmp_path):
        """ensure_journal_header does not overwrite an existing file."""
        from integrations.bannerlord.observer import BannerlordObserver

        journal = tmp_path / "existing.md"
        journal.write_text("# existing content\n")
        observer = BannerlordObserver(journal_path=str(journal))
        observer._ensure_journal_header()

        assert journal.read_text() == "# existing content\n"

    def test_append_to_journal(self, tmp_path):
        """_append_to_journal appends text to the journal file."""
        from integrations.bannerlord.observer import BannerlordObserver

        journal = tmp_path / "journal.md"
        journal.write_text("# header\n")
        observer = BannerlordObserver(journal_path=str(journal))
        observer._append_to_journal("\nentry text\n")

        assert "entry text" in journal.read_text()

    def test_poll_snapshot_returns_none_when_gabs_unreachable(self, tmp_path):
        """_poll_snapshot returns None when get_game_state fails."""
        from integrations.bannerlord.observer import BannerlordObserver

        observer = BannerlordObserver(journal_path=str(tmp_path / "j.md"))
        mock_client = MagicMock()
        mock_client.get_game_state.side_effect = GabsError("refused")

        result = observer._poll_snapshot(mock_client)
        assert result is None

    def test_poll_snapshot_partial_on_secondary_failure(self, tmp_path):
        """_poll_snapshot returns a snapshot even if hero/party calls fail."""
        from integrations.bannerlord.observer import BannerlordObserver

        observer = BannerlordObserver(journal_path=str(tmp_path / "j.md"))
        mock_client = MagicMock()
        mock_client.get_game_state.return_value = {"day": 5}
        mock_client.get_player.side_effect = GabsError("hero unavailable")
        mock_client.get_player_party.side_effect = GabsError("party unavailable")
        mock_client.list_kingdoms.return_value = [{"name": "Empire"}]

        snapshot = observer._poll_snapshot(mock_client)
        assert snapshot is not None
        assert snapshot["game_state"]["day"] == 5
        assert snapshot["player"] == {}
        assert snapshot["player_party"] == {}
        assert snapshot["kingdoms"][0]["name"] == "Empire"

    def test_format_journal_entry_contains_key_fields(self, tmp_path):
        """_format_journal_entry includes hero name, day, and kingdom data."""
        from datetime import UTC, datetime

        from integrations.bannerlord.observer import _format_journal_entry

        snapshot = {
            "game_state": {"day": 7, "season": "winter", "campaign_phase": "early"},
            "player": {
                "name": "Timmy",
                "clan": "Thalheimer",
                "renown": 42,
                "level": 3,
                "gold": 1000,
            },
            "player_party": {"size": 25, "morale": 80, "food_days_left": 5},
            "kingdoms": [{"name": "Vlandia", "ruler": "Derthert", "military_strength": 5000}],
        }
        ts = datetime(2026, 3, 23, 12, 0, 0, tzinfo=UTC)
        entry = _format_journal_entry(snapshot, ts, entry_num=1)

        assert "Entry #0001" in entry
        assert "Day 7" in entry
        assert "winter" in entry
        assert "Timmy" in entry
        assert "Thalheimer" in entry
        assert "Vlandia" in entry
        assert "Derthert" in entry

    @pytest.mark.asyncio
    async def test_observe_stops_after_target_days(self, tmp_path):
        """observe(days=2) stops after 2 unique in-game days are logged."""
        from integrations.bannerlord.observer import BannerlordObserver

        journal = tmp_path / "j.md"
        observer = BannerlordObserver(
            poll_interval=0,  # no sleep
            journal_path=str(journal),
        )

        # Simulate two distinct in-game days across three polls
        snapshots = [
            {"game_state": {"day": 1}, "player": {}, "player_party": {}, "kingdoms": []},
            {"game_state": {"day": 1}, "player": {}, "player_party": {}, "kingdoms": []},
            {"game_state": {"day": 2}, "player": {}, "player_party": {}, "kingdoms": []},
        ]
        call_count = 0

        def fake_poll(client):
            nonlocal call_count
            if call_count >= len(snapshots):
                return snapshots[-1]
            snap = snapshots[call_count]
            call_count += 1
            return snap

        observer._poll_snapshot = fake_poll

        await observer.observe(days=2)

        assert len(observer._days_observed) >= 2
        assert journal.exists()
        content = journal.read_text()
        assert "Entry #" in content
