"""Tests for client CLI: argument parsing, socket communication, output streaming."""

import asyncio
import json
import struct
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import sys

from rpipe.client import main
from rpipe.protocol import (
    pack_frame,
    TYPE_META,
    TYPE_STDOUT,
    TYPE_STDERR,
    TYPE_EXIT,
    TYPE_ERROR,
)


class TestClientArgParsing:
    """Test CLI argument parsing."""

    @pytest.mark.asyncio
    async def test_client_requires_host_and_cmd(self):
        """Client requires host and command arguments."""
        with patch("sys.argv", ["rpipe"]):
            with pytest.raises(SystemExit):
                await main()

    @pytest.mark.asyncio
    async def test_client_parses_host_and_cmd(self):
        """Client parses host and command."""
        with patch("sys.argv", ["rpipe", "example.com", "echo", "hello"]):
            with patch("asyncio.open_unix_connection") as mock_conn:
                mock_reader = AsyncMock()
                mock_writer = AsyncMock()
                mock_conn.return_value = (mock_reader, mock_writer)

                # Mock socket file existence
                with patch("pathlib.Path.exists", return_value=True):
                    # Mock read_frame to return META then EXIT
                    async def mock_read(*args):
                        if not hasattr(mock_read, "call_count"):
                            mock_read.call_count = 0
                        mock_read.call_count += 1

                        if mock_read.call_count == 1:
                            # Return META frame
                            return (TYPE_META, 0, json.dumps({"run_id": 1}).encode())
                        else:
                            # Return EXIT frame
                            return (TYPE_EXIT, 1, struct.pack("!i", 0))

                    with patch("rpipe.client.read_frame", side_effect=mock_read):
                        try:
                            await main()
                        except SystemExit:
                            pass  # Expected

    @pytest.mark.asyncio
    async def test_client_parses_cwd_option(self):
        """Client parses --cwd option without error."""
        with patch("sys.argv", ["rpipe", "example.com", "--cwd", "/tmp", "pwd"]):
            with patch("asyncio.open_unix_connection") as mock_conn:
                mock_reader = AsyncMock()
                mock_writer = AsyncMock()
                mock_conn.return_value = (mock_reader, mock_writer)

                with patch("pathlib.Path.exists", return_value=True):
                    async def mock_read(*args):
                        if not hasattr(mock_read, "call_count"):
                            mock_read.call_count = 0
                        mock_read.call_count += 1
                        if mock_read.call_count == 1:
                            return (TYPE_META, 0, json.dumps({"run_id": 1}).encode())
                        else:
                            return (TYPE_EXIT, 1, struct.pack("!i", 0))

                    with patch("rpipe.client.read_frame", side_effect=mock_read):
                        try:
                            await main()
                        except SystemExit:
                            pass
                        # Client should complete without error

    @pytest.mark.asyncio
    async def test_client_parses_env_option(self):
        """Client parses --env option without error."""
        with patch("sys.argv", ["rpipe", "example.com", "--env", "FOO=bar", "--env", "BAZ=qux", "echo"]):
            with patch("asyncio.open_unix_connection") as mock_conn:
                mock_reader = AsyncMock()
                mock_writer = AsyncMock()
                mock_conn.return_value = (mock_reader, mock_writer)

                with patch("pathlib.Path.exists", return_value=True):
                    async def mock_read(*args):
                        if not hasattr(mock_read, "call_count"):
                            mock_read.call_count = 0
                        mock_read.call_count += 1
                        if mock_read.call_count == 1:
                            return (TYPE_META, 0, json.dumps({"run_id": 1}).encode())
                        else:
                            return (TYPE_EXIT, 1, struct.pack("!i", 0))

                    with patch("rpipe.client.read_frame", side_effect=mock_read):
                        try:
                            await main()
                        except SystemExit:
                            pass
                        # Client should complete without error

    @pytest.mark.asyncio
    async def test_client_parses_stdin_option(self):
        """Client parses --stdin option without error."""
        with patch("sys.argv", ["rpipe", "example.com", "--stdin", "cat"]):
            with patch("asyncio.open_unix_connection") as mock_conn:
                mock_reader = AsyncMock()
                mock_writer = AsyncMock()
                mock_conn.return_value = (mock_reader, mock_writer)

                with patch("pathlib.Path.exists", return_value=True):
                    async def mock_read(*args):
                        if not hasattr(mock_read, "call_count"):
                            mock_read.call_count = 0
                        mock_read.call_count += 1
                        if mock_read.call_count == 1:
                            return (TYPE_META, 0, json.dumps({"run_id": 1}).encode())
                        else:
                            return (TYPE_EXIT, 1, struct.pack("!i", 0))

                    with patch("rpipe.client.read_frame", side_effect=mock_read):
                        try:
                            await main()
                        except SystemExit:
                            pass
                        # Client should complete without error

    @pytest.mark.asyncio
    async def test_client_parses_timeout_option(self):
        """Client parses --timeout option without error."""
        with patch("sys.argv", ["rpipe", "example.com", "--timeout", "30", "sleep", "1"]):
            with patch("asyncio.open_unix_connection") as mock_conn:
                mock_reader = AsyncMock()
                mock_writer = AsyncMock()
                mock_conn.return_value = (mock_reader, mock_writer)

                with patch("pathlib.Path.exists", return_value=True):
                    async def mock_read(*args):
                        if not hasattr(mock_read, "call_count"):
                            mock_read.call_count = 0
                        mock_read.call_count += 1
                        if mock_read.call_count == 1:
                            return (TYPE_META, 0, json.dumps({"run_id": 1}).encode())
                        else:
                            return (TYPE_EXIT, 1, struct.pack("!i", 0))

                    with patch("rpipe.client.read_frame", side_effect=mock_read):
                        try:
                            await main()
                        except SystemExit:
                            pass
                        # Client should complete without error

    @pytest.mark.asyncio
    async def test_client_invalid_env_format(self):
        """Client rejects --env without = separator."""
        with patch("sys.argv", ["rpipe", "example.com", "--env", "NOEQUALSSIGN", "echo"]):
            with pytest.raises(SystemExit):
                await main()


