"""Tests for sampling profiler CLI argument parsing and functionality."""

import io
import json
import os
import subprocess
import sys
import tempfile
import unittest
from unittest import mock

try:
    import _remote_debugging  # noqa: F401
except ImportError:
    raise unittest.SkipTest(
        "Test only runs when _remote_debugging is available"
    )

from test.support import (
    force_not_colorized,
    is_emscripten,
    requires_remote_subprocess_debugging,
)

from profiling.sampling.cli import (
    FORMAT_EXTENSIONS,
    _create_collector,
    _generate_output_filename,
    main,
)
from profiling.sampling.constants import (
    PROFILING_MODE_ALL,
    PROFILING_MODE_CPU,
    PROFILING_MODE_WALL,
)
from profiling.sampling.errors import SamplingScriptNotFoundError, SamplingModuleNotFoundError, SamplingUnknownProcessError
from profiling.sampling.jsonl_collector import JsonlCollector

class TestSampleProfilerCLI(unittest.TestCase):
    def _setup_sync_mocks(self, mock_socket, mock_popen):
        """Helper to set up socket and process mocks for coordinator tests."""
        # Mock the sync socket with context manager support
        mock_sock_instance = mock.MagicMock()
        mock_sock_instance.getsockname.return_value = ("127.0.0.1", 12345)

        # Mock the connection with context manager support
        mock_conn = mock.MagicMock()
        mock_conn.recv.return_value = b"ready"
        mock_conn.__enter__.return_value = mock_conn
        mock_conn.__exit__.return_value = None

        # Mock accept() to return (connection, address) and support indexing
        mock_accept_result = mock.MagicMock()
        mock_accept_result.__getitem__.return_value = (
            mock_conn  # [0] returns the connection
        )
        mock_sock_instance.accept.return_value = mock_accept_result

        # Mock socket with context manager support
        mock_sock_instance.__enter__.return_value = mock_sock_instance
        mock_sock_instance.__exit__.return_value = None
        mock_socket.return_value = mock_sock_instance

        # Mock the subprocess
        mock_process = mock.MagicMock()
        mock_process.pid = 12345
        mock_process.poll.return_value = None
        mock_popen.return_value = mock_process
        return mock_process

    def _verify_coordinator_command(self, mock_popen, expected_target_args):
        """Helper to verify the coordinator command was called correctly."""
        args, kwargs = mock_popen.call_args
        coordinator_cmd = args[0]
        self.assertEqual(coordinator_cmd[0], sys.executable)
        self.assertEqual(coordinator_cmd[1], "-m")
        self.assertEqual(
            coordinator_cmd[2], "profiling.sampling._sync_coordinator"
        )
        self.assertEqual(coordinator_cmd[3], "12345")  # port
        # cwd is coordinator_cmd[4]
        self.assertEqual(coordinator_cmd[5:], expected_target_args)

    @unittest.skipIf(is_emscripten, "socket.SO_REUSEADDR does not exist")
    @requires_remote_subprocess_debugging()
    def test_cli_module_argument_parsing(self):
        test_args = ["profiling.sampling.cli", "run", "-m", "mymodule"]

        with (
            mock.patch("sys.argv", test_args),
            mock.patch("profiling.sampling.cli.sample") as mock_sample,
            mock.patch("subprocess.Popen") as mock_popen,
            mock.patch("socket.socket") as mock_socket,
            mock.patch("profiling.sampling.cli._wait_for_ready_signal"),
            mock.patch("importlib.util.find_spec", return_value=True),
        ):
            self._setup_sync_mocks(mock_socket, mock_popen)
            main()

            self._verify_coordinator_command(mock_popen, ("-m", "mymodule"))
            # Verify sample was called once (exact arguments will vary with the new API)
            mock_sample.assert_called_once()

    @unittest.skipIf(is_emscripten, "socket.SO_REUSEADDR does not exist")
    @requires_remote_subprocess_debugging()
    def test_cli_module_with_arguments(self):
        test_args = [
            "profiling.sampling.cli",
            "run",
            "-m",
            "mymodule",
            "arg1",
            "arg2",
            "--flag",
        ]

        with (
            mock.patch("sys.argv", test_args),
            mock.patch("profiling.sampling.cli.sample") as mock_sample,
            mock.patch("subprocess.Popen") as mock_popen,
            mock.patch("socket.socket") as mock_socket,
            mock.patch("profiling.sampling.cli._wait_for_ready_signal"),
            mock.patch("importlib.util.find_spec", return_value=True),
        ):
            self._setup_sync_mocks(mock_socket, mock_popen)
            main()

            self._verify_coordinator_command(
                mock_popen, ("-m", "mymodule", "arg1", "arg2", "--flag")
            )
            mock_sample.assert_called_once()

    @unittest.skipIf(is_emscripten, "socket.SO_REUSEADDR does not exist")
    def test_cli_script_argument_parsing(self):
        test_args = ["profiling.sampling.cli", "run", "myscript.py"]

        with (
            mock.patch("sys.argv", test_args),
            mock.patch("profiling.sampling.cli.sample") as mock_sample,
            mock.patch("subprocess.Popen") as mock_popen,
            mock.patch("socket.socket") as mock_socket,
            mock.patch("profiling.sampling.cli._wait_for_ready_signal"),
            mock.patch("os.path.exists", return_value=True),
        ):
            self._setup_sync_mocks(mock_socket, mock_popen)
            main()

            self._verify_coordinator_command(mock_popen, ("myscript.py",))
            mock_sample.assert_called_once()

    @unittest.skipIf(is_emscripten, "socket.SO_REUSEADDR does not exist")
    def test_cli_script_with_arguments(self):
        test_args = [
            "profiling.sampling.cli",
            "run",
            "myscript.py",
            "arg1",
            "arg2",
            "--flag",
        ]

        with (
            mock.patch("sys.argv", test_args),
            mock.patch("profiling.sampling.cli.sample") as mock_sample,
            mock.patch("subprocess.Popen") as mock_popen,
            mock.patch("socket.socket") as mock_socket,
            mock.patch("profiling.sampling.cli._wait_for_ready_signal"),
            mock.patch("os.path.exists", return_value=True),
        ):
            # Use the helper to set up mocks consistently
            mock_process = self._setup_sync_mocks(mock_socket, mock_popen)
            # Override specific behavior for this test
            mock_process.wait.side_effect = [
                subprocess.TimeoutExpired(test_args, 0.1),
                None,
            ]

            main()

            # Verify the coordinator command was called
            args, kwargs = mock_popen.call_args
            coordinator_cmd = args[0]
            self.assertEqual(coordinator_cmd[0], sys.executable)
            self.assertEqual(coordinator_cmd[1], "-m")
            self.assertEqual(
                coordinator_cmd[2], "profiling.sampling._sync_coordinator"
            )
            self.assertEqual(coordinator_cmd[3], "12345")  # port
            # cwd is coordinator_cmd[4]
            self.assertEqual(
                coordinator_cmd[5:], ("myscript.py", "arg1", "arg2", "--flag")
            )

    def test_cli_mutually_exclusive_pid_module(self):
        # In new CLI, attach and run are separate subcommands, so this test
        # verifies that mixing them causes an error
        test_args = [
            "profiling.sampling.cli",
            "attach",  # attach subcommand uses PID
            "12345",
            "-m",  # -m is only for run subcommand
            "mymodule",
        ]

        with (
            mock.patch("sys.argv", test_args),
            mock.patch("sys.stderr", io.StringIO()) as mock_stderr,
            self.assertRaises(SystemExit) as cm,
        ):
            main()

        self.assertEqual(cm.exception.code, 2)  # argparse error
        error_msg = mock_stderr.getvalue()
        self.assertIn("unrecognized arguments", error_msg)

    def test_cli_mutually_exclusive_pid_script(self):
        # In new CLI, you can't mix attach (PID) with run (script)
        # This would be caught by providing a PID to run subcommand
        test_args = ["profiling.sampling.cli", "run", "12345"]

        with (
            mock.patch("sys.argv", test_args),
            mock.patch("sys.stderr", io.StringIO()) as mock_stderr,
            self.assertRaises(SamplingScriptNotFoundError) as cm,
        ):
            main()

        # Verify the error is about the non-existent script
        self.assertIn("12345", str(cm.exception))

    def test_cli_no_target_specified(self):
        # In new CLI, must specify a subcommand
        test_args = ["profiling.sampling.cli", "-d", "5"]

        with (
            mock.patch("sys.argv", test_args),
            mock.patch("sys.stderr", io.StringIO()) as mock_stderr,
            self.assertRaises(SystemExit) as cm,
        ):
            main()

        self.assertEqual(cm.exception.code, 2)  # argparse error
        error_msg = mock_stderr.getvalue()
        self.assertIn("invalid choice", error_msg)

    @unittest.skipIf(is_emscripten, "socket.SO_REUSEADDR does not exist")
    @requires_remote_subprocess_debugging()
    def test_cli_module_with_profiler_options(self):
        test_args = [
            "profiling.sampling.cli",
            "run",
            "-r",
            "1000",
            "-d",
            "30",
            "-a",
            "--sort",
            "tottime",
            "-l",
            "20",
            "-m",
            "mymodule",
        ]

        with (
            mock.patch("sys.argv", test_args),
            mock.patch("profiling.sampling.cli.sample") as mock_sample,
            mock.patch("subprocess.Popen") as mock_popen,
            mock.patch("socket.socket") as mock_socket,
            mock.patch("profiling.sampling.cli._wait_for_ready_signal"),
            mock.patch("importlib.util.find_spec", return_value=True),
        ):
            self._setup_sync_mocks(mock_socket, mock_popen)
            main()

            self._verify_coordinator_command(mock_popen, ("-m", "mymodule"))
            mock_sample.assert_called_once()

    @unittest.skipIf(is_emscripten, "socket.SO_REUSEADDR does not exist")
    def test_cli_script_with_profiler_options(self):
        """Test script with various profiler options."""
        test_args = [
            "profiling.sampling.cli",
            "run",
            "-r",
            "500",
            "-d",
            "60",
            "--collapsed",
            "-o",
            "output.txt",
            "myscript.py",
            "scriptarg",
        ]

        with (
            mock.patch("sys.argv", test_args),
            mock.patch("profiling.sampling.cli.sample") as mock_sample,
            mock.patch("subprocess.Popen") as mock_popen,
            mock.patch("socket.socket") as mock_socket,
            mock.patch("profiling.sampling.cli._wait_for_ready_signal"),
            mock.patch("os.path.exists", return_value=True),
        ):
            self._setup_sync_mocks(mock_socket, mock_popen)
            main()

            self._verify_coordinator_command(
                mock_popen, ("myscript.py", "scriptarg")
            )
            # Verify profiler was called
            mock_sample.assert_called_once()

    def test_cli_empty_module_name(self):
        test_args = ["profiling.sampling.cli", "run", "-m"]

        with (
            mock.patch("sys.argv", test_args),
            mock.patch("sys.stderr", io.StringIO()) as mock_stderr,
            self.assertRaises(SystemExit) as cm,
        ):
            main()

        self.assertEqual(cm.exception.code, 2)  # argparse error
        error_msg = mock_stderr.getvalue()
        self.assertIn("required: target", error_msg)  # argparse error for missing positional arg

    @unittest.skipIf(is_emscripten, "socket.SO_REUSEADDR does not exist")
    @requires_remote_subprocess_debugging()
    def test_cli_long_module_option(self):
        test_args = [
            "profiling.sampling.cli",
            "run",
            "-m",
            "mymodule",
            "arg1",
        ]

        with (
            mock.patch("sys.argv", test_args),
            mock.patch("profiling.sampling.cli.sample") as mock_sample,
            mock.patch("subprocess.Popen") as mock_popen,
            mock.patch("socket.socket") as mock_socket,
            mock.patch("profiling.sampling.cli._wait_for_ready_signal"),
            mock.patch("importlib.util.find_spec", return_value=True),
        ):
            self._setup_sync_mocks(mock_socket, mock_popen)
            main()

            self._verify_coordinator_command(
                mock_popen, ("-m", "mymodule", "arg1")
            )

    def test_cli_complex_script_arguments(self):
        test_args = [
            "profiling.sampling.cli",
            "run",
            "script.py",
            "--input",
            "file.txt",
            "-v",
            "--output=/tmp/out",
            "positional",
        ]

        with (
            mock.patch("sys.argv", test_args),
            mock.patch("profiling.sampling.cli.sample") as mock_sample,
            mock.patch(
                "profiling.sampling.cli._run_with_sync"
            ) as mock_run_with_sync,
            mock.patch("os.path.exists", return_value=True),
        ):
            mock_process = mock.MagicMock()
            mock_process.pid = 12345
            mock_process.wait.side_effect = [
                subprocess.TimeoutExpired(test_args, 0.1),
                None,
            ]
            mock_process.poll.return_value = None
            mock_run_with_sync.return_value = mock_process

            main()

            mock_run_with_sync.assert_called_once_with(
                (
                    sys.executable,
                    "script.py",
                    "--input",
                    "file.txt",
                    "-v",
                    "--output=/tmp/out",
                    "positional",
                ),
                suppress_output=False
            )

    def test_cli_collapsed_format_validation(self):
        """Test that CLI properly validates incompatible options with collapsed format."""
        test_cases = [
            # Test sort option is invalid with collapsed
            (
                [
                    "profiling.sampling.cli",
                    "attach",
                    "12345",
                    "--collapsed",
                    "--sort",
                    "tottime",  # Changed from nsamples (default) to trigger validation
                ],
                "sort",
            ),
            # Test limit option is invalid with collapsed
            (
                [
                    "profiling.sampling.cli",
                    "attach",
                    "12345",
                    "--collapsed",
                    "-l",
                    "20",
                ],
                "limit",
            ),
            # Test no-summary option is invalid with collapsed
            (
                [
                    "profiling.sampling.cli",
                    "attach",
                    "12345",
                    "--collapsed",
                    "--no-summary",
                ],
                "summary",
            ),
        ]

        for test_args, expected_error_keyword in test_cases:
            with (
                mock.patch("sys.argv", test_args),
                mock.patch("sys.stderr", io.StringIO()) as mock_stderr,
                mock.patch("profiling.sampling.cli.sample"),  # Prevent actual profiling
                self.assertRaises(SystemExit) as cm,
            ):
                main()

            self.assertEqual(cm.exception.code, 2)  # argparse error code
            error_msg = mock_stderr.getvalue()
            self.assertIn("error:", error_msg)
            self.assertIn("only valid with --pstats", error_msg)

    def test_cli_default_collapsed_filename(self):
        """Test that collapsed format gets a default filename when not specified."""
        test_args = ["profiling.sampling.cli", "attach", "12345", "--collapsed"]

        with (
            mock.patch("sys.argv", test_args),
            mock.patch("profiling.sampling.cli._is_process_running", return_value=True),
            mock.patch("profiling.sampling.cli.sample") as mock_sample,
        ):
            main()

            # Check that sample was called (exact filename depends on implementation)
            mock_sample.assert_called_once()

    def test_cli_custom_output_filenames(self):
        """Test custom output filenames for both formats."""
        test_cases = [
            (
                [
                    "profiling.sampling.cli",
                    "attach",
                    "12345",
                    "--pstats",
                    "-o",
                    "custom.pstats",
                ],
                "custom.pstats",
                "pstats",
            ),
            (
                [
                    "profiling.sampling.cli",
                    "attach",
                    "12345",
                    "--collapsed",
                    "-o",
                    "custom.txt",
                ],
                "custom.txt",
                "collapsed",
            ),
        ]

        for test_args, expected_filename, expected_format in test_cases:
            with (
                mock.patch("sys.argv", test_args),
                mock.patch("profiling.sampling.cli._is_process_running", return_value=True),
                mock.patch("profiling.sampling.cli.sample") as mock_sample,
            ):
                main()

                mock_sample.assert_called_once()

    def test_cli_missing_required_arguments(self):
        """Test that CLI requires subcommand."""
        with (
            mock.patch("sys.argv", ["profiling.sampling.cli"]),
            mock.patch("sys.stderr", io.StringIO()),
        ):
            with self.assertRaises(SystemExit):
                main()

    def test_cli_mutually_exclusive_format_options(self):
        """Test that pstats and collapsed options are mutually exclusive."""
        with (
            mock.patch(
                "sys.argv",
                [
                    "profiling.sampling.cli",
                    "attach",
                    "12345",
                    "--pstats",
                    "--collapsed",
                ],
            ),
            mock.patch("sys.stderr", io.StringIO()),
        ):
            with self.assertRaises(SystemExit):
                main()

    def test_argument_parsing_basic(self):
        test_args = ["profiling.sampling.cli", "attach", "12345"]

        with (
            mock.patch("sys.argv", test_args),
            mock.patch("profiling.sampling.cli._is_process_running", return_value=True),
            mock.patch("profiling.sampling.cli.sample") as mock_sample,
        ):
            main()

            mock_sample.assert_called_once()

    def _run_dump_cli(
        self,
        *cli_args,
        dump_return=None,
        dump_side_effect=None,
        process_running=True,
    ):
        """Run main() for a `dump` invocation, returning (mock_dump, mock_print)."""
        argv = ["profiling.sampling.cli", "dump", *cli_args]
        dump_kwargs = {}
        if dump_side_effect is not None:
            dump_kwargs["side_effect"] = dump_side_effect
        else:
            dump_kwargs["return_value"] = [] if dump_return is None else dump_return
        with (
            mock.patch("sys.argv", argv),
            mock.patch(
                "profiling.sampling.cli._is_process_running",
                return_value=process_running,
            ),
            mock.patch("profiling.sampling.cli.dump_stack", **dump_kwargs) as mock_dump_stack,
            mock.patch("profiling.sampling.cli.print_stack_dump") as mock_print_stack_dump,
        ):
            main()
        return mock_dump_stack, mock_print_stack_dump

    def _run_dump_cli_expecting_exit(self, *cli_args, capture="stderr"):
        """Run main() for a `dump` invocation expected to SystemExit, return (cm, captured_text)."""
        argv = ["profiling.sampling.cli", "dump", *cli_args]
        buf = io.StringIO()
        stream = "sys.stderr" if capture == "stderr" else "sys.stdout"
        with (
            mock.patch("sys.argv", argv),
            mock.patch(stream, buf),
            self.assertRaises(SystemExit) as cm,
        ):
            main()
        return cm, buf.getvalue()

    def test_cli_dump_subcommand(self):
        stack_frames = [mock.sentinel.stack_frames]
        mock_dump_stack, mock_print_stack_dump = self._run_dump_cli(
            "12345", dump_return=stack_frames
        )

        mock_dump_stack.assert_called_once()
        self.assertEqual(mock_dump_stack.call_args.args, (12345,))
        call_kwargs = mock_dump_stack.call_args.kwargs
        self.assertFalse(call_kwargs["all_threads"])
        self.assertIsNone(call_kwargs["async_aware"])
        self.assertFalse(call_kwargs["native"])
        self.assertTrue(call_kwargs["gc"])
        self.assertFalse(call_kwargs["opcodes"])
        self.assertFalse(call_kwargs["blocking"])
        self.assertEqual(call_kwargs["mode"], PROFILING_MODE_ALL)
        mock_print_stack_dump.assert_called_once_with(stack_frames, pid=12345)

    def test_cli_dump_subcommand_options(self):
        mock_dump_stack, _ = self._run_dump_cli(
            "-a", "--native", "--no-gc", "--opcodes", "--blocking", "12345"
        )

        call_kwargs = mock_dump_stack.call_args.kwargs
        self.assertTrue(call_kwargs["all_threads"])
        self.assertTrue(call_kwargs["native"])
        self.assertFalse(call_kwargs["gc"])
        self.assertTrue(call_kwargs["opcodes"])
        self.assertTrue(call_kwargs["blocking"])
        self.assertEqual(call_kwargs["mode"], PROFILING_MODE_ALL)

    def test_cli_dump_rejects_mode_option(self):
        cm, stderr = self._run_dump_cli_expecting_exit("12345", "--mode", "cpu")
        self.assertEqual(cm.exception.code, 2)
        self.assertIn("unrecognized arguments: --mode", stderr)

    def test_cli_dump_async_aware_defaults_to_all(self):
        mock_dump_stack, _ = self._run_dump_cli("--async-aware", "12345")
        call_kwargs = mock_dump_stack.call_args.kwargs
        self.assertEqual(call_kwargs["async_aware"], "all")
        self.assertEqual(call_kwargs["mode"], PROFILING_MODE_WALL)

    def test_cli_dump_async_mode_all_is_forwarded(self):
        mock_dump_stack, _ = self._run_dump_cli(
            "--async-aware", "--async-mode", "all", "12345"
        )
        call_kwargs = mock_dump_stack.call_args.kwargs
        self.assertEqual(call_kwargs["async_aware"], "all")
        self.assertEqual(call_kwargs["mode"], PROFILING_MODE_WALL)

    def test_cli_dump_async_mode_running_is_forwarded(self):
        mock_dump_stack, _ = self._run_dump_cli(
            "--async-aware", "--async-mode", "running", "12345"
        )
        call_kwargs = mock_dump_stack.call_args.kwargs
        self.assertEqual(call_kwargs["async_aware"], "running")
        self.assertEqual(call_kwargs["mode"], PROFILING_MODE_WALL)

    def test_cli_dump_async_mode_requires_async_aware(self):
        cm, stderr = self._run_dump_cli_expecting_exit(
            "--async-mode", "running", "12345"
        )
        self.assertEqual(cm.exception.code, 2)
        self.assertIn("--async-mode requires --async-aware", stderr)

    def test_cli_dump_rejects_async_aware_with_all_threads(self):
        cm, stderr = self._run_dump_cli_expecting_exit(
            "--async-aware", "-a", "12345"
        )
        self.assertEqual(cm.exception.code, 2)
        self.assertIn("--all-threads", stderr)
        self.assertIn("incompatible with --async-aware", stderr)

    def test_cli_dump_rejects_async_aware_with_native(self):
        cm, stderr = self._run_dump_cli_expecting_exit(
            "--async-aware", "--native", "12345"
        )
        self.assertEqual(cm.exception.code, 2)
        self.assertIn("--native", stderr)
        self.assertIn("incompatible with --async-aware", stderr)

    def test_cli_dump_rejects_async_aware_with_no_gc(self):
        cm, stderr = self._run_dump_cli_expecting_exit(
            "--async-aware", "--no-gc", "12345"
        )
        self.assertEqual(cm.exception.code, 2)
        self.assertIn("--no-gc", stderr)
        self.assertIn("incompatible with --async-aware", stderr)

    def test_cli_dump_unknown_process(self):
        with self.assertRaises(SamplingUnknownProcessError):
            self._run_dump_cli("12345", process_running=False)

    def test_cli_dump_process_exits_before_snapshot(self):
        with self.assertRaises(SystemExit) as cm:
            self._run_dump_cli("12345", dump_side_effect=ProcessLookupError)
        self.assertIn(
            "No stack dump collected - process 12345 exited",
            str(cm.exception.code),
        )

    @force_not_colorized
    def test_cli_dump_help_lists_dump_options_without_mode(self):
        cm, stdout = self._run_dump_cli_expecting_exit("--help", capture="stdout")
        self.assertEqual(cm.exception.code, 0)
        self.assertIn("--async-mode {running,all}", stdout)
        self.assertIn("--opcodes", stdout)
        self.assertNotIn("--mode {wall,cpu,gil,exception}", stdout)

    def test_sort_options(self):
        sort_options = [
            ("nsamples", 0),
            ("tottime", 1),
            ("cumtime", 2),
            ("sample-pct", 3),
            ("cumul-pct", 4),
            ("name", -1),
        ]

        for option, expected_sort_value in sort_options:
            test_args = ["profiling.sampling.cli", "attach", "12345", "--sort", option]

            with (
                mock.patch("sys.argv", test_args),
                mock.patch("profiling.sampling.cli._is_process_running", return_value=True),
                mock.patch("profiling.sampling.cli.sample") as mock_sample,
            ):
                main()

                mock_sample.assert_called_once()
                mock_sample.reset_mock()

    def test_async_aware_flag_defaults_to_running(self):
        """Test --async-aware flag enables async profiling with default 'running' mode."""
        test_args = ["profiling.sampling.cli", "attach", "12345", "--async-aware"]

        with (
            mock.patch("sys.argv", test_args),
            mock.patch("profiling.sampling.cli._is_process_running", return_value=True),
            mock.patch("profiling.sampling.cli.sample") as mock_sample,
        ):
            main()

            mock_sample.assert_called_once()
            # Verify async_aware was passed with default "running" mode
            call_kwargs = mock_sample.call_args[1]
            self.assertEqual(call_kwargs.get("async_aware"), "running")

    def test_async_aware_with_async_mode_all(self):
        """Test --async-aware with --async-mode all."""
        test_args = ["profiling.sampling.cli", "attach", "12345", "--async-aware", "--async-mode", "all"]

        with (
            mock.patch("sys.argv", test_args),
            mock.patch("profiling.sampling.cli._is_process_running", return_value=True),
            mock.patch("profiling.sampling.cli.sample") as mock_sample,
        ):
            main()

            mock_sample.assert_called_once()
            call_kwargs = mock_sample.call_args[1]
            self.assertEqual(call_kwargs.get("async_aware"), "all")

    def test_async_aware_default_is_none(self):
        """Test async_aware defaults to None when --async-aware not specified."""
        test_args = ["profiling.sampling.cli", "attach", "12345"]

        with (
            mock.patch("sys.argv", test_args),
            mock.patch("profiling.sampling.cli._is_process_running", return_value=True),
            mock.patch("profiling.sampling.cli.sample") as mock_sample,
        ):
            main()

            mock_sample.assert_called_once()
            call_kwargs = mock_sample.call_args[1]
            self.assertIsNone(call_kwargs.get("async_aware"))

    def test_async_mode_invalid_choice(self):
        """Test --async-mode with invalid choice raises error."""
        test_args = ["profiling.sampling.cli", "attach", "12345", "--async-aware", "--async-mode", "invalid"]

        with (
            mock.patch("sys.argv", test_args),
            mock.patch("sys.stderr", io.StringIO()),
            self.assertRaises(SystemExit) as cm,
        ):
            main()

        self.assertEqual(cm.exception.code, 2)  # argparse error

    def test_async_mode_requires_async_aware(self):
        """Test --async-mode without --async-aware raises error."""
        test_args = ["profiling.sampling.cli", "attach", "12345", "--async-mode", "all"]

        with (
            mock.patch("sys.argv", test_args),
            mock.patch("sys.stderr", io.StringIO()) as mock_stderr,
            self.assertRaises(SystemExit) as cm,
        ):
            main()

        self.assertEqual(cm.exception.code, 2)  # argparse error
        error_msg = mock_stderr.getvalue()
        self.assertIn("--async-mode requires --async-aware", error_msg)

    def test_async_aware_incompatible_with_native(self):
        """Test --async-aware is incompatible with --native."""
        test_args = ["profiling.sampling.cli", "attach", "12345", "--async-aware", "--native"]

        with (
            mock.patch("sys.argv", test_args),
            mock.patch("sys.stderr", io.StringIO()) as mock_stderr,
            self.assertRaises(SystemExit) as cm,
        ):
            main()

        self.assertEqual(cm.exception.code, 2)  # argparse error
        error_msg = mock_stderr.getvalue()
        self.assertIn("--native", error_msg)
        self.assertIn("incompatible with --async-aware", error_msg)

    def test_async_aware_incompatible_with_no_gc(self):
        """Test --async-aware is incompatible with --no-gc."""
        test_args = ["profiling.sampling.cli", "attach", "12345", "--async-aware", "--no-gc"]

        with (
            mock.patch("sys.argv", test_args),
            mock.patch("sys.stderr", io.StringIO()) as mock_stderr,
            self.assertRaises(SystemExit) as cm,
        ):
            main()

        self.assertEqual(cm.exception.code, 2)  # argparse error
        error_msg = mock_stderr.getvalue()
        self.assertIn("--no-gc", error_msg)
        self.assertIn("incompatible with --async-aware", error_msg)

    def test_async_aware_incompatible_with_both_native_and_no_gc(self):
        """Test --async-aware is incompatible with both --native and --no-gc."""
        test_args = ["profiling.sampling.cli", "attach", "12345", "--async-aware", "--native", "--no-gc"]

        with (
            mock.patch("sys.argv", test_args),
            mock.patch("sys.stderr", io.StringIO()) as mock_stderr,
            self.assertRaises(SystemExit) as cm,
        ):
            main()

        self.assertEqual(cm.exception.code, 2)  # argparse error
        error_msg = mock_stderr.getvalue()
        self.assertIn("--native", error_msg)
        self.assertIn("--no-gc", error_msg)
        self.assertIn("incompatible with --async-aware", error_msg)

    def test_async_aware_incompatible_with_mode(self):
        """Test --async-aware is incompatible with --mode (non-wall)."""
        test_args = ["profiling.sampling.cli", "attach", "12345", "--async-aware", "--mode", "cpu"]

        with (
            mock.patch("sys.argv", test_args),
            mock.patch("sys.stderr", io.StringIO()) as mock_stderr,
            self.assertRaises(SystemExit) as cm,
        ):
            main()

        self.assertEqual(cm.exception.code, 2)  # argparse error
        error_msg = mock_stderr.getvalue()
        self.assertIn("--mode=cpu", error_msg)
        self.assertIn("incompatible with --async-aware", error_msg)

    def test_async_aware_incompatible_with_all_threads(self):
        """Test --async-aware is incompatible with --all-threads."""
        test_args = ["profiling.sampling.cli", "attach", "12345", "--async-aware", "--all-threads"]

        with (
            mock.patch("sys.argv", test_args),
            mock.patch("sys.stderr", io.StringIO()) as mock_stderr,
            self.assertRaises(SystemExit) as cm,
        ):
            main()

        self.assertEqual(cm.exception.code, 2)  # argparse error
        error_msg = mock_stderr.getvalue()
        self.assertIn("--all-threads", error_msg)
        self.assertIn("incompatible with --async-aware", error_msg)

    @unittest.skipIf(is_emscripten, "subprocess not available")
    def test_run_nonexistent_script_exits_cleanly(self):
        """Test that running a non-existent script exits with a clean error."""
        with mock.patch("sys.argv", ["profiling.sampling.cli", "run", "/nonexistent/script.py"]):
            with self.assertRaisesRegex(SamplingScriptNotFoundError, "Script '[\\w/.]+' not found."):
                main()

    @unittest.skipIf(is_emscripten, "subprocess not available")
    def test_run_nonexistent_module_exits_cleanly(self):
        """Test that running a non-existent module exits with a clean error."""
        with mock.patch("sys.argv", ["profiling.sampling.cli", "run", "-m", "nonexistent_module_xyz"]):
            with self.assertRaisesRegex(SamplingModuleNotFoundError, "Module '[\\w/.]+' not found."):
                main()

    @unittest.skipIf(is_emscripten, "subprocess not available")
    def test_cli_attach_nonexistent_pid(self):
        fake_pid = "99999"
        with mock.patch("sys.argv", ["profiling.sampling.cli", "attach", fake_pid]):
            with self.assertRaises(SamplingUnknownProcessError) as cm:
                main()

            self.assertIn(fake_pid, str(cm.exception))

    def test_cli_replay_rejects_non_binary_profile(self):
        with tempfile.TemporaryDirectory() as tempdir:
            profile = os.path.join(tempdir, "output.prof")
            with open(profile, "wb") as file:
                file.write(b"not a binary sampling profile")

            with mock.patch("sys.argv", ["profiling.sampling.cli", "replay", profile]):
                with self.assertRaises(SystemExit) as cm:
                    main()

        error = str(cm.exception)
        self.assertIn("not a binary sampling profile", error)
        self.assertIn("--binary", error)

    def test_cli_replay_reader_errors_exit_cleanly(self):
        with tempfile.TemporaryDirectory() as tempdir:
            profile = os.path.join(tempdir, "output.bin")
            with open(profile, "wb") as file:
                file.write(b"HCAT" + (b"\0" * 60))

            with (
                mock.patch("sys.argv", ["profiling.sampling.cli", "replay", profile]),
                mock.patch(
                    "profiling.sampling.cli.BinaryReader",
                    side_effect=ValueError("Unsupported format version 2"),
                ),
            ):
                with self.assertRaises(SystemExit) as cm:
                    main()

        self.assertEqual(
            str(cm.exception),
            "Error: Unsupported format version 2",
        )

    def test_cli_jsonl_format_mutually_exclusive_with_pstats(self):
        """--jsonl and --pstats cannot be combined (mutually exclusive group)."""
        with (
            mock.patch(
                "sys.argv",
                [
                    "profiling.sampling.cli",
                    "attach",
                    "12345",
                    "--jsonl",
                    "--pstats",
                ],
            ),
            mock.patch("sys.stderr", io.StringIO()),
        ):
            with self.assertRaises(SystemExit):
                main()

    def test_cli_jsonl_extension_in_format_extensions(self):
        """FORMAT_EXTENSIONS maps 'jsonl' -> 'jsonl' so default filenames work."""
        self.assertEqual(FORMAT_EXTENSIONS["jsonl"], "jsonl")
        self.assertEqual(_generate_output_filename("jsonl", 12345), "jsonl_12345.jsonl")

    def test_cli_jsonl_create_collector_propagates_mode(self):
        """_create_collector('jsonl', ..., mode=X) lands X in the meta record."""
        collector = _create_collector(
            "jsonl",
            sample_interval_usec=1000,
            skip_idle=False,
            mode=PROFILING_MODE_CPU,
        )
        self.assertIsInstance(collector, JsonlCollector)

        with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=False) as f:
            jsonl_path = f.name
        self.addCleanup(os.unlink, jsonl_path)
        collector.export(jsonl_path)
        with open(jsonl_path, "r", encoding="utf-8") as f:
            records = [json.loads(line) for line in f]
        meta = next(r for r in records if r["type"] == "meta")
        self.assertEqual(meta["mode"], "cpu")

    def test_cli_jsonl_rejects_opcodes_combination(self):
        """--opcodes is incompatible with --jsonl per opcodes_compatible_formats."""
        test_args = [
            "profiling.sampling.cli",
            "attach",
            "12345",
            "--jsonl",
            "--opcodes",
        ]
        with (
            mock.patch("sys.argv", test_args),
            mock.patch("sys.stderr", io.StringIO()) as mock_stderr,
            mock.patch("profiling.sampling.cli.sample"),
            self.assertRaises(SystemExit) as cm,
        ):
            main()

        self.assertEqual(cm.exception.code, 2)
        self.assertIn("--opcodes", mock_stderr.getvalue())
