"""
Comprehensive cross-platform tests for Windows OS compatibility.

Tests path normalization, environment detection, service registration,
process management, and file permissions across platform boundaries.
"""

import asyncio
import os
import stat
import subprocess
import sys
import tempfile
from pathlib import Path, PurePosixPath, PureWindowsPath
from unittest.mock import AsyncMock, MagicMock, patch, call

import psutil
import pytest

from skop_runner.compat import (
    get_default_system_path,
    get_subprocess_kwargs,
    is_pid_alive,
    kill_process_tree,
    secure_file_permissions,
    setup_signal_handlers,
    terminate_process,
)
from skop_runner.file_manager import FileManager
from skop_runner.service import (
    _install_windows,
    _uninstall_windows,
    _status_windows,
    TASK_NAME,
)


# ============================================================
# _normalize_output_path — THE CRITICAL PATH NORMALIZATION
# ============================================================


class TestNormalizeOutputPath:
    """Tests for FileManager._normalize_output_path."""

    # ---- Platform-guarded behavior ----

    def test_unix_returns_path_unchanged(self):
        """On Unix (current platform), paths pass through unchanged."""
        if sys.platform == "win32":
            pytest.skip("Unix-only test")
        assert FileManager._normalize_output_path("foo/bar/baz.py") == "foo/bar/baz.py"

    def test_unix_preserves_literal_backslash_in_filename(self):
        """On Unix, backslash is a valid filename character — must NOT be converted."""
        if sys.platform == "win32":
            pytest.skip("Unix-only test")
        # A file literally named "foo\bar.txt" on Unix
        assert FileManager._normalize_output_path("project/foo\\bar.txt") == "project/foo\\bar.txt"

    def test_unix_preserves_multiple_backslashes(self):
        """On Unix, multiple backslashes in a filename are preserved."""
        if sys.platform == "win32":
            pytest.skip("Unix-only test")
        assert FileManager._normalize_output_path("a\\b\\c.txt") == "a\\b\\c.txt"

    @patch("skop_runner.file_manager.sys")
    def test_windows_converts_backslashes_to_forward(self, mock_sys):
        mock_sys.platform = "win32"
        assert FileManager._normalize_output_path("foo\\bar\\baz.py") == "foo/bar/baz.py"

    @patch("skop_runner.file_manager.sys")
    def test_windows_handles_full_windows_path(self, mock_sys):
        mock_sys.platform = "win32"
        result = FileManager._normalize_output_path("C:\\Users\\test\\project\\file.txt")
        assert result == "C:/Users/test/project/file.txt"

    @patch("skop_runner.file_manager.sys")
    def test_windows_handles_mixed_separators(self, mock_sys):
        mock_sys.platform = "win32"
        result = FileManager._normalize_output_path("foo/bar\\baz/qux.py")
        assert result == "foo/bar/baz/qux.py"

    @patch("skop_runner.file_manager.sys")
    def test_windows_handles_unc_path(self, mock_sys):
        mock_sys.platform = "win32"
        result = FileManager._normalize_output_path("\\\\server\\share\\file.txt")
        assert result == "//server/share/file.txt"

    def test_empty_string(self):
        assert FileManager._normalize_output_path("") == ""

    def test_single_filename_no_separators(self):
        assert FileManager._normalize_output_path("file.txt") == "file.txt"

    def test_forward_slashes_unchanged_on_any_platform(self):
        """Forward slashes are never modified, regardless of platform."""
        assert FileManager._normalize_output_path("foo/bar/baz.py") == "foo/bar/baz.py"

    # ---- Integration with FileManager methods ----

    def test_get_project_root_normalizes(self, tmp_path):
        """get_project_root returns normalized path."""
        fm = FileManager(str(tmp_path))
        root = fm.get_project_root()
        assert root is not None
        assert "\\" not in root or sys.platform != "win32"

    @pytest.mark.asyncio
    async def test_list_directory_normalizes_paths(self, tmp_path):
        """list_directory returns normalized relative paths."""
        fm = FileManager(str(tmp_path))
        # Create a nested structure
        (tmp_path / "subdir").mkdir()
        (tmp_path / "subdir" / "file.txt").write_text("hello")

        items = await fm.list_directory("")
        for item in items:
            assert "\\" not in item["path"] or sys.platform != "win32"

    def test_get_home_directory_normalizes(self, tmp_path):
        """get_home_directory returns normalized path."""
        fm = FileManager(str(tmp_path))
        home = fm.get_home_directory()
        assert "\\" not in home or sys.platform != "win32"

    def test_get_file_info_normalizes(self, tmp_path):
        """get_file_info returns normalized path."""
        fm = FileManager(str(tmp_path))
        (tmp_path / "test.txt").write_text("hello")
        info = fm.get_file_info("test.txt")
        assert "\\" not in info["path"] or sys.platform != "win32"

    @pytest.mark.asyncio
    async def test_find_notebooks_normalizes(self, tmp_path):
        """find_notebooks returns normalized paths for both path and folder."""
        fm = FileManager(str(tmp_path))
        # Create a notebook
        nb_dir = tmp_path / "notebooks"
        nb_dir.mkdir()
        nb_file = nb_dir / "test.ipynb"
        nb_file.write_text('{"cells": [], "metadata": {}, "nbformat": 4, "nbformat_minor": 5}')

        notebooks = await fm.find_notebooks()
        for nb in notebooks:
            assert "\\" not in nb["path"] or sys.platform != "win32"
            if nb.get("folder"):
                assert "\\" not in nb["folder"] or sys.platform != "win32"

    @pytest.mark.asyncio
    async def test_glob_normalizes(self, tmp_path):
        """glob returns normalized paths."""
        fm = FileManager(str(tmp_path))
        (tmp_path / "test.py").write_text("x = 1")

        results = await fm.glob_files("*.py")
        for path in results:
            assert "\\" not in path or sys.platform != "win32"

    @pytest.mark.asyncio
    async def test_grep_normalizes(self, tmp_path):
        """grep returns normalized file paths."""
        fm = FileManager(str(tmp_path))
        (tmp_path / "test.py").write_text("hello world")

        results = await fm.grep_files("hello")
        for match in results:
            assert "\\" not in match["file"] or sys.platform != "win32"


