"""Unit: smolmachines pty_resize bridge (issue #82).

Locks down the parts of the wrapper we can test without spawning
real children or signalling — argument parsing, the side-channel
`smolvm machine exec` argv shape, and TTY-resolution fallback
across stdin/stdout/stderr.
"""

from __future__ import annotations

import io
import unittest
import unittest.mock
from unittest.mock import patch

from bot_bottle.backend.smolmachines import pty_resize


class TestPushSize(unittest.TestCase):
    def test_emits_for_loop_over_all_pts_devices(self):
        # The shell `for f in /dev/pts/*` handles multiple
        # interactive sessions in the same VM (rare but cheap).
        # Per-PTY `stty -F ... 2>/dev/null` swallows EBADF when a
        # session has already exited.
        with patch.object(pty_resize.subprocess, "run") as run:
            pty_resize._push_size("bot-bottle-m", 50, 200)
        argv = run.call_args.args[0]
        self.assertEqual(
            ["smolvm", "machine", "exec", "--name",
             "bot-bottle-m", "--", "sh", "-c"],
            argv[:8],
        )
        # cols / rows land in the order stty wants them.
        self.assertIn("cols 200", argv[8])
        self.assertIn("rows 50", argv[8])
        self.assertIn("for f in /dev/pts/*", argv[8])

    def test_side_channel_uses_devnull_stdin(self):
        # Load-bearing regression: under tmux, inheriting the
        # pane PTY as the side-channel's stdin makes smolvm crash
        # within ~100ms (concurrent smolvm processes sharing the
        # PTY's FG-PG / input plumbing). DEVNULL stdin sidesteps
        # the interaction.
        with patch.object(pty_resize.subprocess, "run") as run:
            pty_resize._push_size("bot-bottle-m", 24, 80)
        self.assertEqual(
            pty_resize.subprocess.DEVNULL,
            run.call_args.kwargs.get("stdin"),
        )

    def test_swallows_subprocess_failures(self):
        # `check=False` + DEVNULL streams: a side-channel failure
        # mustn't break the operator's session.
        with patch.object(
            pty_resize.subprocess, "run",
            side_effect=OSError("boom"),
        ):
            with self.assertRaises(OSError):
                pty_resize._push_size("m", 24, 80)
        # The wrapper-level `sync()` is what swallows; `_push_size`
        # itself raises so the test above documents that. The
        # signal-handler-side `sync` in main wraps in try/except
        # via the `if size is None: return` guard for the
        # no-TTY case (no separate try needed because subprocess
        # already has check=False; only fcntl.ioctl raising would
        # surface, and _read_winsize handles that).


class TestReadWinsize(unittest.TestCase):
    def test_returns_none_when_no_tty(self):
        # Patch ioctl to always OSError — simulates the case where
        # none of stdin/stdout/stderr is a TTY (e.g., tests, piped
        # automation).
        with patch.object(
            pty_resize.fcntl, "ioctl",
            side_effect=OSError("ENOTTY"),
        ):
            self.assertIsNone(pty_resize._read_winsize())

    def test_returns_first_tty_size(self):
        # First fd that responds with a non-zero size wins —
        # matches the "different surfaces give different TTYs"
        # invariant noted in the module docstring.
        import struct

        calls: list[int] = []

        def fake_ioctl(fd, req, buf):  # type: ignore
            calls.append(fd)
            if fd == 0:
                raise OSError("stdin not a tty")
            return struct.pack("hhhh", 42, 137, 0, 0)

        with patch.object(pty_resize.fcntl, "ioctl", side_effect=fake_ioctl):
            self.assertEqual((42, 137), pty_resize._read_winsize())

    def test_skips_zero_sizes(self):
        # A TTY that reports `0 0` (the smolvm-allocated PTY's
        # initial state, ironically) shouldn't be used as the
        # source of truth — keep probing fallback fds.
        import struct

        responses = iter([
            struct.pack("hhhh", 0, 0, 0, 0),       # stdin: zero
            struct.pack("hhhh", 24, 80, 0, 0),     # stdout: real
        ])

        def fake_ioctl(fd, req, buf):  # type: ignore
            return next(responses)

        with patch.object(pty_resize.fcntl, "ioctl", side_effect=fake_ioctl):
            self.assertEqual((24, 80), pty_resize._read_winsize())


class TestMainArgvParsing(unittest.TestCase):
    def test_missing_separator_returns_error_exit_code(self):
        # No `--` between machine name and inner argv.
        with patch.object(pty_resize.sys, "stderr", new=io.StringIO()) as err:
            rc = pty_resize.main(["bot-bottle-m", "smolvm", "machine"])
        self.assertEqual(2, rc)
        self.assertIn("usage:", err.getvalue())

    def test_too_few_args_returns_error_exit_code(self):
        with patch.object(pty_resize.sys, "stderr", new=io.StringIO()):
            self.assertEqual(2, pty_resize.main([]))
            self.assertEqual(2, pty_resize.main(["m"]))
            self.assertEqual(2, pty_resize.main(["m", "--"]))


class TestStartupSyncDeferred(unittest.TestCase):
    """Regression: the initial sync MUST be deferred (timer), not
    called synchronously between Popen + wait. Calling it
    immediately races libkrun's per-exec OCI config write during
    the main exec's bringup and crashes the child (rc=137 or
    'parse error: trailing garbage')."""

    def test_main_schedules_timer_does_not_call_sync_synchronously(self):
        # Fake Popen + wait so main returns immediately. Patch
        # Timer to record args without spawning a real thread.
        # _push_size patched so any rogue synchronous call would
        # be observable.
        fake_proc = unittest.mock.MagicMock()
        fake_proc.wait.return_value = 0
        with patch.object(
            pty_resize.subprocess, "Popen", return_value=fake_proc,
        ), patch.object(
            pty_resize.threading, "Timer",
        ) as timer_cls, patch.object(
            pty_resize, "_push_size",
        ) as push:
            rc = pty_resize.main(["machine-name", "--", "echo", "hi"])

        self.assertEqual(0, rc)
        # Timer scheduled with the documented delay constant.
        timer_cls.assert_called_once()
        delay, callback = timer_cls.call_args.args  # type: ignore
        self.assertEqual(pty_resize._STARTUP_SYNC_DELAY_SEC, delay)
        # _push_size never called synchronously — the only path to
        # it is via the (mocked) timer's callback firing.
        push.assert_not_called()


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