"""E2E sandbox tests for PR #4624: lazy torch imports, CPU fallback, install.sh parsing, NO_TORCH filtering, live server."""

from __future__ import annotations

import os
import shutil
import signal
import subprocess
import sys
import textwrap
import time
from pathlib import Path
from unittest import mock

import pytest

REPO_ROOT = Path(__file__).resolve().parents[2]
STUDIO_DIR = REPO_ROOT / "studio"
BACKEND_DIR = STUDIO_DIR / "backend"
DATASETS_DIR = BACKEND_DIR / "utils" / "datasets"
HARDWARE_DIR = BACKEND_DIR / "utils" / "hardware"
INSTALL_SH = REPO_ROOT / "install.sh"
INSTALL_PY = STUDIO_DIR / "install_python_stack.py"

DATA_COLLATORS = DATASETS_DIR / "data_collators.py"
CHAT_TEMPLATES = DATASETS_DIR / "chat_templates.py"
FORMAT_DETECTION = DATASETS_DIR / "format_detection.py"
MODEL_MAPPINGS = DATASETS_DIR / "model_mappings.py"
VLM_PROCESSING = DATASETS_DIR / "vlm_processing.py"
ITERABLE = DATASETS_DIR / "iterable.py"
HARDWARE_PY = HARDWARE_DIR / "hardware.py"

# Studio venv for server tests
STUDIO_VENV = Path.home() / ".unsloth" / "studio" / "unsloth_studio"

sys.path.insert(0, str(STUDIO_DIR))


def _venv_python(venv_dir: Path) -> Path:
    """Return a venv's Python executable path, cross-platform."""
    if sys.platform == "win32":
        return venv_dir / "Scripts" / "python.exe"
    return venv_dir / "bin" / "python"


def _has_uv() -> bool:
    return shutil.which("uv") is not None


def _create_no_torch_venv(venv_dir: Path, python_version: str = "3.12") -> Path | None:
    """Create a uv venv with no torch. Returns python path or None."""
    result = subprocess.run(
        ["uv", "venv", str(venv_dir), "--python", python_version],
        capture_output = True,
    )
    if result.returncode != 0:
        return None
    py = _venv_python(venv_dir)
    if not py.exists():
        return None
    check = subprocess.run([str(py), "-c", "import torch"], capture_output = True)
    if check.returncode == 0:
        return None
    return py


def _run_in_sandbox(
    py: str | Path,
    code: str,
    timeout: int = 60,
    env: dict | None = None,
) -> subprocess.CompletedProcess:
    """Run Python code in a sandboxed interpreter."""
    return subprocess.run(
        [str(py), "-c", code],
        capture_output = True,
        timeout = timeout,
        env = env,
    )


def _run_sh(script: str, timeout: int = 30) -> subprocess.CompletedProcess:
    """Run a bash snippet and return the result."""
    return subprocess.run(
        ["bash", "-c", script],
        capture_output = True,
        timeout = timeout,
    )


def _write_loggers_stub(sandbox: Path) -> None:
    """Create a minimal loggers package stub (replaces the structlog-backed real one)."""
    loggers_dir = sandbox / "loggers"
    loggers_dir.mkdir(exist_ok = True)
    (loggers_dir / "__init__.py").write_text(
        "from .handlers import get_logger\n__all__ = ['get_logger']\n",
        encoding = "utf-8",
    )
    (loggers_dir / "handlers.py").write_text(
        textwrap.dedent("""\
            class _Logger:
                def info(self, msg, *a, **k): pass
                def warning(self, msg, *a, **k): pass
                def debug(self, msg, *a, **k): pass
                def error(self, msg, *a, **k): pass
                def msg(self, msg, *a, **k): pass
            def get_logger(name=None):
                return _Logger()
        """),
        encoding = "utf-8",
    )


def _write_structlog_stub(sandbox: Path) -> None:
    """Create a minimal structlog stub."""
    structlog_dir = sandbox / "structlog"
    structlog_dir.mkdir(exist_ok = True)
    (structlog_dir / "__init__.py").write_text(
        textwrap.dedent("""\
            class _Logger:
                def info(self, msg, *a, **k): pass
                def warning(self, msg, *a, **k): pass
                def debug(self, msg, *a, **k): pass
                def error(self, msg, *a, **k): pass
                def msg(self, msg, *a, **k): pass
            def get_logger(name=None):
                return _Logger()
        """),
        encoding = "utf-8",
    )


def _write_hardware_stub(sandbox: Path) -> None:
    """Create utils/hardware stub with dataset_map_num_proc."""
    hw_dir = sandbox / "utils" / "hardware"
    hw_dir.mkdir(parents = True, exist_ok = True)
    (sandbox / "utils" / "__init__.py").write_text("", encoding = "utf-8")
    (hw_dir / "__init__.py").write_text(
        "def dataset_map_num_proc(n=None): return n\n",
        encoding = "utf-8",
    )


@pytest.fixture(scope = "session")
def repo_root():
    return REPO_ROOT


@pytest.fixture
def sandbox_dir(tmp_path):
    """Per-test temporary sandbox directory."""
    return tmp_path