# ============================================================
# compat.py — EXTENDED PROCESS MANAGEMENT TESTS
# ============================================================


class TestKillProcessTreeExtended:
    """Extended tests for kill_process_tree edge cases."""

    def test_kills_process_with_children(self):
        """Verify child processes are also killed."""
        parent = subprocess.Popen(
            [sys.executable, "-c",
             "import subprocess, sys, time; "
             "subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)']); "
             "time.sleep(60)"],
            start_new_session=True,
        )
        time.sleep(0.5)

        # Get children before kill
        try:
            p = psutil.Process(parent.pid)
            children = p.children()
            child_pids = [c.pid for c in children]
        except psutil.NoSuchProcess:
            parent.wait()
            pytest.skip("Process died before we could check children")

        kill_process_tree(parent.pid, timeout=5)
        parent.wait(timeout=5)

        # Both parent and children should be dead
        assert not psutil.pid_exists(parent.pid)
        for cpid in child_pids:
            assert not psutil.pid_exists(cpid)

    def test_kill_already_dead_process_no_error(self):
        """Killing a process that already exited should not raise."""
        proc = subprocess.Popen(
            [sys.executable, "-c", "pass"]
        )
        proc.wait()  # Wait for it to finish
        # Should not raise
        kill_process_tree(proc.pid)

    def test_kill_with_zero_timeout(self):
        """Zero timeout should still work (no wait)."""
        proc = subprocess.Popen(
            [sys.executable, "-c", "import time; time.sleep(60)"],
            start_new_session=True,
        )
        kill_process_tree(proc.pid, timeout=0)
        proc.wait(timeout=5)
        assert proc.returncode is not None


class TestTerminateProcessExtended:
    """Extended tests for terminate_process edge cases."""

    def test_terminate_already_dead_process(self):
        proc = subprocess.Popen([sys.executable, "-c", "pass"])
        proc.wait()
        # Should not raise
        terminate_process(proc.pid)

    def test_terminate_force_kills_on_timeout(self):
        """Process that ignores SIGTERM should be force-killed."""
        # Python process that catches SIGTERM and ignores it
        proc = subprocess.Popen(
            [sys.executable, "-c",
             "import signal, time; "
             "signal.signal(signal.SIGTERM, lambda *a: None); "
             "time.sleep(60)"],
        )
        terminate_process(proc.pid, timeout=2)
        proc.wait(timeout=5)
        assert proc.returncode is not None


