"""
Tests for the ESPal client module.
"""

import pytest
import socket
import threading
import time
import tempfile
import os
from unittest.mock import Mock, patch, MagicMock
from esppal.client import ESPalClient, DaemonNotRunning, print_data_message, print_build_output
from esppal.protocol import (
    StatusMessage, SubscribedMessage, ErrorMessage, DataMessage,
    BuildOutputMessage, BuildCompleteMessage, MarkerMessage
)


@pytest.fixture
def temp_socket_path():
    """Create a temporary socket path."""
    fd, path = tempfile.mkstemp(suffix=".sock")
    os.close(fd)
    os.unlink(path)
    yield path
    if os.path.exists(path):
        os.unlink(path)


@pytest.fixture
def mock_socket():
    """Create a mock socket."""
    with patch("esppal.client.socket") as mock:
        sock = MagicMock()
        mock.socket.return_value = sock
        mock.AF_UNIX = socket.AF_UNIX
        mock.SOCK_STREAM = socket.SOCK_STREAM
        mock.error = socket.error
        mock.timeout = socket.timeout
        yield sock


class TestClientBasics:
    """Basic tests for ESPalClient."""

    def test_initial_state(self):
        """Client starts disconnected."""
        with patch("esppal.client.get_socket_path", return_value="/tmp/test.sock"):
            with patch("esppal.client.get_pid_path", return_value="/tmp/test.pid"):
                client = ESPalClient()
                assert not client.connected

    def test_socket_path(self):
        """Client uses correct socket path."""
        with patch("esppal.client.get_socket_path", return_value="/tmp/test.sock"):
            with patch("esppal.client.get_pid_path", return_value="/tmp/test.pid"):
                client = ESPalClient(vid="10c4", pid="ea60")
                assert "/tmp/test.sock" in client.socket_path


class TestConnection:
    """Tests for connection handling."""

    def test_connect_to_running_daemon(self, mock_socket, temp_socket_path):
        """Connect to already running daemon."""
        pid_path = temp_socket_path.replace(".sock", ".pid")

        # Create PID file
        with open(pid_path, "w") as f:
            f.write(str(os.getpid()))

        try:
            with patch("esppal.client.get_socket_path", return_value=temp_socket_path):
                with patch("esppal.client.get_pid_path", return_value=pid_path):
                    client = ESPalClient(auto_spawn=False)

                    assert client.connect()
                    assert client.connected
        finally:
            if os.path.exists(pid_path):
                os.unlink(pid_path)

    def test_connect_daemon_not_running(self, temp_socket_path):
        """Connect fails when daemon not running and auto_spawn disabled."""
        pid_path = temp_socket_path.replace(".sock", ".pid")

        with patch("esppal.client.get_socket_path", return_value=temp_socket_path):
            with patch("esppal.client.get_pid_path", return_value=pid_path):
                client = ESPalClient(auto_spawn=False)

                with pytest.raises(DaemonNotRunning):
                    client.connect()

    def test_disconnect(self, mock_socket, temp_socket_path):
        """Disconnect closes socket."""
        pid_path = temp_socket_path.replace(".sock", ".pid")

        with open(pid_path, "w") as f:
            f.write(str(os.getpid()))

        try:
            with patch("esppal.client.get_socket_path", return_value=temp_socket_path):
                with patch("esppal.client.get_pid_path", return_value=pid_path):
                    client = ESPalClient(auto_spawn=False)
                    client.connect()
                    client.disconnect()

                    assert not client.connected
                    mock_socket.close.assert_called()
        finally:
            if os.path.exists(pid_path):
                os.unlink(pid_path)


class TestStatus:
    """Tests for status command."""

    def test_status_command(self, mock_socket, temp_socket_path):
        """Status returns daemon status."""
        pid_path = temp_socket_path.replace(".sock", ".pid")

        with open(pid_path, "w") as f:
            f.write(str(os.getpid()))

        # Mock receive to return status
        status_response = b'{"type": "status", "port": "/dev/ttyUSB0", "port_state": "connected", "clients": 1, "buffer_bytes": 1024, "buffer_entries": 10, "uptime": 100.0, "idle_timeout": 1800.0, "vid_pid": "10c4:ea60"}\n'
        mock_socket.recv.return_value = status_response

        try:
            with patch("esppal.client.get_socket_path", return_value=temp_socket_path):
                with patch("esppal.client.get_pid_path", return_value=pid_path):
                    client = ESPalClient(auto_spawn=False)
                    status = client.status()

                    assert status is not None
                    assert status.port == "/dev/ttyUSB0"
                    assert status.clients == 1
        finally:
            if os.path.exists(pid_path):
                os.unlink(pid_path)