@pytest.fixture(params = ["3.12", "3.13"], scope = "module")
def no_torch_venv(request, tmp_path_factory):
    """Temporary uv venv with no torch; 3.12 = Intel Mac default, 3.13 = Apple Silicon/Linux."""
    if not _has_uv():
        pytest.skip("uv not available")

    py_version = request.param
    venv_dir = tmp_path_factory.mktemp(f"e2e_no_torch_{py_version}")
    py = _create_no_torch_venv(venv_dir, py_version)
    if py is None:
        pytest.skip(f"Could not create Python {py_version} no-torch venv")
    return str(py)


# Group 1: BEFORE vs AFTER -- Import Chain


class TestBeforeAfterImportChain:
    """BEFORE (synthetic top-level torch import) crashes; AFTER (lazy imports) works."""

    # -- BEFORE: crashes --

    def test_before_chat_templates_crashes(self, no_torch_venv, sandbox_dir):
        """BEFORE: chat_templates.py with top-level IterableDataset import crashes without torch."""
        source = CHAT_TEMPLATES.read_text(encoding = "utf-8")
        before_source = "from torch.utils.data import IterableDataset\n" + source

        before_file = sandbox_dir / "chat_templates_before.py"
        before_file.write_text(before_source, encoding = "utf-8")

        code = textwrap.dedent(f"""\
            import sys, types
            loggers = types.ModuleType('loggers')
            loggers.get_logger = lambda n: type('L', (), {{'info': lambda s, m: None}})()
            sys.modules['loggers'] = loggers
            fd = types.ModuleType('format_detection')
            fd.detect_dataset_format = fd.detect_multimodal_dataset = fd.detect_custom_format_heuristic = lambda *a, **k: None
            sys.modules['format_detection'] = fd
            mm = types.ModuleType('model_mappings')
            mm.MODEL_TO_TEMPLATE_MAPPER = {{}}
            sys.modules['model_mappings'] = mm
            source = open({str(before_file)!r}).read()
            source = source.replace('from .format_detection import', 'from format_detection import')
            source = source.replace('from .model_mappings import', 'from model_mappings import')
            exec(source)
        """)
        result = _run_in_sandbox(no_torch_venv, code)
        assert result.returncode != 0, "BEFORE chat_templates.py should crash without torch"
        assert b"ModuleNotFoundError" in result.stderr or b"ImportError" in result.stderr

    def test_before_data_collators_crashes(self, no_torch_venv, sandbox_dir):
        """BEFORE: data_collators.py with top-level 'import torch' crashes."""
        source = DATA_COLLATORS.read_text(encoding = "utf-8")
        before_source = "import torch\n" + source

        before_file = sandbox_dir / "data_collators_before.py"
        before_file.write_text(before_source, encoding = "utf-8")

        code = textwrap.dedent(f"""\
            import sys, types
            loggers = types.ModuleType('loggers')
            loggers.get_logger = lambda n: None
            sys.modules['loggers'] = loggers
            exec(open({str(before_file)!r}).read())
        """)
        result = _run_in_sandbox(no_torch_venv, code)
        assert result.returncode != 0, "BEFORE data_collators.py should crash without torch"
        assert b"ModuleNotFoundError" in result.stderr or b"ImportError" in result.stderr

    def test_before_full_import_chain_crashes(self, no_torch_venv, sandbox_dir):
        """BEFORE: full utils/datasets/ package with top-level torch imports crashes."""
        _write_loggers_stub(sandbox_dir)
        _write_hardware_stub(sandbox_dir)

        pkg_dir = sandbox_dir / "utils" / "datasets"
        pkg_dir.mkdir(parents = True, exist_ok = True)

        # Copy torch-free modules as-is
        shutil.copy2(FORMAT_DETECTION, pkg_dir / "format_detection.py")
        shutil.copy2(MODEL_MAPPINGS, pkg_dir / "model_mappings.py")
        shutil.copy2(VLM_PROCESSING, pkg_dir / "vlm_processing.py")

        # BEFORE data_collators: prepend top-level 'import torch'
        dc_source = DATA_COLLATORS.read_text(encoding = "utf-8")
        (pkg_dir / "data_collators.py").write_text(
            "import torch\n" + dc_source,
            encoding = "utf-8",
        )

        # BEFORE chat_templates: prepend top-level IterableDataset import
        ct_source = CHAT_TEMPLATES.read_text(encoding = "utf-8")
        (pkg_dir / "chat_templates.py").write_text(
            "from torch.utils.data import IterableDataset\n" + ct_source,
            encoding = "utf-8",
        )

        (pkg_dir / "__init__.py").write_text(
            textwrap.dedent("""\
                from .format_detection import detect_dataset_format
                from .data_collators import DataCollatorSpeechSeq2SeqWithPadding
                from .chat_templates import DEFAULT_ALPACA_TEMPLATE
            """),
            encoding = "utf-8",
        )

        code = textwrap.dedent(f"""\
            import sys
            sys.path.insert(0, {str(sandbox_dir)!r})
            from utils.datasets import detect_dataset_format
        """)
        result = _run_in_sandbox(no_torch_venv, code)
        assert result.returncode != 0, "BEFORE full import chain should crash without torch"
        assert b"ModuleNotFoundError" in result.stderr or b"ImportError" in result.stderr

    # -- AFTER: succeeds --

    def test_after_chat_templates_imports(self, no_torch_venv):
        """AFTER: PR branch chat_templates.py imports fine without torch."""
        code = textwrap.dedent(f"""\
            import sys, types
            loggers = types.ModuleType('loggers')
            loggers.get_logger = lambda n: type('L', (), {{'info': lambda s, m: None}})()
            sys.modules['loggers'] = loggers
            fd = types.ModuleType('format_detection')
            fd.detect_dataset_format = fd.detect_multimodal_dataset = fd.detect_custom_format_heuristic = lambda *a, **k: None
            sys.modules['format_detection'] = fd
            mm = types.ModuleType('model_mappings')
            mm.MODEL_TO_TEMPLATE_MAPPER = {{}}
            sys.modules['model_mappings'] = mm
            it = types.ModuleType('iterable')
            it.is_streaming_dataset = lambda *a, **k: False
            sys.modules['iterable'] = it
            source = open({str(CHAT_TEMPLATES)!r}).read()
            source = source.replace('from .format_detection import', 'from format_detection import')
            source = source.replace('from .model_mappings import', 'from model_mappings import')
            source = source.replace('from .iterable import', 'from iterable import')
            exec(source)
            print("OK")
        """)
        result = _run_in_sandbox(no_torch_venv, code)
        assert (
            result.returncode == 0
        ), f"AFTER chat_templates.py should work without torch:\n{result.stderr.decode()}"
        assert b"OK" in result.stdout

    def test_after_data_collators_imports(self, no_torch_venv):
        """AFTER: PR branch data_collators.py imports fine without torch."""
        code = textwrap.dedent(f"""\
            import sys, types
            loggers = types.ModuleType('loggers')
            loggers.get_logger = lambda n: None
            sys.modules['loggers'] = loggers
            exec(open({str(DATA_COLLATORS)!r}).read())
            print("OK")
        """)
        result = _run_in_sandbox(no_torch_venv, code)
        assert (
            result.returncode == 0
        ), f"AFTER data_collators.py should work without torch:\n{result.stderr.decode()}"
        assert b"OK" in result.stdout

    def test_after_full_import_chain_imports(self, no_torch_venv, sandbox_dir):
        """AFTER: full utils/datasets/ package imports fine without torch."""
        _write_loggers_stub(sandbox_dir)
        _write_hardware_stub(sandbox_dir)

        pkg_dir = sandbox_dir / "utils" / "datasets"
        pkg_dir.mkdir(parents = True, exist_ok = True)

        # Copy AFTER versions (PR branch -- no top-level torch)
        for src in [
            FORMAT_DETECTION,
            MODEL_MAPPINGS,
            VLM_PROCESSING,
            DATA_COLLATORS,
            CHAT_TEMPLATES,
            ITERABLE,
        ]:
            if src.exists():
                shutil.copy2(src, pkg_dir / src.name)

        (pkg_dir / "__init__.py").write_text(
            textwrap.dedent("""\
                from .format_detection import detect_dataset_format, detect_custom_format_heuristic
                from .model_mappings import MODEL_TO_TEMPLATE_MAPPER
                from .chat_templates import DEFAULT_ALPACA_TEMPLATE, get_dataset_info_summary
                from .data_collators import (
                    DataCollatorSpeechSeq2SeqWithPadding,
                    DeepSeekOCRDataCollator,
                    VLMDataCollator,
                )
                from .vlm_processing import generate_smart_vlm_instruction
            """),
            encoding = "utf-8",
        )

        code = textwrap.dedent(f"""\
            import sys
            sys.path.insert(0, {str(sandbox_dir)!r})
            from utils.datasets import (
                detect_dataset_format,
                DEFAULT_ALPACA_TEMPLATE,
                DataCollatorSpeechSeq2SeqWithPadding,
                DeepSeekOCRDataCollator,
                VLMDataCollator,
                generate_smart_vlm_instruction,
            )
            assert 'Instruction' in DEFAULT_ALPACA_TEMPLATE
            print("OK: full import chain succeeded")
        """)
        result = _run_in_sandbox(no_torch_venv, code)
        assert (
            result.returncode == 0
        ), f"AFTER full import chain should work:\n{result.stderr.decode()}"
        assert b"OK: full import chain succeeded" in result.stdout