class TestClientSocketConnection:
    """Test socket connection and communication."""

    @pytest.mark.asyncio
    async def test_client_fails_if_socket_not_found(self):
        """Client should exit if daemon socket not found."""
        with patch("sys.argv", ["rpipe", "example.com", "echo"]):
            with patch("pathlib.Path.exists", return_value=False):
                with pytest.raises(SystemExit):
                    await main()

    @pytest.mark.asyncio
    async def test_client_fails_if_connection_refused(self):
        """Client should exit if connection fails."""
        with patch("sys.argv", ["rpipe", "example.com", "echo"]):
            with patch("pathlib.Path.exists", return_value=True):
                with patch("asyncio.open_unix_connection") as mock_conn:
                    mock_conn.side_effect = ConnectionRefusedError("Connection refused")
                    with pytest.raises(SystemExit):
                        await main()

    @pytest.mark.asyncio
    async def test_client_sends_run_req(self):
        """Client should send RUN_REQ frame."""
        with patch("sys.argv", ["rpipe", "example.com", "echo", "hello"]):
            with patch("asyncio.open_unix_connection") as mock_conn:
                mock_reader = AsyncMock()
                mock_writer = AsyncMock()
                mock_conn.return_value = (mock_reader, mock_writer)

                with patch("pathlib.Path.exists", return_value=True):
                    async def mock_read(*args):
                        if not hasattr(mock_read, "call_count"):
                            mock_read.call_count = 0
                        mock_read.call_count += 1
                        if mock_read.call_count == 1:
                            return (TYPE_META, 0, json.dumps({"run_id": 1}).encode())
                        else:
                            return (TYPE_EXIT, 1, struct.pack("!i", 0))

                    with patch("rpipe.client.read_frame", side_effect=mock_read):
                        try:
                            await main()
                        except SystemExit:
                            pass

                        # Verify RUN_REQ was written
                        assert mock_writer.write.called
                        frame_data = mock_writer.write.call_args[0][0]
                        # Frame type at byte 4
                        assert frame_data[4] == 10  # TYPE_RUN_REQ