class TestIsPidAliveExtended:
    """Extended tests for is_pid_alive edge cases."""

    def test_own_pid_alive(self):
        assert is_pid_alive(os.getpid()) is True

    def test_child_pid_alive_then_dead(self):
        proc = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"])
        assert is_pid_alive(proc.pid) is True
        proc.kill()
        proc.wait()
        # Give the OS a moment to reclaim the PID
        import time
        time.sleep(0.1)
        assert is_pid_alive(proc.pid) is False

    def test_negative_pid(self):
        assert is_pid_alive(-1) is False

    def test_very_large_pid(self):
        assert is_pid_alive(2**30) is False


# ============================================================
# compat.py — SIGNAL HANDLING EXTENDED TESTS
# ============================================================


class TestSetupSignalHandlersExtended:
    """Extended signal handler tests."""

    @patch("skop_runner.compat.sys")
    def test_unix_registers_both_sigint_and_sigterm(self, mock_sys):
        mock_sys.platform = "darwin"
        mock_loop = MagicMock()
        handler = lambda: None

        setup_signal_handlers(mock_loop, handler)

        calls = mock_loop.add_signal_handler.call_args_list
        registered_signals = {c[0][0] for c in calls}
        import signal
        assert signal.SIGINT in registered_signals
        assert signal.SIGTERM in registered_signals

    @patch("skop_runner.compat.signal")
    @patch("skop_runner.compat.sys")
    def test_windows_handler_calls_threadsafe(self, mock_sys, mock_signal):
        """Windows handler should use call_soon_threadsafe."""
        mock_sys.platform = "win32"
        mock_signal.SIGINT = 2
        # Simulate SIGBREAK not existing (some mock environments)
        mock_signal.SIGBREAK = 21
        delattr(mock_signal, "SIGBREAK") if hasattr(mock_signal, "SIGBREAK") else None
        mock_loop = MagicMock()
        handler = MagicMock()

        setup_signal_handlers(mock_loop, handler)

        # Get the registered handler and call it
        registered_handler = mock_signal.signal.call_args_list[0][0][1]
        registered_handler(2, None)  # Simulate SIGINT

        mock_loop.call_soon_threadsafe.assert_called_once_with(handler)


# ============================================================
# compat.py — SECURE FILE PERMISSIONS EXTENDED
# ============================================================


class TestSecureFilePermissionsExtended:
    """Extended file permission tests."""

    @pytest.mark.skipif(sys.platform == "win32", reason="Unix permissions")
    def test_unix_permissions_on_new_file(self, tmp_path):
        test_file = tmp_path / "config.json"
        test_file.write_text('{"key": "value"}')
        # Make it initially world-readable
        os.chmod(test_file, 0o644)

        secure_file_permissions(str(test_file))

        mode = stat.S_IMODE(os.stat(test_file).st_mode)
        assert mode == 0o600
        # Owner can read
        assert mode & stat.S_IRUSR
        # Owner can write
        assert mode & stat.S_IWUSR
        # Group cannot read
        assert not (mode & stat.S_IRGRP)
        # Others cannot read
        assert not (mode & stat.S_IROTH)

    @patch("skop_runner.compat.subprocess")
    @patch("skop_runner.compat.sys")
    def test_windows_icacls_includes_username(self, mock_sys, mock_subprocess):
        mock_sys.platform = "win32"
        with patch.dict(os.environ, {"USERNAME": "alice"}):
            secure_file_permissions("C:\\config.json")

        args = mock_subprocess.run.call_args[0][0]
        assert "alice:(R,W)" in args

    @patch("skop_runner.compat.subprocess")
    @patch("skop_runner.compat.sys")
    def test_windows_icacls_timeout(self, mock_sys, mock_subprocess):
        """If icacls times out, it should not raise."""
        mock_sys.platform = "win32"
        mock_subprocess.run.side_effect = subprocess.TimeoutExpired("icacls", 10)
        with patch.dict(os.environ, {"USERNAME": "alice"}):
            # Should not raise
            secure_file_permissions("C:\\config.json")

    @patch("skop_runner.compat.subprocess")
    @patch("skop_runner.compat.sys")
    def test_windows_icacls_file_not_found(self, mock_sys, mock_subprocess):
        """If icacls binary not found, it should not raise."""
        mock_sys.platform = "win32"
        mock_subprocess.run.side_effect = FileNotFoundError("icacls not found")
        with patch.dict(os.environ, {"USERNAME": "alice"}):
            secure_file_permissions("C:\\config.json")