# Group 2: Dataclass Instantiation


class TestDataclassInstantiation:
    """Dataclass collators instantiate and constants are accessible without torch."""

    def test_speech_collator_instantiate(self, no_torch_venv):
        """DataCollatorSpeechSeq2SeqWithPadding(processor=None) succeeds."""
        code = textwrap.dedent(f"""\
            import sys, types
            loggers = types.ModuleType('loggers')
            loggers.get_logger = lambda n: None
            sys.modules['loggers'] = loggers
            exec(open({str(DATA_COLLATORS)!r}).read())
            obj = DataCollatorSpeechSeq2SeqWithPadding(processor=None)
            assert obj.processor is None
            print("OK")
        """)
        result = _run_in_sandbox(no_torch_venv, code)
        assert result.returncode == 0, f"Failed:\n{result.stderr.decode()}"

    def test_deepseek_ocr_collator_instantiate(self, no_torch_venv):
        """DeepSeekOCRDataCollator has correct default field values."""
        code = textwrap.dedent(f"""\
            import sys, types
            loggers = types.ModuleType('loggers')
            loggers.get_logger = lambda n: None
            sys.modules['loggers'] = loggers
            exec(open({str(DATA_COLLATORS)!r}).read())
            obj = DeepSeekOCRDataCollator(processor=None)
            assert obj.processor is None
            assert obj.max_length == 2048
            assert obj.ignore_index == -100
            print("OK")
        """)
        result = _run_in_sandbox(no_torch_venv, code)
        assert result.returncode == 0, f"Failed:\n{result.stderr.decode()}"

    def test_vlm_collator_instantiate(self, no_torch_venv):
        """VLMDataCollator has correct default field values."""
        code = textwrap.dedent(f"""\
            import sys, types
            loggers = types.ModuleType('loggers')
            loggers.get_logger = lambda n: None
            sys.modules['loggers'] = loggers
            exec(open({str(DATA_COLLATORS)!r}).read())
            obj = VLMDataCollator(processor=None)
            assert obj.processor is None
            assert obj.max_length == 2048
            assert obj.mask_input_tokens is True
            print("OK")
        """)
        result = _run_in_sandbox(no_torch_venv, code)
        assert result.returncode == 0, f"Failed:\n{result.stderr.decode()}"

    def test_alpaca_template_accessible(self, no_torch_venv):
        """DEFAULT_ALPACA_TEMPLATE constant is accessible and contains 'Instruction'."""
        code = textwrap.dedent(f"""\
            import sys, types
            loggers = types.ModuleType('loggers')
            loggers.get_logger = lambda n: type('L', (), {{'info': lambda s, m: None}})()
            sys.modules['loggers'] = loggers
            fd = types.ModuleType('format_detection')
            fd.detect_dataset_format = fd.detect_multimodal_dataset = fd.detect_custom_format_heuristic = lambda *a, **k: None
            sys.modules['format_detection'] = fd
            mm = types.ModuleType('model_mappings')
            mm.MODEL_TO_TEMPLATE_MAPPER = {{}}
            sys.modules['model_mappings'] = mm
            it = types.ModuleType('iterable')
            it.is_streaming_dataset = lambda *a, **k: False
            sys.modules['iterable'] = it
            ns = {{}}
            source = open({str(CHAT_TEMPLATES)!r}).read()
            source = source.replace('from .format_detection import', 'from format_detection import')
            source = source.replace('from .model_mappings import', 'from model_mappings import')
            source = source.replace('from .iterable import', 'from iterable import')
            exec(source, ns)
            assert 'Instruction' in ns['DEFAULT_ALPACA_TEMPLATE']
            print("OK")
        """)
        result = _run_in_sandbox(no_torch_venv, code)
        assert result.returncode == 0, f"Failed:\n{result.stderr.decode()}"