class TestClientOutputStreaming:
    """Test output handling."""

    @pytest.mark.asyncio
    async def test_client_outputs_stdout(self):
        """Client should output STDOUT frames."""
        with patch("sys.argv", ["rpipe", "example.com", "echo", "hello"]):
            with patch("asyncio.open_unix_connection") as mock_conn:
                mock_reader = AsyncMock()
                mock_writer = AsyncMock()
                mock_conn.return_value = (mock_reader, mock_writer)

                with patch("pathlib.Path.exists", return_value=True):
                    call_count = [0]

                    async def mock_read(*args):
                        call_count[0] += 1
                        if call_count[0] == 1:
                            return (TYPE_META, 0, json.dumps({"run_id": 1}).encode())
                        elif call_count[0] == 2:
                            return (TYPE_STDOUT, 1, b"hello world")
                        else:
                            return (TYPE_EXIT, 1, struct.pack("!i", 0))

                    with patch("rpipe.client.read_frame", side_effect=mock_read):
                        with patch("sys.stdout.buffer.write") as mock_stdout:
                            try:
                                await main()
                            except SystemExit:
                                pass

                            # Verify stdout was written
                            mock_stdout.assert_called()

    @pytest.mark.asyncio
    async def test_client_outputs_stderr(self):
        """Client should output STDERR frames."""
        with patch("sys.argv", ["rpipe", "example.com", "echo", "error"]):
            with patch("asyncio.open_unix_connection") as mock_conn:
                mock_reader = AsyncMock()
                mock_writer = AsyncMock()
                mock_conn.return_value = (mock_reader, mock_writer)

                with patch("pathlib.Path.exists", return_value=True):
                    call_count = [0]

                    async def mock_read(*args):
                        call_count[0] += 1
                        if call_count[0] == 1:
                            return (TYPE_META, 0, json.dumps({"run_id": 1}).encode())
                        elif call_count[0] == 2:
                            return (TYPE_STDERR, 1, b"error message")
                        else:
                            return (TYPE_EXIT, 1, struct.pack("!i", 0))

                    with patch("rpipe.client.read_frame", side_effect=mock_read):
                        with patch("sys.stderr.buffer.write") as mock_stderr:
                            try:
                                await main()
                            except SystemExit:
                                pass

                            # Verify stderr was written
                            mock_stderr.assert_called()

    @pytest.mark.asyncio
    async def test_client_propagates_exit_code(self):
        """Client should exit with remote process exit code."""
        with patch("sys.argv", ["rpipe", "example.com", "false"]):
            with patch("asyncio.open_unix_connection") as mock_conn:
                mock_reader = AsyncMock()
                mock_writer = AsyncMock()
                mock_conn.return_value = (mock_reader, mock_writer)

                with patch("pathlib.Path.exists", return_value=True):
                    call_count = [0]

                    async def mock_read(*args):
                        call_count[0] += 1
                        if call_count[0] == 1:
                            return (TYPE_META, 0, json.dumps({"run_id": 1}).encode())
                        else:
                            return (TYPE_EXIT, 1, struct.pack("!i", 42))

                    with patch("rpipe.client.read_frame", side_effect=mock_read):
                        with pytest.raises(SystemExit) as exc_info:
                            await main()
                        assert exc_info.value.code == 42

    @pytest.mark.asyncio
    async def test_client_handles_error_frame(self):
        """Client should handle ERROR frames."""
        with patch("sys.argv", ["rpipe", "example.com", "bad_command"]):
            with patch("asyncio.open_unix_connection") as mock_conn:
                mock_reader = AsyncMock()
                mock_writer = AsyncMock()
                mock_conn.return_value = (mock_reader, mock_writer)

                with patch("pathlib.Path.exists", return_value=True):
                    call_count = [0]

                    async def mock_read(*args):
                        call_count[0] += 1
                        if call_count[0] == 1:
                            return (TYPE_META, 0, json.dumps({"run_id": 1}).encode())
                        elif call_count[0] == 2:
                            return (TYPE_ERROR, 1, json.dumps({"error": "command failed"}).encode())
                        else:
                            return (TYPE_EXIT, 1, struct.pack("!i", 1))

                    with patch("rpipe.client.read_frame", side_effect=mock_read):
                        with patch("sys.stderr.write") as mock_stderr:
                            try:
                                await main()
                            except SystemExit:
                                pass

                            # Verify error was printed
                            mock_stderr.assert_called()

    @pytest.mark.asyncio
    async def test_client_communication_error(self):
        """Client should handle communication errors gracefully."""
        with patch("sys.argv", ["rpipe", "example.com", "echo"]):
            with patch("asyncio.open_unix_connection") as mock_conn:
                mock_reader = AsyncMock()
                mock_writer = AsyncMock()
                mock_conn.return_value = (mock_reader, mock_writer)

                with patch("pathlib.Path.exists", return_value=True):
                    with patch("rpipe.client.read_frame") as mock_read_frame:
                        mock_read_frame.side_effect = Exception("Connection lost")

                        with pytest.raises(SystemExit):
                            await main()