# ============================================================
# compat.py — PATH CONSTRUCTION EXTENDED
# ============================================================


class TestGetDefaultSystemPathExtended:
    """Extended PATH construction tests."""

    @patch("skop_runner.compat.sys")
    def test_unix_path_has_required_directories(self, mock_sys):
        mock_sys.platform = "linux"
        path = get_default_system_path()
        dirs = path.split(":")
        assert "/usr/bin" in dirs
        assert "/bin" in dirs

    @patch("skop_runner.compat.sys")
    def test_windows_path_uses_semicolons(self, mock_sys):
        mock_sys.platform = "win32"
        with patch.dict(os.environ, {"SystemRoot": r"C:\Windows"}):
            path = get_default_system_path()
        assert ";" in path
        assert ":" not in path.replace("C:", "")  # Ignore drive letter colon

    @patch("skop_runner.compat.sys")
    def test_windows_path_respects_custom_systemroot(self, mock_sys):
        mock_sys.platform = "win32"
        with patch.dict(os.environ, {"SystemRoot": r"D:\WinNT"}):
            path = get_default_system_path()
        assert r"D:\WinNT\System32" in path

    def test_path_separator_matches_os(self):
        """The path should use the correct separator for the current OS."""
        path = get_default_system_path()
        if sys.platform == "win32":
            assert ";" in path
        else:
            assert ":" in path


# ============================================================
# env_manager — WINDOWS PYTHON DETECTION
# ============================================================


class TestFindSystemPythonWindows:
    """Tests for Windows-specific Python detection in env_manager."""

    @patch("shutil.which")
    @patch("skop_runner.env_manager.sys")
    def test_windows_finds_py_launcher(self, mock_sys, mock_which):
        """On Windows, py.exe launcher is preferred."""
        mock_sys.platform = "win32"
        mock_which.return_value = r"C:\Windows\py.exe"

        from skop_runner.env_manager import EnvironmentManager
        em = EnvironmentManager()
        result = em._find_system_python()

        assert result is not None
        assert str(result) == r"C:\Windows\py.exe"

    @patch("shutil.which")
    @patch("skop_runner.env_manager.sys")
    def test_windows_falls_back_to_python_exe(self, mock_sys, mock_which):
        """When py.exe not found, falls back to python.exe in PATH."""
        mock_sys.platform = "win32"

        def which_side_effect(name):
            if name == "py":
                return None
            if name == "python":
                return r"C:\Python312\python.exe"
            return None

        mock_which.side_effect = which_side_effect

        from skop_runner.env_manager import EnvironmentManager
        em = EnvironmentManager()
        with patch.object(Path, "resolve", return_value=Path(r"C:\Python312\python.exe")):
            result = em._find_system_python()

        assert result is not None

    @patch("shutil.which")
    @patch("skop_runner.env_manager.sys")
    def test_windows_excludes_windowsapps_stub(self, mock_sys, mock_which):
        """Microsoft Store stub in WindowsApps should be filtered out."""
        mock_sys.platform = "win32"

        def which_side_effect(name):
            if name == "py":
                return None
            if name == "python":
                return r"C:\Users\test\AppData\Local\Microsoft\WindowsApps\python.exe"
            return None

        mock_which.side_effect = which_side_effect

        from skop_runner.env_manager import EnvironmentManager
        em = EnvironmentManager()
        with patch.object(Path, "resolve",
                          return_value=Path(r"C:\Users\test\AppData\Local\Microsoft\WindowsApps\python.exe")):
            result = em._find_system_python()

        assert result is None

    @patch("shutil.which")
    @patch("skop_runner.env_manager.sys")
    def test_windows_returns_none_when_nothing_found(self, mock_sys, mock_which):
        """Returns None when no Python found on Windows."""
        mock_sys.platform = "win32"
        mock_which.return_value = None

        from skop_runner.env_manager import EnvironmentManager
        em = EnvironmentManager()
        result = em._find_system_python()
        assert result is None