# Group 3: Edge Cases -- Partial/Broken Torch


class TestEdgeCasesBrokenTorch:
    """Behavior with fake or broken torch modules on sys.path."""

    def test_fake_broken_torch_module(self, no_torch_venv, sandbox_dir):
        """Fake torch raising RuntimeError on import: data_collators.py (no top-level torch) still loads."""
        torch_dir = sandbox_dir / "torch"
        torch_dir.mkdir()
        (torch_dir / "__init__.py").write_text(
            'raise RuntimeError("CUDA not found")\n',
            encoding = "utf-8",
        )
        _write_loggers_stub(sandbox_dir)
        shutil.copy2(DATA_COLLATORS, sandbox_dir / "data_collators.py")

        code = textwrap.dedent(f"""\
            import sys
            sys.path.insert(0, {str(sandbox_dir)!r})
            exec(open({str(sandbox_dir / 'data_collators.py')!r}).read())
            obj = DataCollatorSpeechSeq2SeqWithPadding(processor=None)
            print("OK: data_collators works despite broken torch on sys.path")
        """)
        result = _run_in_sandbox(no_torch_venv, code)
        assert result.returncode == 0, f"Should work with broken torch:\n{result.stderr.decode()}"
        assert b"OK:" in result.stdout

    def test_torch_import_error_hardware_fallback(self, no_torch_venv, sandbox_dir):
        """Fake torch raising ImportError: detect_hardware() falls back to CPU."""
        torch_dir = sandbox_dir / "torch"
        torch_dir.mkdir()
        (torch_dir / "__init__.py").write_text(
            'raise ImportError("No torch binary")\n',
            encoding = "utf-8",
        )
        _write_loggers_stub(sandbox_dir)
        _write_structlog_stub(sandbox_dir)

        code = textwrap.dedent(f"""\
            import sys
            sys.path.insert(0, {str(sandbox_dir)!r})
            source = open({str(HARDWARE_PY)!r}).read()
            ns = {{'__name__': '__test__'}}
            exec(source, ns)
            result = ns['detect_hardware']()
            assert result == ns['DeviceType'].CPU, f"Expected CPU, got {{result}}"
            print("OK: detect_hardware returned CPU")
        """)
        result = _run_in_sandbox(no_torch_venv, code)
        assert (
            result.returncode == 0
        ), f"detect_hardware should fallback to CPU:\n{result.stderr.decode()}"
        assert b"OK: detect_hardware returned CPU" in result.stdout

    def test_fake_torch_no_cuda(self, no_torch_venv, sandbox_dir):
        """Fake torch imports OK but cuda.is_available() is False: detect_hardware() falls back to CPU."""
        torch_dir = sandbox_dir / "torch"
        torch_dir.mkdir()
        (torch_dir / "__init__.py").write_text(
            textwrap.dedent("""\
                class _Cuda:
                    @staticmethod
                    def is_available():
                        return False
                cuda = _Cuda()
                class version:
                    cuda = None
            """),
            encoding = "utf-8",
        )
        _write_loggers_stub(sandbox_dir)
        _write_structlog_stub(sandbox_dir)

        code = textwrap.dedent(f"""\
            import sys
            sys.path.insert(0, {str(sandbox_dir)!r})
            source = open({str(HARDWARE_PY)!r}).read()
            ns = {{'__name__': '__test__'}}
            exec(source, ns)
            result = ns['detect_hardware']()
            assert result == ns['DeviceType'].CPU, f"Expected CPU, got {{result}}"
            print("OK: detect_hardware returned CPU with fake torch (no CUDA)")
        """)
        result = _run_in_sandbox(no_torch_venv, code)
        assert result.returncode == 0, f"Should fall back to CPU:\n{result.stderr.decode()}"
        assert b"OK:" in result.stdout

    def test_lazy_torch_fails_at_call_time_not_import_time(self, no_torch_venv, sandbox_dir):
        """apply_chat_template_to_dataset imports without torch; the lazy import fails at call time, not import time."""
        _write_loggers_stub(sandbox_dir)

        code = textwrap.dedent(f"""\
            import sys, types
            sys.path.insert(0, {str(sandbox_dir)!r})
            fd = types.ModuleType('format_detection')
            fd.detect_dataset_format = fd.detect_multimodal_dataset = fd.detect_custom_format_heuristic = lambda *a, **k: None
            sys.modules['format_detection'] = fd
            mm = types.ModuleType('model_mappings')
            mm.MODEL_TO_TEMPLATE_MAPPER = {{}}
            sys.modules['model_mappings'] = mm
            it = types.ModuleType('iterable')
            it.is_streaming_dataset = lambda *a, **k: False
            sys.modules['iterable'] = it

            ns = {{}}
            source = open({str(CHAT_TEMPLATES)!r}).read()
            source = source.replace('from .format_detection import', 'from format_detection import')
            source = source.replace('from .model_mappings import', 'from model_mappings import')
            source = source.replace('from .iterable import', 'from iterable import')
            exec(source, ns)

            # Import succeeds -- this is the fix
            assert 'apply_chat_template_to_dataset' in ns
            print("OK: import succeeded")

            # Calling alpaca branch triggers lazy torch import inside the try block.
            # The function catches the error and returns it in the errors list.
            dataset_info = {{
                'dataset': type('D', (), {{'map': lambda *a, **k: None}})(),
                'final_format': 'alpaca',
                'chat_column': None,
                'is_standardized': True,
                'warnings': [],
            }}
            result = ns['apply_chat_template_to_dataset'](dataset_info, None)
            # The function has a try/except that catches the error gracefully
            if not result['success']:
                print("OK: call-time failure caught gracefully")
            else:
                print("OK: call succeeded (unexpected but not a crash)")
        """)
        result = _run_in_sandbox(no_torch_venv, code)
        assert result.returncode == 0, f"Should not crash at import time:\n{result.stderr.decode()}"
        assert b"OK: import succeeded" in result.stdout


