"""Tests for crank_cli.client — run with: python -m pytest tests/test_client.py"""

import os
import socket
import tempfile
import threading
import unittest
from unittest.mock import patch

from crank_cli.client import main


def _fake_server(sock_path, response, accept_event=None):
    """Run a minimal Unix socket server that sends a canned response."""
    srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
    srv.bind(str(sock_path))
    srv.listen(1)
    if accept_event:
        accept_event.set()
    conn, _ = srv.accept()
    # Read the command (we don't care what it is)
    conn.recv(4096)
    conn.sendall(response.encode())
    conn.close()
    srv.close()


class TestClientMain(unittest.TestCase):
    def test_too_few_args(self):
        with patch("sys.argv", ["crankc"]):
            with self.assertRaises(SystemExit) as ctx:
                main()
            self.assertEqual(ctx.exception.code, 1)

    def test_unknown_command(self):
        with patch("sys.argv", ["crankc", "badcmd", "hello"]):
            with self.assertRaises(SystemExit) as ctx:
                main()
            self.assertEqual(ctx.exception.code, 1)

    def test_socket_not_found(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            with patch("sys.argv", ["crankc", "run", "hello"]):
                with patch.dict("os.environ", {"CRANK_HOME": tmpdir}, clear=False):
                    with self.assertRaises(SystemExit) as ctx:
                        main()
                    self.assertEqual(ctx.exception.code, 1)

    def test_ok_response_exits_0(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            sock_path = os.path.join(tmpdir, "crankd.sock")
            ready = threading.Event()
            t = threading.Thread(target=_fake_server, args=(sock_path, "OK 1\n", ready), daemon=True)
            t.start()
            ready.wait(timeout=5)

            with patch("sys.argv", ["crankc", "run", "hello"]):
                with patch.dict("os.environ", {"CRANK_HOME": tmpdir}, clear=False):
                    with self.assertRaises(SystemExit) as ctx:
                        main()
                    self.assertEqual(ctx.exception.code, 0)

    def test_err_response_exits_1(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            sock_path = os.path.join(tmpdir, "crankd.sock")
            ready = threading.Event()
            t = threading.Thread(target=_fake_server, args=(sock_path, "ERR job not found\n", ready), daemon=True)
            t.start()
            ready.wait(timeout=5)

            with patch("sys.argv", ["crankc", "queue", "nope"]):
                with patch.dict("os.environ", {"CRANK_HOME": tmpdir}, clear=False):
                    with self.assertRaises(SystemExit) as ctx:
                        main()
                    self.assertEqual(ctx.exception.code, 1)

    def test_command_line_formatting(self):
        """Verify the protocol line is correctly formatted from argv."""
        with tempfile.TemporaryDirectory() as tmpdir:
            sock_path = os.path.join(tmpdir, "crankd.sock")
            received = []
            ready = threading.Event()

            def capture_server(path, resp):
                srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
                srv.bind(str(path))
                srv.listen(1)
                ready.set()
                conn, _ = srv.accept()
                data = conn.recv(4096)
                received.append(data.decode())
                conn.sendall(resp.encode())
                conn.close()
                srv.close()

            t = threading.Thread(target=capture_server, args=(sock_path, "OK 1\n"), daemon=True)
            t.start()
            ready.wait(timeout=5)

            with patch("sys.argv", ["crankc", "run", "myjob", "FOO=bar"]):
                with patch.dict("os.environ", {"CRANK_HOME": tmpdir}, clear=False):
                    with self.assertRaises(SystemExit):
                        main()

            self.assertEqual(len(received), 1)
            self.assertEqual(received[0].strip(), "run\tmyjob\tFOO=bar")

    # ---------- Bug C1: ERR response parsing ----------

    def test_err_with_build_number_and_status(self):
        """ERR '5 failed' should be formatted as 'build #5 failed'."""
        with tempfile.TemporaryDirectory() as tmpdir:
            sock_path = os.path.join(tmpdir, "crankd.sock")
            ready = threading.Event()
            t = threading.Thread(
                target=_fake_server, args=(sock_path, "ERR 5 failed\n", ready), daemon=True
            )
            t.start()
            ready.wait(timeout=5)

            with patch("sys.argv", ["crankc", "run", "hello"]):
                with patch.dict("os.environ", {"CRANK_HOME": tmpdir}, clear=False):
                    with patch("sys.stderr") as mock_stderr:
                        with self.assertRaises(SystemExit) as ctx:
                            main()
                        self.assertEqual(ctx.exception.code, 1)
                        # Check that the formatted message was written
                        output = "".join(
                            call.args[0] for call in mock_stderr.write.call_args_list
                        )
                        self.assertIn("build #5 failed", output)

    def test_err_without_build_number(self):
        """ERR 'job not found' should pass through as-is."""
        with tempfile.TemporaryDirectory() as tmpdir:
            sock_path = os.path.join(tmpdir, "crankd.sock")
            ready = threading.Event()
            t = threading.Thread(
                target=_fake_server, args=(sock_path, "ERR job not found\n", ready), daemon=True
            )
            t.start()
            ready.wait(timeout=5)

            with patch("sys.argv", ["crankc", "run", "hello"]):
                with patch.dict("os.environ", {"CRANK_HOME": tmpdir}, clear=False):
                    with patch("sys.stderr") as mock_stderr:
                        with self.assertRaises(SystemExit) as ctx:
                            main()
                        self.assertEqual(ctx.exception.code, 1)
                        output = "".join(
                            call.args[0] for call in mock_stderr.write.call_args_list
                        )
                        self.assertIn("job not found", output)
                        # Should NOT contain "build #" formatting
                        self.assertNotIn("build #", output)

    def test_unexpected_response(self):
        """A response that starts with neither OK nor ERR should say 'unexpected'."""
        with tempfile.TemporaryDirectory() as tmpdir:
            sock_path = os.path.join(tmpdir, "crankd.sock")
            ready = threading.Event()
            t = threading.Thread(
                target=_fake_server, args=(sock_path, "WEIRD stuff\n", ready), daemon=True
            )
            t.start()
            ready.wait(timeout=5)

            with patch("sys.argv", ["crankc", "run", "hello"]):
                with patch.dict("os.environ", {"CRANK_HOME": tmpdir}, clear=False):
                    with patch("sys.stderr") as mock_stderr:
                        with self.assertRaises(SystemExit) as ctx:
                            main()
                        self.assertEqual(ctx.exception.code, 1)
                        output = "".join(
                            call.args[0] for call in mock_stderr.write.call_args_list
                        )
                        self.assertIn("unexpected response", output)

    # ---------- Bug C2: ConnectionRefusedError ----------

    def test_connection_refused(self):
        """A socket that exists but has no listener should give a clear message."""
        with tempfile.TemporaryDirectory() as tmpdir:
            sock_path = os.path.join(tmpdir, "crankd.sock")
            # Create a socket, bind, listen, then close — leaves a stale socket file
            srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
            srv.bind(sock_path)
            srv.listen(1)
            srv.close()
            # The socket file still exists, but nobody is listening

            with patch("sys.argv", ["crankc", "run", "hello"]):
                with patch.dict("os.environ", {"CRANK_HOME": tmpdir}, clear=False):
                    with patch("sys.stderr") as mock_stderr:
                        with self.assertRaises(SystemExit) as ctx:
                            main()
                        self.assertEqual(ctx.exception.code, 1)
                        output = "".join(
                            call.args[0] for call in mock_stderr.write.call_args_list
                        )
                        self.assertIn("connection refused", output)

    # ---------- Bug C3: PermissionError ----------

    def test_permission_denied(self):
        """Mock a PermissionError on connect to verify the error message."""
        with tempfile.TemporaryDirectory() as tmpdir:
            with patch("sys.argv", ["crankc", "run", "hello"]):
                with patch.dict("os.environ", {"CRANK_HOME": tmpdir}, clear=False):
                    with patch("socket.socket") as mock_socket_cls:
                        mock_sock = mock_socket_cls.return_value
                        mock_sock.connect.side_effect = PermissionError("Permission denied")

                        with patch("sys.stderr") as mock_stderr:
                            with self.assertRaises(SystemExit) as ctx:
                                main()
                            self.assertEqual(ctx.exception.code, 1)
                            output = "".join(
                                call.args[0] for call in mock_stderr.write.call_args_list
                            )
                            self.assertIn("permission denied", output)
                            self.assertIn("crank", output)

    # ---------- Bug C4: SIGPIPE handling ----------

    def test_sigpipe_handler_is_set(self):
        """Verify that SIGPIPE is set to SIG_DFL at the start of main()."""
        import signal

        with patch("signal.signal") as mock_signal:
            with patch("sys.argv", ["crankc"]):
                with self.assertRaises(SystemExit):
                    main()
            # signal.signal should have been called with SIGPIPE, SIG_DFL
            mock_signal.assert_any_call(signal.SIGPIPE, signal.SIG_DFL)


    # ---------- Fix: SIGPIPE guard uses hasattr ----------

    def test_sigpipe_guard_uses_hasattr(self):
        """On platforms without SIGPIPE (e.g., Windows), the hasattr check
        prevents AttributeError. Simulate by removing SIGPIPE from signal."""
        import signal

        original = getattr(signal, "SIGPIPE", None)
        try:
            if hasattr(signal, "SIGPIPE"):
                delattr(signal, "SIGPIPE")
            # Should not raise even when SIGPIPE doesn't exist
            with patch("sys.argv", ["crankc"]):
                with self.assertRaises(SystemExit) as ctx:
                    main()
                self.assertEqual(ctx.exception.code, 1)
        finally:
            if original is not None:
                signal.SIGPIPE = original

    # ---------- Fix: KeyboardInterrupt during recv ----------

    def test_keyboard_interrupt_during_recv_gives_clean_exit(self):
        """KeyboardInterrupt during recv should exit with code 130."""
        with tempfile.TemporaryDirectory() as tmpdir:
            sock_path = os.path.join(tmpdir, "crankd.sock")
            ready = threading.Event()

            def slow_server(path):
                srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
                srv.bind(str(path))
                srv.listen(1)
                ready.set()
                conn, _ = srv.accept()
                conn.recv(4096)
                # Don't send anything — let client block on recv
                import time
                time.sleep(5)
                conn.close()
                srv.close()

            t = threading.Thread(target=slow_server, args=(sock_path,), daemon=True)
            t.start()
            ready.wait(timeout=5)

            with patch("sys.argv", ["crankc", "run", "hello"]):
                with patch.dict("os.environ", {"CRANK_HOME": tmpdir}, clear=False):
                    with patch("socket.socket") as mock_socket_cls:
                        mock_sock = mock_socket_cls.return_value
                        mock_sock.connect = lambda _: None
                        mock_sock.sendall = lambda _: None
                        mock_sock.shutdown = lambda _: None
                        mock_sock.recv.side_effect = KeyboardInterrupt()

                        with self.assertRaises(SystemExit) as ctx:
                            main()
                        self.assertEqual(ctx.exception.code, 130)

    # ---------- Fix: Response size cap (MAX_RESPONSE) ----------

    def test_response_size_cap(self):
        """Response larger than MAX_RESPONSE should cause exit with 'too large' error."""
        from crank_cli.client import MAX_RESPONSE

        with tempfile.TemporaryDirectory() as tmpdir:
            sock_path = os.path.join(tmpdir, "crankd.sock")
            ready = threading.Event()

            def big_response_server(path):
                srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
                srv.bind(str(path))
                srv.listen(1)
                ready.set()
                conn, _ = srv.accept()
                conn.recv(4096)
                # Send data exceeding MAX_RESPONSE
                chunk = b"X" * 65536
                sent = 0
                try:
                    while sent < MAX_RESPONSE + 65536:
                        conn.sendall(chunk)
                        sent += len(chunk)
                except (BrokenPipeError, OSError):
                    pass
                conn.close()
                srv.close()

            t = threading.Thread(target=big_response_server, args=(sock_path,), daemon=True)
            t.start()
            ready.wait(timeout=5)

            with patch("sys.argv", ["crankc", "run", "hello"]):
                with patch.dict("os.environ", {"CRANK_HOME": tmpdir}, clear=False):
                    with self.assertRaises(SystemExit) as ctx:
                        main()
                    self.assertEqual(ctx.exception.code, 1)

    # ---------- Fix: OK exact match works (not just startswith) ----------

    def test_ok_exact_match_no_payload(self):
        """Bare 'OK' with no trailing text should exit 0 silently."""
        with tempfile.TemporaryDirectory() as tmpdir:
            sock_path = os.path.join(tmpdir, "crankd.sock")
            ready = threading.Event()
            t = threading.Thread(
                target=_fake_server, args=(sock_path, "OK\n", ready), daemon=True
            )
            t.start()
            ready.wait(timeout=5)

            with patch("sys.argv", ["crankc", "run", "hello"]):
                with patch.dict("os.environ", {"CRANK_HOME": tmpdir}, clear=False):
                    with self.assertRaises(SystemExit) as ctx:
                        main()
                    self.assertEqual(ctx.exception.code, 0)

    def test_ok_with_payload_prints_it(self):
        """'OK 42' should print '42' to stdout and exit 0."""
        with tempfile.TemporaryDirectory() as tmpdir:
            sock_path = os.path.join(tmpdir, "crankd.sock")
            ready = threading.Event()
            t = threading.Thread(
                target=_fake_server, args=(sock_path, "OK 42\n", ready), daemon=True
            )
            t.start()
            ready.wait(timeout=5)

            import io
            with patch("sys.argv", ["crankc", "run", "hello"]):
                with patch.dict("os.environ", {"CRANK_HOME": tmpdir}, clear=False):
                    with patch("sys.stdout", new=io.StringIO()) as fake_out:
                        with self.assertRaises(SystemExit) as ctx:
                            main()
                        self.assertEqual(ctx.exception.code, 0)
                        self.assertIn("42", fake_out.getvalue())

    # ---------- Fix: ERR exact match works ----------

    def test_err_exact_match_no_message(self):
        """Bare 'ERR' with no trailing text should say 'unknown error'."""
        with tempfile.TemporaryDirectory() as tmpdir:
            sock_path = os.path.join(tmpdir, "crankd.sock")
            ready = threading.Event()
            t = threading.Thread(
                target=_fake_server, args=(sock_path, "ERR\n", ready), daemon=True
            )
            t.start()
            ready.wait(timeout=5)

            with patch("sys.argv", ["crankc", "run", "hello"]):
                with patch.dict("os.environ", {"CRANK_HOME": tmpdir}, clear=False):
                    with patch("sys.stderr") as mock_stderr:
                        with self.assertRaises(SystemExit) as ctx:
                            main()
                        self.assertEqual(ctx.exception.code, 1)
                        output = "".join(
                            call.args[0] for call in mock_stderr.write.call_args_list
                        )
                        self.assertIn("unknown error", output)

    def test_err_with_space_only_message(self):
        """'ERR ' with only trailing whitespace should say 'unknown error'."""
        with tempfile.TemporaryDirectory() as tmpdir:
            sock_path = os.path.join(tmpdir, "crankd.sock")
            ready = threading.Event()
            t = threading.Thread(
                target=_fake_server, args=(sock_path, "ERR \n", ready), daemon=True
            )
            t.start()
            ready.wait(timeout=5)

            with patch("sys.argv", ["crankc", "run", "hello"]):
                with patch.dict("os.environ", {"CRANK_HOME": tmpdir}, clear=False):
                    with patch("sys.stderr") as mock_stderr:
                        with self.assertRaises(SystemExit) as ctx:
                            main()
                        self.assertEqual(ctx.exception.code, 1)
                        output = "".join(
                            call.args[0] for call in mock_stderr.write.call_args_list
                        )
                        self.assertIn("unknown error", output)

    # ---------- Fix: Tab-separated protocol format ----------

    def test_tab_separated_protocol_with_spaces_in_values(self):
        """Env values containing spaces should be preserved via tab separation."""
        with tempfile.TemporaryDirectory() as tmpdir:
            sock_path = os.path.join(tmpdir, "crankd.sock")
            received = []
            ready = threading.Event()

            def capture_server(path, resp):
                srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
                srv.bind(str(path))
                srv.listen(1)
                ready.set()
                conn, _ = srv.accept()
                data = conn.recv(4096)
                received.append(data.decode())
                conn.sendall(resp.encode())
                conn.close()
                srv.close()

            t = threading.Thread(
                target=capture_server, args=(sock_path, "OK 1\n"), daemon=True
            )
            t.start()
            ready.wait(timeout=5)

            with patch("sys.argv", ["crankc", "run", "myjob", "MSG=hello world"]):
                with patch.dict("os.environ", {"CRANK_HOME": tmpdir}, clear=False):
                    with self.assertRaises(SystemExit):
                        main()

            self.assertEqual(len(received), 1)
            line = received[0].strip()
            parts = line.split("\t")
            self.assertEqual(parts[0], "run")
            self.assertEqual(parts[1], "myjob")
            self.assertEqual(parts[2], "MSG=hello world")


if __name__ == "__main__":
    unittest.main()