class TestGetVenvPythonCrossPlatform:
    """Tests for _get_venv_python on both platforms."""

    def test_finds_unix_venv_python(self, tmp_path):
        """On Unix, finds bin/python."""
        venv = tmp_path / ".venv"
        (venv / "bin").mkdir(parents=True)
        (venv / "bin" / "python").touch()

        from skop_runner.env_manager import EnvironmentManager
        em = EnvironmentManager()
        result = em._get_venv_python(venv)
        assert result is not None
        assert "bin" in str(result)

    def test_finds_windows_venv_python(self, tmp_path):
        """Finds Scripts/python.exe when bin/python doesn't exist."""
        venv = tmp_path / ".venv"
        (venv / "Scripts").mkdir(parents=True)
        (venv / "Scripts" / "python.exe").touch()

        from skop_runner.env_manager import EnvironmentManager
        em = EnvironmentManager()
        result = em._get_venv_python(venv)
        assert result is not None
        assert "Scripts" in str(result) or "python.exe" in str(result)

    def test_returns_none_when_no_python(self, tmp_path):
        """Returns None when neither bin/python nor Scripts/python.exe exists."""
        venv = tmp_path / ".venv"
        venv.mkdir()

        from skop_runner.env_manager import EnvironmentManager
        em = EnvironmentManager()
        result = em._get_venv_python(venv)
        assert result is None


class TestGetCondaPythonCrossPlatform:
    """Tests for _get_conda_python on both platforms."""

    def test_finds_unix_conda_python(self, tmp_path):
        """On Unix, finds bin/python."""
        conda_env = tmp_path / "myenv"
        (conda_env / "bin").mkdir(parents=True)
        (conda_env / "bin" / "python").touch()

        from skop_runner.env_manager import EnvironmentManager
        em = EnvironmentManager()
        result = em._get_conda_python(conda_env)
        assert result is not None
        assert "bin" in str(result)

    def test_finds_windows_conda_python(self, tmp_path):
        """On Windows, finds python.exe directly in conda env root."""
        conda_env = tmp_path / "myenv"
        conda_env.mkdir()
        (conda_env / "python.exe").touch()

        from skop_runner.env_manager import EnvironmentManager
        em = EnvironmentManager()
        result = em._get_conda_python(conda_env)
        assert result is not None
        assert "python.exe" in str(result)

    def test_returns_none_when_no_python(self, tmp_path):
        conda_env = tmp_path / "myenv"
        conda_env.mkdir()

        from skop_runner.env_manager import EnvironmentManager
        em = EnvironmentManager()
        result = em._get_conda_python(conda_env)
        assert result is None


class TestPyenvWinDetection:
    """Tests for pyenv-win path detection."""

    @patch("skop_runner.env_manager.sys")
    def test_windows_uses_pyenv_win_path(self, mock_sys, tmp_path):
        """On Windows, pyenv root should default to ~/.pyenv/pyenv-win."""
        mock_sys.platform = "win32"

        from skop_runner.env_manager import EnvironmentManager
        em = EnvironmentManager()

        # The pyenv detection reads from env vars; verify correct default
        with patch.dict(os.environ, {}, clear=True):
            with patch("skop_runner.env_manager.Path.home", return_value=tmp_path):
                # Create the pyenv-win versions dir
                versions_dir = tmp_path / ".pyenv" / "pyenv-win" / "versions"
                versions_dir.mkdir(parents=True)
                (versions_dir / "3.11.0").mkdir()
                (versions_dir / "3.11.0" / "Scripts").mkdir(parents=True)
                (versions_dir / "3.11.0" / "Scripts" / "python.exe").touch()

                result = em._find_pyenv_versions()

        # Should find the version
        assert len(result) >= 1 or not (versions_dir / "3.11.0").exists()


# ============================================================
# service.py — WINDOWS TASK SCHEDULER EXTENDED
# ============================================================