# Group 4: Hardware Detection Without Torch


class TestHardwareDetectionNoTorch:
    """Hardware module works without torch, falling back to CPU."""

    def test_detect_hardware_no_torch(self, no_torch_venv, sandbox_dir):
        """detect_hardware() returns CPU when torch is not installed."""
        _write_loggers_stub(sandbox_dir)
        _write_structlog_stub(sandbox_dir)

        code = textwrap.dedent(f"""\
            import sys
            sys.path.insert(0, {str(sandbox_dir)!r})
            source = open({str(HARDWARE_PY)!r}).read()
            ns = {{'__name__': '__test__'}}
            exec(source, ns)
            device = ns['detect_hardware']()
            assert device == ns['DeviceType'].CPU
            assert ns['CHAT_ONLY'] is True
            print("OK: detect_hardware returned CPU, CHAT_ONLY=True")
        """)
        result = _run_in_sandbox(no_torch_venv, code)
        assert result.returncode == 0, f"Failed:\n{result.stderr.decode()}"
        assert b"OK:" in result.stdout

    def test_get_package_versions_no_torch(self, no_torch_venv, sandbox_dir):
        """get_package_versions() returns torch=None, cuda=None without torch."""
        _write_loggers_stub(sandbox_dir)
        _write_structlog_stub(sandbox_dir)

        code = textwrap.dedent(f"""\
            import sys
            sys.path.insert(0, {str(sandbox_dir)!r})
            source = open({str(HARDWARE_PY)!r}).read()
            ns = {{'__name__': '__test__'}}
            exec(source, ns)
            versions = ns['get_package_versions']()
            assert versions['torch'] is None, f"Expected torch=None, got {{versions['torch']}}"
            assert versions['cuda'] is None, f"Expected cuda=None, got {{versions['cuda']}}"
            print("OK: torch=None, cuda=None")
        """)
        result = _run_in_sandbox(no_torch_venv, code)
        assert result.returncode == 0, f"Failed:\n{result.stderr.decode()}"
        assert b"OK:" in result.stdout

    def test_hardware_module_import_no_torch(self, no_torch_venv, sandbox_dir):
        """Hardware module imports and detect_hardware is callable without torch."""
        _write_loggers_stub(sandbox_dir)
        _write_structlog_stub(sandbox_dir)
        _write_hardware_stub(sandbox_dir)

        # Copy the real hardware module into a sandbox package
        hw_sandbox = sandbox_dir / "hw_pkg"
        hw_sandbox.mkdir()
        (hw_sandbox / "__init__.py").write_text("", encoding = "utf-8")
        shutil.copy2(HARDWARE_PY, hw_sandbox / "hardware.py")

        code = textwrap.dedent(f"""\
            import sys
            sys.path.insert(0, {str(sandbox_dir)!r})
            source = open({str(hw_sandbox / 'hardware.py')!r}).read()
            ns = {{'__name__': '__test__'}}
            exec(source, ns)
            assert callable(ns['detect_hardware'])
            assert callable(ns['get_package_versions'])
            assert callable(ns['is_apple_silicon'])
            print("OK: all hardware functions accessible")
        """)
        result = _run_in_sandbox(no_torch_venv, code)
        assert result.returncode == 0, f"Failed:\n{result.stderr.decode()}"
        assert b"OK:" in result.stdout