class TestClientStdinPumping:
    """Test stdin pumping."""

    @pytest.mark.asyncio
    async def test_client_pumps_stdin_when_enabled(self):
        """Client should pump stdin to daemon when --stdin enabled."""
        with patch("sys.argv", ["rpipe", "example.com", "--stdin", "cat"]):
            with patch("asyncio.open_unix_connection") as mock_conn:
                mock_reader = AsyncMock()
                mock_writer = AsyncMock()
                mock_conn.return_value = (mock_reader, mock_writer)

                with patch("pathlib.Path.exists", return_value=True):
                    call_count = [0]

                    async def mock_read(*args):
                        call_count[0] += 1
                        if call_count[0] == 1:
                            return (TYPE_META, 0, json.dumps({"run_id": 1}).encode())
                        else:
                            return (TYPE_EXIT, 1, struct.pack("!i", 0))

                    with patch("rpipe.client.read_frame", side_effect=mock_read):
                        # Mock stdin
                        with patch("sys.stdin.buffer.read") as mock_stdin:
                            mock_stdin.return_value = b"test input"
                            try:
                                await main()
                            except SystemExit:
                                pass

                            # stdin_task should have been created
                            # (verification is indirect through no exception)

    @pytest.mark.asyncio
    async def test_client_does_not_pump_stdin_when_disabled(self):
        """Client should not pump stdin when --stdin not enabled."""
        with patch("sys.argv", ["rpipe", "example.com", "echo"]):
            with patch("asyncio.open_unix_connection") as mock_conn:
                mock_reader = AsyncMock()
                mock_writer = AsyncMock()
                mock_conn.return_value = (mock_reader, mock_writer)

                with patch("pathlib.Path.exists", return_value=True):
                    call_count = [0]

                    async def mock_read(*args):
                        call_count[0] += 1
                        if call_count[0] == 1:
                            return (TYPE_META, 0, json.dumps({"run_id": 1}).encode())
                        else:
                            return (TYPE_EXIT, 1, struct.pack("!i", 0))

                    with patch("rpipe.client.read_frame", side_effect=mock_read):
                        try:
                            await main()
                        except SystemExit:
                            pass

                        # stdin task should be None
                        # (no stdin pumping task created)