class TestWindowsServiceExtended:
    """Extended tests for Windows Task Scheduler service."""

    @patch("skop_runner.service.subprocess")
    @patch("skop_runner.service.os")
    @patch("skop_runner.service.tempfile")
    @patch("skop_runner.service.sys")
    def test_install_xml_contains_runner_path(self, mock_sys, mock_tempfile, mock_os, mock_subprocess):
        """XML task definition should reference the runner executable."""
        mock_sys.platform = "win32"
        mock_tempfile.mkstemp.return_value = (99, "C:\\tmp\\task.xml")
        mock_os.fdopen.return_value.__enter__ = lambda s: s
        mock_os.fdopen.return_value.__exit__ = lambda s, *a: None
        mock_subprocess.run.return_value.returncode = 0

        written_data = []
        mock_file = MagicMock()
        mock_file.write = lambda data: written_data.append(data)
        mock_os.fdopen.return_value = MagicMock()
        mock_os.fdopen.return_value.__enter__ = lambda s: mock_file
        mock_os.fdopen.return_value.__exit__ = lambda s, *a: None

        _install_windows(r"C:\Python\Scripts\skop-runner.exe")

        xml_content = "".join(written_data)
        assert r"C:\Python\Scripts\skop-runner.exe" in xml_content
        assert "<Arguments>start</Arguments>" in xml_content

    @patch("skop_runner.service.subprocess")
    @patch("skop_runner.service.os")
    @patch("skop_runner.service.tempfile")
    @patch("skop_runner.service.sys")
    def test_install_cleans_up_temp_file(self, mock_sys, mock_tempfile, mock_os, mock_subprocess):
        """Temp XML file should be deleted after schtasks import."""
        mock_sys.platform = "win32"
        mock_tempfile.mkstemp.return_value = (99, "C:\\tmp\\task.xml")
        mock_os.fdopen.return_value.__enter__ = lambda s: s
        mock_os.fdopen.return_value.__exit__ = lambda s, *a: None
        mock_subprocess.run.return_value.returncode = 0

        _install_windows("runner.exe")

        mock_os.unlink.assert_called_once_with("C:\\tmp\\task.xml")

    @patch("skop_runner.service.subprocess")
    @patch("skop_runner.service.os")
    @patch("skop_runner.service.tempfile")
    @patch("skop_runner.service.sys")
    def test_install_raises_on_schtasks_failure(self, mock_sys, mock_tempfile, mock_os, mock_subprocess):
        """Should raise RuntimeError when schtasks /Create fails."""
        mock_sys.platform = "win32"
        mock_tempfile.mkstemp.return_value = (99, "C:\\tmp\\task.xml")
        mock_os.fdopen.return_value.__enter__ = lambda s: s
        mock_os.fdopen.return_value.__exit__ = lambda s, *a: None
        mock_subprocess.run.return_value.returncode = 1
        mock_subprocess.run.return_value.stderr = "Access denied"

        with pytest.raises(RuntimeError, match="schtasks /Create failed"):
            _install_windows("runner.exe")

    @patch("skop_runner.service.subprocess")
    def test_uninstall_handles_not_found(self, mock_subprocess):
        """When task doesn't exist, should return friendly message."""
        mock_subprocess.run.return_value.returncode = 1
        mock_subprocess.run.return_value.stderr = "ERROR: The system cannot find the file specified."

        # "does not exist" check won't match, but "cannot find" will
        result = _uninstall_windows()
        assert "not installed" in result.lower()

    @patch("skop_runner.service.subprocess")
    def test_uninstall_raises_on_other_errors(self, mock_subprocess):
        """Non-not-found errors should raise RuntimeError."""
        mock_subprocess.run.return_value.returncode = 1
        mock_subprocess.run.return_value.stderr = "Access is denied."

        with pytest.raises(RuntimeError, match="schtasks /Delete failed"):
            _uninstall_windows()

    @patch("skop_runner.service.subprocess")
    def test_status_stopped_task(self, mock_subprocess):
        """Task exists but not running."""
        mock_subprocess.run.return_value.returncode = 0
        mock_subprocess.run.return_value.stdout = '"SkopRunner","Ready"'

        assert _status_windows() == "stopped"

    @patch("skop_runner.service.subprocess")
    def test_status_uses_correct_schtasks_args(self, mock_subprocess):
        """Verify schtasks /Query uses CSV format with no header."""
        mock_subprocess.run.return_value.returncode = 0
        mock_subprocess.run.return_value.stdout = '"SkopRunner","Running"'

        _status_windows()

        args = mock_subprocess.run.call_args[0][0]
        assert "/FO" in args
        assert "CSV" in args
        assert "/NH" in args
        assert "/TN" in args
        assert TASK_NAME in args