# Group 5: install.sh Logic (via bash subprocess)


class TestInstallShLogic:
    """install.sh flag parsing, platform detection, and guard logic."""

    @pytest.fixture(autouse = True)
    def _check_install_sh(self):
        if not INSTALL_SH.is_file():
            pytest.skip("install.sh not found")

    def test_python_flag_parsing(self):
        """--python flag correctly sets _USER_PYTHON."""
        script = textwrap.dedent("""\
            _USER_PYTHON=""
            _next_is_python=false
            for arg in "$@"; do
                if [ "$_next_is_python" = true ]; then
                    _USER_PYTHON="$arg"
                    _next_is_python=false
                    continue
                fi
                case "$arg" in
                    --python) _next_is_python=true ;;
                esac
            done
            echo "$_USER_PYTHON"
        """)
        # --python 3.12
        r = _run_sh(f"{script}" + "\n", timeout = 10)
        r = subprocess.run(
            ["bash", "-c", script + "\n", "_", "--python", "3.12"],
            capture_output = True,
            timeout = 10,
        )
        assert r.stdout.strip() == b"3.12"

        # --local --python 3.11
        r = subprocess.run(
            ["bash", "-c", script + "\n", "_", "--local", "--python", "3.11"],
            capture_output = True,
            timeout = 10,
        )
        assert r.stdout.strip() == b"3.11"

        # no --python flag
        r = subprocess.run(
            ["bash", "-c", script + "\n", "_", "--local"],
            capture_output = True,
            timeout = 10,
        )
        assert r.stdout.strip() == b""

    def test_python_flag_missing_arg_errors(self):
        """--python without a version argument triggers an error."""
        script = textwrap.dedent("""\
            set -e
            _USER_PYTHON=""
            _next_is_python=false
            for arg in "$@"; do
                if [ "$_next_is_python" = true ]; then
                    _USER_PYTHON="$arg"
                    _next_is_python=false
                    continue
                fi
                case "$arg" in
                    --python) _next_is_python=true ;;
                esac
            done
            if [ "$_next_is_python" = true ]; then
                echo "ERROR: --python requires a version argument" >&2
                exit 1
            fi
            echo "$_USER_PYTHON"
        """)
        r = subprocess.run(
            ["bash", "-c", script + "\n", "_", "--python"],
            capture_output = True,
            timeout = 10,
        )
        assert r.returncode != 0
        assert b"ERROR" in r.stderr

    def test_python_version_resolution(self):
        """Python version defaults to 3.12 on Intel Mac, 3.13 elsewhere.
        --python overrides both."""
        script = textwrap.dedent("""\
            MAC_INTEL="$1"
            _USER_PYTHON="$2"

            if [ -n "$_USER_PYTHON" ]; then
                PYTHON_VERSION="$_USER_PYTHON"
            elif [ "$MAC_INTEL" = true ]; then
                PYTHON_VERSION="3.12"
            else
                PYTHON_VERSION="3.13"
            fi
            echo "$PYTHON_VERSION"
        """)
        # Intel Mac, no override
        r = subprocess.run(
            ["bash", "-c", script + "\n", "_", "true", ""],
            capture_output = True,
            timeout = 10,
        )
        assert r.stdout.strip() == b"3.12"

        # non-Intel, no override
        r = subprocess.run(
            ["bash", "-c", script + "\n", "_", "false", ""],
            capture_output = True,
            timeout = 10,
        )
        assert r.stdout.strip() == b"3.13"

        # Intel Mac with --python override
        r = subprocess.run(
            ["bash", "-c", script + "\n", "_", "true", "3.11"],
            capture_output = True,
            timeout = 10,
        )
        assert r.stdout.strip() == b"3.11"

    def test_mac_intel_detection_snippet(self):
        """Architecture detection sets MAC_INTEL correctly for different platforms."""
        script = textwrap.dedent("""\
            OS="$1"
            _ARCH="$2"
            MAC_INTEL=false
            if [ "$OS" = "macos" ] && [ "$_ARCH" = "x86_64" ]; then
                MAC_INTEL=true
            fi
            echo "$MAC_INTEL"
        """)
        cases = [
            (("macos", "x86_64"), b"true"),
            (("macos", "arm64"), b"false"),
            (("linux", "x86_64"), b"false"),
            (("linux", "aarch64"), b"false"),
        ]
        for (os_val, arch), expected in cases:
            r = subprocess.run(
                ["bash", "-c", script + "\n", "_", os_val, arch],
                capture_output = True,
                timeout = 10,
            )
            assert r.stdout.strip() == expected, (
                f"MAC_INTEL for ({os_val}, {arch}): "
                f"expected {expected!r}, got {r.stdout.strip()!r}"
            )

    def test_stale_venv_guard_respects_override(self):
        """When _USER_PYTHON is set, the stale venv recreation guard is skipped."""
        script = textwrap.dedent("""\
            MAC_INTEL=true
            _USER_PYTHON="$1"
            _VENV_EXISTS=true  # simulate existing venv

            SHOULD_RECREATE=false
            if [ "$MAC_INTEL" = true ] && [ -z "$_USER_PYTHON" ] && [ "$_VENV_EXISTS" = true ]; then
                SHOULD_RECREATE=true
            fi
            echo "$SHOULD_RECREATE"
        """)
        # with override: should NOT recreate
        r = subprocess.run(
            ["bash", "-c", script + "\n", "_", "3.11"],
            capture_output = True,
            timeout = 10,
        )
        assert r.stdout.strip() == b"false"

        # without override: SHOULD recreate
        r = subprocess.run(
            ["bash", "-c", script + "\n", "_", ""],
            capture_output = True,
            timeout = 10,
        )
        assert r.stdout.strip() == b"true"