class TestSubscription:
    """Tests for subscription."""

    def test_subscribe_live(self, mock_socket, temp_socket_path):
        """Subscribe in live mode."""
        pid_path = temp_socket_path.replace(".sock", ".pid")

        with open(pid_path, "w") as f:
            f.write(str(os.getpid()))

        # Mock receive
        subscribe_response = b'{"type": "subscribed", "mode": "live", "position": 100}\n'
        mock_socket.recv.return_value = subscribe_response

        try:
            with patch("esppal.client.get_socket_path", return_value=temp_socket_path):
                with patch("esppal.client.get_pid_path", return_value=pid_path):
                    client = ESPalClient(auto_spawn=False)

                    result = client.subscribe(mode="live")
                    assert result

                    client.disconnect()
        finally:
            if os.path.exists(pid_path):
                os.unlink(pid_path)

    def test_subscribe_with_callback(self, mock_socket, temp_socket_path):
        """Subscribe with data callback."""
        pid_path = temp_socket_path.replace(".sock", ".pid")

        with open(pid_path, "w") as f:
            f.write(str(os.getpid()))

        data_received = []

        def on_data(msg):
            data_received.append(msg)

        # First recv returns subscribe confirmation, second returns data
        responses = [
            b'{"type": "subscribed", "mode": "live", "position": 100}\n',
            b'{"type": "data", "source": "serial", "ts": 1234567890.0, "data": "aGVsbG8="}\n',
            b""  # Empty to signal disconnect
        ]
        mock_socket.recv.side_effect = responses

        try:
            with patch("esppal.client.get_socket_path", return_value=temp_socket_path):
                with patch("esppal.client.get_pid_path", return_value=pid_path):
                    client = ESPalClient(auto_spawn=False)
                    client.subscribe(mode="live", on_data=on_data)

                    # Wait briefly for receive thread
                    time.sleep(0.2)
                    client.disconnect()

                    # Should have received at least one message
                    # (timing dependent, may not always get the data)
        finally:
            if os.path.exists(pid_path):
                os.unlink(pid_path)


class TestBuild:
    """Tests for build commands."""

    def test_build_command(self, mock_socket, temp_socket_path):
        """Build command returns completion."""
        pid_path = temp_socket_path.replace(".sock", ".pid")

        with open(pid_path, "w") as f:
            f.write(str(os.getpid()))

        # Mock responses
        responses = [
            b'{"type": "build_output", "stream": "stdout", "line": "Compiling..."}\n',
            b'{"type": "build_complete", "exit_code": 0, "success": true}\n'
        ]
        mock_socket.recv.side_effect = responses

        try:
            with patch("esppal.client.get_socket_path", return_value=temp_socket_path):
                with patch("esppal.client.get_pid_path", return_value=pid_path):
                    client = ESPalClient(auto_spawn=False)
                    result = client.build(wait=True)

                    assert result is not None
                    assert result.success
                    assert result.exit_code == 0
        finally:
            if os.path.exists(pid_path):
                os.unlink(pid_path)

    def test_build_with_output_callback(self, mock_socket, temp_socket_path):
        """Build with output callback."""
        pid_path = temp_socket_path.replace(".sock", ".pid")

        with open(pid_path, "w") as f:
            f.write(str(os.getpid()))

        outputs = []

        def on_output(msg):
            outputs.append(msg.line)

        responses = [
            b'{"type": "build_output", "stream": "stdout", "line": "Line 1"}\n',
            b'{"type": "build_output", "stream": "stdout", "line": "Line 2"}\n',
            b'{"type": "build_complete", "exit_code": 0, "success": true}\n'
        ]
        mock_socket.recv.side_effect = responses

        try:
            with patch("esppal.client.get_socket_path", return_value=temp_socket_path):
                with patch("esppal.client.get_pid_path", return_value=pid_path):
                    client = ESPalClient(auto_spawn=False)
                    client.build(wait=True, on_output=on_output)

                    assert "Line 1" in outputs
                    assert "Line 2" in outputs
        finally:
            if os.path.exists(pid_path):
                os.unlink(pid_path)


class TestShutdown:
    """Tests for shutdown command."""

    def test_shutdown_command(self, mock_socket, temp_socket_path):
        """Shutdown sends command."""
        pid_path = temp_socket_path.replace(".sock", ".pid")

        with open(pid_path, "w") as f:
            f.write(str(os.getpid()))

        try:
            with patch("esppal.client.get_socket_path", return_value=temp_socket_path):
                with patch("esppal.client.get_pid_path", return_value=pid_path):
                    client = ESPalClient(auto_spawn=False)
                    result = client.shutdown()

                    assert result
                    mock_socket.sendall.assert_called()
        finally:
            if os.path.exists(pid_path):
                os.unlink(pid_path)


class TestPrintHelpers:
    """Tests for print helper functions."""

    def test_print_data_message(self, capsys):
        """print_data_message outputs to stdout."""
        msg = DataMessage(source="serial", timestamp=0, data=b"Hello")
        print_data_message(msg)

        captured = capsys.readouterr()
        assert "Hello" in captured.out

    def test_print_build_output(self, capsys):
        """print_build_output formats correctly."""
        msg = BuildOutputMessage(stream="stdout", line="Compiling...")
        print_build_output(msg)

        captured = capsys.readouterr()
        assert "Compiling..." in captured.out

    def test_print_build_output_stderr(self, capsys):
        """print_build_output adds prefix for stderr."""
        msg = BuildOutputMessage(stream="stderr", line="Error!")
        print_build_output(msg)

        captured = capsys.readouterr()
        assert "[ERR]" in captured.out
        assert "Error!" in captured.out