# ============================================================
# message_handler.py — BASH EXECUTION CROSS-PLATFORM
# ============================================================


class TestBashExecutionCrossPlatform:
    """Tests for bash execution cross-platform behavior."""

    @pytest.fixture
    def handler(self, tmp_path):
        from skop_runner.message_handler import MessageHandler
        from skop_runner.file_manager import FileManager
        from skop_runner.mock_backend import MockKernelBackend

        backend = MockKernelBackend()
        fm = FileManager(str(tmp_path))
        h = MessageHandler(kernel_backend=backend, file_manager=fm)
        return h

    @pytest.mark.asyncio
    async def test_python_command_works_cross_platform(self, handler):
        """Python -c should work on all platforms."""
        resp = await handler.handle_message({
            "jsonrpc": "2.0", "id": "1",
            "method": "system.bash",
            "params": {"command": f'{sys.executable} -c "print(42)"'},
        })
        result = resp["result"]
        assert result["exit_code"] == 0
        assert "42" in result["stdout"]

    @pytest.mark.asyncio
    async def test_path_separator_in_env(self, handler):
        """PATH should use os.pathsep (: on Unix, ; on Windows)."""
        # Run a command that prints PATH
        cmd = f'{sys.executable} -c "import os; print(os.environ.get(\'PATH\', \'\'))"'
        resp = await handler.handle_message({
            "jsonrpc": "2.0", "id": "1",
            "method": "system.bash",
            "params": {"command": cmd},
        })
        result = resp["result"]
        assert result["exit_code"] == 0
        path_value = result["stdout"].strip()
        # PATH should contain the correct separator
        if sys.platform == "win32":
            assert ";" in path_value
        else:
            assert ":" in path_value

    @pytest.mark.asyncio
    async def test_exit_code_nonzero(self, handler):
        """Non-zero exit code should be captured."""
        resp = await handler.handle_message({
            "jsonrpc": "2.0", "id": "1",
            "method": "system.bash",
            "params": {"command": f'{sys.executable} -c "exit(42)"'},
        })
        result = resp["result"]
        assert result["exit_code"] == 42

    @pytest.mark.asyncio
    async def test_stderr_captured(self, handler):
        """stderr should be captured cross-platform."""
        resp = await handler.handle_message({
            "jsonrpc": "2.0", "id": "1",
            "method": "system.bash",
            "params": {"command": f'{sys.executable} -c "import sys; sys.stderr.write(\'error\\n\')"'},
        })
        result = resp["result"]
        assert "error" in result["stderr"]


# ============================================================
# session_manager — PATH NORMALIZATION IN SESSIONS
# ============================================================


class TestSessionManagerNormalization:
    """Tests for path normalization in session listings."""

    @pytest.mark.asyncio
    async def test_list_sessions_normalizes_notebook_path(self):
        """list_sessions should normalize notebook_path."""
        from skop_runner.session_manager import SessionManager
        from skop_runner.mock_backend import MockKernelBackend

        backend = MockKernelBackend()
        sm = SessionManager(backend)

        # MockKernelBackend uses real async methods, so we set root first
        await backend.set_root_directory("/tmp/test-project")
        session, created = await sm.get_or_create_session("test.ipynb")

        sessions = sm.list_sessions()
        assert len(sessions) == 1
        # On Unix, path should be unchanged. On Windows, backslashes converted.
        notebook_path = sessions[0]["notebook_path"]
        assert "\\" not in notebook_path or sys.platform != "win32"
        assert "test.ipynb" in notebook_path


# ============================================================
# config.py — SECURE CONFIG FILE
# ============================================================


class TestConfigPermissions:
    """Tests for config file permission handling."""

    @pytest.mark.skipif(sys.platform == "win32", reason="Unix permissions")
    def test_config_save_sets_600_permissions(self, tmp_path):
        """Config file should have 600 permissions after save on Unix."""
        from skop_runner.config import RunnerConfig

        config = RunnerConfig(
            device_token="test-token",
            api_url="https://example.com",
            ws_url="wss://example.com",
            log_level="INFO",
            config_dir=tmp_path,
        )
        config.save()

        config_file = tmp_path / "config.json"
        if config_file.exists():
            mode = stat.S_IMODE(os.stat(config_file).st_mode)
            assert mode == 0o600


# Need time for process tests
import time