# Group 6: install_python_stack.py NO_TORCH Filtering


class TestInstallPythonStackFiltering:
    """NO_TORCH filtering logic in install_python_stack.py."""

    @pytest.fixture(autouse = True)
    def _check_install_py(self):
        if not INSTALL_PY.is_file():
            pytest.skip("install_python_stack.py not found")

    def test_filter_requirements_removes_torch_deps(self):
        """_filter_requirements removes all NO_TORCH_SKIP_PACKAGES from a real extras file."""
        import install_python_stack as ips

        extras = STUDIO_DIR / "backend" / "requirements" / "extras.txt"
        if not extras.is_file():
            pytest.skip("extras.txt not found")

        result_path = ips._filter_requirements(extras, ips.NO_TORCH_SKIP_PACKAGES)
        filtered = Path(result_path).read_text(encoding = "utf-8").lower()
        lines = [
            l.strip() for l in filtered.splitlines() if l.strip() and not l.strip().startswith("#")
        ]

        for pkg in ips.NO_TORCH_SKIP_PACKAGES:
            assert not any(
                l.startswith(pkg) for l in lines
            ), f"{pkg} should be removed from extras.txt"

    def test_filter_requirements_preserves_non_torch(self):
        """Non-torch packages survive NO_TORCH filtering."""
        import install_python_stack as ips

        extras = STUDIO_DIR / "backend" / "requirements" / "extras.txt"
        if not extras.is_file():
            pytest.skip("extras.txt not found")

        result_path = ips._filter_requirements(extras, ips.NO_TORCH_SKIP_PACKAGES)
        filtered_text = Path(result_path).read_text(encoding = "utf-8").lower()

        must_survive = ["scikit-learn", "loguru", "tiktoken", "einops"]
        original_text = extras.read_text(encoding = "utf-8").lower()
        for pkg in must_survive:
            if pkg in original_text:
                assert pkg in filtered_text, f"{pkg} should survive NO_TORCH filtering"

    def test_infer_no_torch_env_var_overrides_platform(self):
        """UNSLOTH_NO_TORCH=true on Linux -> True; =false on Intel Mac -> False."""
        import install_python_stack as ips

        # Explicit true on Linux
        with (
            mock.patch.dict(os.environ, {"UNSLOTH_NO_TORCH": "true"}),
            mock.patch.object(ips, "IS_MAC_INTEL", False),
        ):
            assert ips._infer_no_torch() is True

        # explicit false on Intel Mac
        with (
            mock.patch.dict(os.environ, {"UNSLOTH_NO_TORCH": "false"}),
            mock.patch.object(ips, "IS_MAC_INTEL", True),
        ):
            assert ips._infer_no_torch() is False

        # Unset on Intel Mac -> True (platform fallback)
        env = os.environ.copy()
        env.pop("UNSLOTH_NO_TORCH", None)
        with (
            mock.patch.dict(os.environ, env, clear = True),
            mock.patch.object(ips, "IS_MAC_INTEL", True),
        ):
            assert ips._infer_no_torch() is True

    def test_no_torch_skips_overrides_and_triton(self):
        """When NO_TORCH=True, overrides.txt and triton are skipped (source guard check)."""
        import install_python_stack as ips

        source = Path(ips.__file__).read_text(encoding = "utf-8")

        assert "if NO_TORCH:" in source, "NO_TORCH guard not found in install_python_stack.py"

        # macOS guard for triton
        assert (
            "not IS_WINDOWS and not IS_MACOS" in source
        ), "'not IS_WINDOWS and not IS_MACOS' guard for triton not found"


# Group 7: Live Server Startup -- Heavyweight


def _studio_venv_python() -> Path | None:
    """Return the studio venv Python path, or None if not found."""
    py = _venv_python(STUDIO_VENV)
    if py.exists():
        return py
    return None


def _server_port() -> int:
    """Find an available port for the test server."""
    import socket
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.bind(("", 0))
        return s.getsockname()[1]


server = pytest.mark.server


@server
class TestLiveServerStartup:
    """Live server startup against the existing Studio venv with torch made unimportable (pytest -m server)."""

    @pytest.fixture(autouse = True)
    def _check_studio_venv(self):
        py = _studio_venv_python()
        if py is None:
            pytest.skip("Studio venv not found at ~/.unsloth/studio/unsloth_studio")

    @pytest.fixture(scope = "class")
    def server_process(self):
        """Start the studio backend server without torch, yield (proc, port), then stop."""
        py = _studio_venv_python()
        if py is None:
            pytest.skip("Studio venv not found")

        port = _server_port()
        backend_dir = BACKEND_DIR

        check = subprocess.run(
            [str(py), "-c", "import torch; print(torch.__version__)"],
            capture_output = True,
        )
        torch_was_installed = check.returncode == 0
        torch_version = check.stdout.decode().strip() if torch_was_installed else None

        if torch_was_installed:
            subprocess.run(
                [
                    str(py),
                    "-m",
                    "pip",
                    "uninstall",
                    "-y",
                    "torch",
                    "torchvision",
                    "torchaudio",
                ],
                capture_output = True,
                timeout = 120,
            )

        env = os.environ.copy()
        env["PYTHONPATH"] = str(backend_dir)
        proc = subprocess.Popen(
            [str(py), str(backend_dir / "run.py"), "--port", str(port)],
            env = env,
            stdout = subprocess.PIPE,
            stderr = subprocess.PIPE,
            cwd = str(backend_dir),
        )

        # Wait for server to be ready (poll /api/health)
        import urllib.request
        import urllib.error

        ready = False
        for _ in range(30):
            time.sleep(1)
            try:
                resp = urllib.request.urlopen(f"http://127.0.0.1:{port}/api/health", timeout = 2)
                if resp.status == 200:
                    ready = True
                    break
            except (urllib.error.URLError, ConnectionRefusedError, OSError):
                continue

        if not ready:
            stdout, stderr = proc.communicate(timeout = 5)
            if torch_was_installed and torch_version:
                subprocess.run(
                    [
                        str(py),
                        "-m",
                        "pip",
                        "install",
                        f"torch=={torch_version}",
                        "torchvision",
                        "torchaudio",
                    ],
                    capture_output = True,
                    timeout = 300,
                )
            server_output = stdout.decode(errors = "replace") + stderr.decode(errors = "replace")
            pytest.skip(f"Server failed to start within 30 seconds. Output:\n{server_output}")

        yield proc, port

        # Cleanup: stop server, reinstall torch
        proc.terminate()
        try:
            proc.wait(timeout = 10)
        except subprocess.TimeoutExpired:
            proc.kill()
            proc.wait(timeout = 5)

        if torch_was_installed and torch_version:
            subprocess.run(
                [
                    str(py),
                    "-m",
                    "pip",
                    "install",
                    f"torch=={torch_version}",
                    "torchvision",
                    "torchaudio",
                ],
                capture_output = True,
                timeout = 300,
            )

    def test_server_starts_without_torch(self, server_process):
        """Server responds to /api/health with chat_only: true."""
        import json
        import urllib.request

        _, port = server_process
        resp = urllib.request.urlopen(f"http://127.0.0.1:{port}/api/health", timeout = 5)
        data = json.loads(resp.read())
        assert data["status"] == "healthy"
        assert data["chat_only"] is True

    def test_all_routes_registered(self, server_process):
        """OpenAPI spec shows >= 20 paths (server started fully)."""
        import json
        import urllib.request

        _, port = server_process
        resp = urllib.request.urlopen(f"http://127.0.0.1:{port}/openapi.json", timeout = 5)
        spec = json.loads(resp.read())
        assert (
            len(spec.get("paths", {})) >= 20
        ), f"Expected >= 20 routes, got {len(spec.get('paths', {}))}"

    def test_hardware_endpoint_no_torch(self, server_process):
        """GET /api/system/hardware returns torch=null, gpu_name=null."""
        import json
        import urllib.request

        _, port = server_process
        resp = urllib.request.urlopen(
            f"http://127.0.0.1:{port}/api/system/hardware",
            timeout = 5,
        )
        data = json.loads(resp.read())
        versions = data.get("versions", {})
        assert versions.get("torch") is None
        assert versions.get("cuda") is None

    def test_server_survives_multiple_requests(self, server_process):
        """Hit 5 different endpoints. Server PID should still be alive after."""
        import urllib.request
        import urllib.error

        proc, port = server_process
        endpoints = [
            "/api/health",
            "/openapi.json",
            "/api/system/hardware",
            "/api/health",
            "/docs",
        ]
        for ep in endpoints:
            try:
                urllib.request.urlopen(f"http://127.0.0.1:{port}{ep}", timeout = 5)
            except urllib.error.HTTPError:
                pass  # 4xx/5xx fine -- server didn't crash
            except urllib.error.URLError:
                pytest.fail(f"Server stopped responding at {ep}")

        assert proc.poll() is None, "Server process should still be running"
