"""Tests for preset inference auto-tuning."""

from __future__ import annotations

from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest
from click.testing import CliRunner

from yowo.cli._main import cli
from yowo.config import classify_device, classify_source, preset_config
from yowo.errors import ConfigError
from yowo.hardware import HardwareProfile
from yowo.hardware._capabilities import InstalledLibraries
from yowo.hardware._device import Device
from yowo.types import (
    BackendType,
    CPUArch,
    DeviceCategory,
    DeviceType,
    FrameDropPolicy,
    GPUArch,
    SourceCategory,
)


class TestSourceCategory:
    def test_values(self) -> None:
        assert SourceCategory.IMAGE == "image"
        assert SourceCategory.VIDEO == "video"
        assert SourceCategory.LIVE_STREAM == "live"

    def test_is_str_enum(self) -> None:
        assert isinstance(SourceCategory.IMAGE, str)


class TestDeviceCategory:
    def test_values(self) -> None:
        assert DeviceCategory.CUDA_HIGH == "cuda_high"
        assert DeviceCategory.CUDA_LOW == "cuda_low"
        assert DeviceCategory.JETSON == "jetson"
        assert DeviceCategory.APPLE_SILICON == "apple_silicon"
        assert DeviceCategory.CPU_X86 == "cpu_x86"
        assert DeviceCategory.CPU_ARM == "cpu_arm"

    def test_is_str_enum(self) -> None:
        assert isinstance(DeviceCategory.CUDA_HIGH, str)


# ---------------------------------------------------------------------------
# classify_source tests
# ---------------------------------------------------------------------------


class TestClassifySource:
    def test_image_jpg(self) -> None:
        assert classify_source("photo.jpg") == SourceCategory.IMAGE

    def test_image_png(self) -> None:
        assert classify_source(Path("photo.png")) == SourceCategory.IMAGE

    def test_image_bmp(self) -> None:
        assert classify_source("scan.bmp") == SourceCategory.IMAGE

    def test_image_webp(self) -> None:
        assert classify_source("img.webp") == SourceCategory.IMAGE

    def test_image_jpeg(self) -> None:
        assert classify_source("photo.jpeg") == SourceCategory.IMAGE

    def test_video_mp4(self) -> None:
        assert classify_source("clip.mp4") == SourceCategory.VIDEO

    def test_video_avi(self) -> None:
        assert classify_source("clip.avi") == SourceCategory.VIDEO

    def test_video_mov(self) -> None:
        assert classify_source(Path("clip.mov")) == SourceCategory.VIDEO

    def test_video_mkv(self) -> None:
        assert classify_source("clip.mkv") == SourceCategory.VIDEO

    def test_video_ts(self) -> None:
        assert classify_source("stream.ts") == SourceCategory.VIDEO

    def test_rtsp_url(self) -> None:
        assert classify_source("rtsp://192.168.1.1/stream") == SourceCategory.LIVE_STREAM

    def test_rtsps_url(self) -> None:
        assert classify_source("rtsps://cam.example.com/feed") == SourceCategory.LIVE_STREAM

    def test_webcam_zero(self) -> None:
        assert classify_source("0") == SourceCategory.LIVE_STREAM

    def test_webcam_one(self) -> None:
        assert classify_source("1") == SourceCategory.LIVE_STREAM

    def test_directory(self, tmp_path: Path) -> None:
        assert classify_source(tmp_path) == SourceCategory.IMAGE

    def test_image_uppercase_extension(self) -> None:
        assert classify_source("PHOTO.JPG") == SourceCategory.IMAGE

    def test_video_uppercase_extension(self) -> None:
        assert classify_source("clip.MP4") == SourceCategory.VIDEO

    def test_unknown_extension_raises(self) -> None:
        with pytest.raises(ConfigError):
            classify_source("data.xyz")


# ---------------------------------------------------------------------------
# classify_device tests
# ---------------------------------------------------------------------------


def _cpu_device(
    arch: CPUArch = CPUArch.X86_64,
    is_jetson: bool = False,
    memory_mb: int = 16384,
) -> Device:
    return Device(
        type=DeviceType.CPU,
        index=0,
        name="test-cpu",
        arch=None,
        cpu_arch=arch,
        memory_total_mb=memory_mb,
        memory_available_mb=memory_mb // 2,
        is_jetson=is_jetson,
    )


def _gpu_device(
    vram_mb: int = 8192,
    gpu_arch: GPUArch = GPUArch.AMPERE,
    is_jetson: bool = False,
) -> Device:
    return Device(
        type=DeviceType.CUDA,
        index=0,
        name="test-gpu",
        arch=gpu_arch,
        cpu_arch=CPUArch.X86_64,
        memory_total_mb=vram_mb,
        memory_available_mb=vram_mb // 2,
        is_jetson=is_jetson,
    )


def _libs(**kwargs: object) -> InstalledLibraries:
    return InstalledLibraries(**kwargs)  # type: ignore[arg-type]


def _hw(
    gpus: tuple[Device, ...] = (),
    cpu: Device | None = None,
    libs: InstalledLibraries | None = None,
) -> HardwareProfile:
    return HardwareProfile(
        gpus=gpus,
        cpu=cpu or _cpu_device(),
        cpu_features=frozenset(),
        libraries=libs or _libs(),
    )


class TestClassifyDevice:
    def test_jetson(self) -> None:
        hw = _hw(
            gpus=(_gpu_device(vram_mb=8192, is_jetson=True),),
            cpu=_cpu_device(arch=CPUArch.AARCH64, is_jetson=True),
        )
        assert classify_device(hw) == DeviceCategory.JETSON

    def test_cuda_high_vram(self) -> None:
        hw = _hw(gpus=(_gpu_device(vram_mb=24576),))
        assert classify_device(hw) == DeviceCategory.CUDA_HIGH

    def test_cuda_high_boundary(self) -> None:
        hw = _hw(gpus=(_gpu_device(vram_mb=8192),))
        assert classify_device(hw) == DeviceCategory.CUDA_HIGH

    def test_cuda_low_vram(self) -> None:
        hw = _hw(gpus=(_gpu_device(vram_mb=4096),))
        assert classify_device(hw) == DeviceCategory.CUDA_LOW

    def test_apple_silicon(self) -> None:
        hw = _hw(
            cpu=_cpu_device(arch=CPUArch.AARCH64),
            libs=_libs(onnxruntime_version="1.17.0", onnxruntime_has_coreml=True),
        )
        assert classify_device(hw) == DeviceCategory.APPLE_SILICON

    def test_cpu_x86(self) -> None:
        hw = _hw(cpu=_cpu_device(arch=CPUArch.X86_64))
        assert classify_device(hw) == DeviceCategory.CPU_X86

    def test_cpu_arm_no_coreml(self) -> None:
        hw = _hw(cpu=_cpu_device(arch=CPUArch.AARCH64))
        assert classify_device(hw) == DeviceCategory.CPU_ARM

    def test_cuda_just_below_high_threshold(self) -> None:
        hw = _hw(gpus=(_gpu_device(vram_mb=8191),))
        assert classify_device(hw) == DeviceCategory.CUDA_LOW

    def test_jetson_priority_over_cuda(self) -> None:
        """Jetson has a GPU but should be classified as JETSON, not CUDA."""
        jetson_gpu = _gpu_device(vram_mb=8192, is_jetson=True)
        hw = _hw(
            gpus=(jetson_gpu,),
            cpu=_cpu_device(arch=CPUArch.AARCH64, is_jetson=True),
        )
        assert classify_device(hw) == DeviceCategory.JETSON

    def test_jetson_gpu_flag_ignored_when_cpu_not_jetson(self) -> None:
        """classify_device reads cpu.is_jetson, not gpu.is_jetson."""
        hw = _hw(
            gpus=(_gpu_device(vram_mb=8192, is_jetson=True),),
            cpu=_cpu_device(arch=CPUArch.AARCH64, is_jetson=False),
        )
        assert classify_device(hw) == DeviceCategory.CUDA_HIGH


# ---------------------------------------------------------------------------
# preset_config tests
# ---------------------------------------------------------------------------


class TestPresetConfig:
    def test_cuda_high_video(self) -> None:
        hw = _hw(gpus=(_gpu_device(vram_mb=24576),))
        cfg = preset_config(hw, SourceCategory.VIDEO)
        assert cfg.batch_size == 4
        assert cfg.cache is True
        assert cfg.prefetch is True

    def test_cuda_high_live(self) -> None:
        hw = _hw(gpus=(_gpu_device(vram_mb=24576),))
        cfg = preset_config(hw, SourceCategory.LIVE_STREAM)
        assert cfg.batch_size == 1
        assert cfg.kv_cache is True
        assert cfg.frame_drop_policy == FrameDropPolicy.LATEST
        assert cfg.max_queue_size == 4

    def test_cpu_x86_image(self) -> None:
        hw = _hw(cpu=_cpu_device(arch=CPUArch.X86_64))
        cfg = preset_config(hw, SourceCategory.IMAGE)
        assert cfg.batch_size == 1
        assert cfg.prefetch is False

    def test_cpu_x86_live_no_kv_cache(self) -> None:
        hw = _hw(cpu=_cpu_device(arch=CPUArch.X86_64))
        cfg = preset_config(hw, SourceCategory.LIVE_STREAM)
        assert cfg.kv_cache is False
        assert cfg.frame_drop_policy == FrameDropPolicy.LATEST

    def test_apple_silicon_video(self) -> None:
        hw = _hw(
            cpu=_cpu_device(arch=CPUArch.AARCH64),
            libs=_libs(onnxruntime_version="1.17.0", onnxruntime_has_coreml=True),
        )
        cfg = preset_config(hw, SourceCategory.VIDEO)
        assert cfg.batch_size == 2
        assert cfg.cache is True
        assert cfg.prefetch is True

    def test_override_batch_size(self) -> None:
        hw = _hw(gpus=(_gpu_device(vram_mb=24576),))
        cfg = preset_config(hw, SourceCategory.VIDEO, batch_size=8)
        assert cfg.batch_size == 8
        # other preset values still applied
        assert cfg.cache is True
        assert cfg.prefetch is True

    def test_override_model_family(self) -> None:
        from yowo.types import ModelFamily

        hw = _hw()
        cfg = preset_config(hw, SourceCategory.IMAGE, model_family=ModelFamily.YOLO11)
        assert cfg.model_family == ModelFamily.YOLO11

    def test_unknown_override_raises(self) -> None:
        hw = _hw()
        with pytest.raises(ConfigError, match="Unknown"):
            preset_config(hw, SourceCategory.IMAGE, nonexistent_field=42)

    def test_cuda_low_video(self) -> None:
        hw = _hw(gpus=(_gpu_device(vram_mb=4096),))
        cfg = preset_config(hw, SourceCategory.VIDEO)
        assert cfg.batch_size == 2
        assert cfg.prefetch is True
        assert cfg.cache is False  # CUDA_LOW does not set cache

    def test_cuda_low_live(self) -> None:
        hw = _hw(gpus=(_gpu_device(vram_mb=4096),))
        cfg = preset_config(hw, SourceCategory.LIVE_STREAM)
        assert cfg.kv_cache is True
        assert cfg.frame_drop_policy == FrameDropPolicy.LATEST
        assert cfg.max_queue_size == 2

    def test_jetson_video(self) -> None:
        hw = _hw(
            gpus=(_gpu_device(vram_mb=8192, is_jetson=True),),
            cpu=_cpu_device(arch=CPUArch.AARCH64, is_jetson=True),
        )
        cfg = preset_config(hw, SourceCategory.VIDEO)
        assert cfg.batch_size == 1
        assert cfg.prefetch is True

    def test_jetson_live(self) -> None:
        hw = _hw(
            gpus=(_gpu_device(vram_mb=8192, is_jetson=True),),
            cpu=_cpu_device(arch=CPUArch.AARCH64, is_jetson=True),
        )
        cfg = preset_config(hw, SourceCategory.LIVE_STREAM)
        assert cfg.kv_cache is True
        assert cfg.frame_drop_policy == FrameDropPolicy.LATEST
        assert cfg.max_queue_size == 2

    def test_cpu_arm_video(self) -> None:
        hw = _hw(cpu=_cpu_device(arch=CPUArch.AARCH64))
        cfg = preset_config(hw, SourceCategory.VIDEO)
        assert cfg.batch_size == 1
        assert cfg.prefetch is True

    def test_cpu_arm_live_no_kv_cache(self) -> None:
        hw = _hw(cpu=_cpu_device(arch=CPUArch.AARCH64))
        cfg = preset_config(hw, SourceCategory.LIVE_STREAM)
        assert cfg.kv_cache is False
        assert cfg.frame_drop_policy == FrameDropPolicy.LATEST
        assert cfg.max_queue_size == 2

    def test_apple_silicon_live(self) -> None:
        hw = _hw(
            cpu=_cpu_device(arch=CPUArch.AARCH64),
            libs=_libs(onnxruntime_version="1.17.0", onnxruntime_has_coreml=True),
        )
        cfg = preset_config(hw, SourceCategory.LIVE_STREAM)
        assert cfg.kv_cache is True
        assert cfg.frame_drop_policy == FrameDropPolicy.LATEST
        assert cfg.max_queue_size == 2

    def test_override_prefetch_overrides_preset(self) -> None:
        hw = _hw(gpus=(_gpu_device(vram_mb=24576),))
        cfg = preset_config(hw, SourceCategory.VIDEO, prefetch=False)
        assert cfg.prefetch is False
        assert cfg.batch_size == 4  # other preset values intact

    def test_none_field_uses_inference_config_default(self) -> None:
        hw = _hw(cpu=_cpu_device(arch=CPUArch.X86_64))
        cfg = preset_config(hw, SourceCategory.IMAGE)
        assert cfg.cache is False  # not in preset → InferenceConfig default
        assert cfg.kv_cache is False
        assert cfg.iou_threshold == 0.45

    def test_does_not_set_backend(self) -> None:
        """Preset must not override backend/device/precision."""
        hw = _hw(gpus=(_gpu_device(vram_mb=24576),))
        cfg = preset_config(hw, SourceCategory.VIDEO)
        assert cfg.backend is None
        assert cfg.device == "auto"
        assert cfg.precision is None


# ---------------------------------------------------------------------------
# CLI --preset integration tests
# ---------------------------------------------------------------------------


class TestPresetCLI:
    def test_preset_flag_exists(self) -> None:
        runner = CliRunner()
        result = runner.invoke(cli, ["detect", "--help"])
        assert "--preset" in result.output

    @patch("yowo.engine.InferenceEngine")
    @patch("yowo.io.open_source")
    @patch("yowo.hardware.get_hardware_profile")
    @patch("yowo.config.preset_config")
    def test_preset_calls_preset_config(
        self,
        mock_preset: MagicMock,
        mock_hw: MagicMock,
        mock_open: MagicMock,
        mock_engine_cls: MagicMock,
    ) -> None:
        from yowo.config import InferenceConfig

        mock_preset.return_value = InferenceConfig()
        mock_hw.return_value = _hw()
        mock_open.return_value = iter([])
        engine = MagicMock()
        engine.stream.return_value = iter([])
        mock_engine_cls.return_value.__enter__ = MagicMock(return_value=engine)
        mock_engine_cls.return_value.__exit__ = MagicMock(return_value=False)

        runner = CliRunner()
        runner.invoke(cli, ["detect", "video.mp4", "--preset"])

        mock_preset.assert_called_once()
        call_args = mock_preset.call_args
        assert call_args[0][1] == SourceCategory.VIDEO

    @patch("yowo.engine.InferenceEngine")
    @patch("yowo.io.open_source")
    @patch("yowo.hardware.get_hardware_profile")
    @patch("yowo.config.preset_config")
    def test_preset_with_batch_override(
        self,
        mock_preset: MagicMock,
        mock_hw: MagicMock,
        mock_open: MagicMock,
        mock_engine_cls: MagicMock,
    ) -> None:
        from yowo.config import InferenceConfig

        mock_preset.return_value = InferenceConfig(batch_size=8)
        mock_hw.return_value = _hw()
        mock_open.return_value = iter([])
        engine = MagicMock()
        engine.stream.return_value = iter([])
        mock_engine_cls.return_value.__enter__ = MagicMock(return_value=engine)
        mock_engine_cls.return_value.__exit__ = MagicMock(return_value=False)

        runner = CliRunner()
        runner.invoke(cli, ["detect", "video.mp4", "--preset", "--batch", "8"])

        call_kwargs = mock_preset.call_args[1]
        assert call_kwargs["batch_size"] == 8

    @patch("yowo.engine.InferenceEngine")
    @patch("yowo.io.open_source")
    @patch("yowo.hardware.get_hardware_profile")
    @patch("yowo.config.preset_config")
    def test_preset_confidence_explicit_at_default_is_forwarded(
        self,
        mock_preset: MagicMock,
        mock_hw: MagicMock,
        mock_open: MagicMock,
        mock_engine_cls: MagicMock,
    ) -> None:
        """--confidence 0.25 equals the Click default but must be forwarded."""
        from yowo.config import InferenceConfig

        mock_preset.return_value = InferenceConfig()
        mock_hw.return_value = _hw()
        mock_open.return_value = iter([])
        engine = MagicMock()
        engine.stream.return_value = iter([])
        mock_engine_cls.return_value.__enter__ = MagicMock(return_value=engine)
        mock_engine_cls.return_value.__exit__ = MagicMock(return_value=False)

        runner = CliRunner()
        runner.invoke(cli, ["detect", "video.mp4", "--preset", "--confidence", "0.25"])

        call_kwargs = mock_preset.call_args[1]
        assert "confidence_threshold" in call_kwargs
        assert call_kwargs["confidence_threshold"] == pytest.approx(0.25)

    @patch("yowo.engine.InferenceEngine")
    @patch("yowo.io.open_source")
    @patch("yowo.hardware.get_hardware_profile")
    @patch("yowo.config.preset_config")
    def test_preset_no_explicit_flags_forwards_no_pipeline_overrides(
        self,
        mock_preset: MagicMock,
        mock_hw: MagicMock,
        mock_open: MagicMock,
        mock_engine_cls: MagicMock,
    ) -> None:
        """With no explicit CLI flags, only model identity is forwarded."""
        from yowo.config import InferenceConfig

        mock_preset.return_value = InferenceConfig()
        mock_hw.return_value = _hw()
        mock_open.return_value = iter([])
        engine = MagicMock()
        engine.stream.return_value = iter([])
        mock_engine_cls.return_value.__enter__ = MagicMock(return_value=engine)
        mock_engine_cls.return_value.__exit__ = MagicMock(return_value=False)

        runner = CliRunner()
        runner.invoke(cli, ["detect", "video.mp4", "--preset"])

        call_kwargs = mock_preset.call_args[1]
        assert "model_family" in call_kwargs
        assert "model_size" in call_kwargs
        for key in (
            "backend",
            "device",
            "precision",
            "batch_size",
            "confidence_threshold",
            "iou_threshold",
        ):
            assert key not in call_kwargs, f"{key!r} should not be forwarded when omitted"

    @patch("yowo.engine.InferenceEngine")
    @patch("yowo.io.open_source")
    @patch("yowo.hardware.get_hardware_profile")
    @patch("yowo.config.preset_config")
    def test_preset_backend_explicit_auto_forwards_none(
        self,
        mock_preset: MagicMock,
        mock_hw: MagicMock,
        mock_open: MagicMock,
        mock_engine_cls: MagicMock,
    ) -> None:
        """Explicitly passing --backend auto forwards backend=None."""
        from yowo.config import InferenceConfig

        mock_preset.return_value = InferenceConfig()
        mock_hw.return_value = _hw()
        mock_open.return_value = iter([])
        engine = MagicMock()
        engine.stream.return_value = iter([])
        mock_engine_cls.return_value.__enter__ = MagicMock(return_value=engine)
        mock_engine_cls.return_value.__exit__ = MagicMock(return_value=False)

        runner = CliRunner()
        runner.invoke(cli, ["detect", "video.mp4", "--preset", "--backend", "auto"])

        call_kwargs = mock_preset.call_args[1]
        assert "backend" in call_kwargs
        assert call_kwargs["backend"] is None

    @patch("yowo.engine.InferenceEngine")
    @patch("yowo.io.open_source")
    @patch("yowo.hardware.get_hardware_profile")
    @patch("yowo.config.preset_config")
    def test_preset_backend_explicit_pytorch_forwards_enum(
        self,
        mock_preset: MagicMock,
        mock_hw: MagicMock,
        mock_open: MagicMock,
        mock_engine_cls: MagicMock,
    ) -> None:
        from yowo.config import InferenceConfig

        mock_preset.return_value = InferenceConfig()
        mock_hw.return_value = _hw()
        mock_open.return_value = iter([])
        engine = MagicMock()
        engine.stream.return_value = iter([])
        mock_engine_cls.return_value.__enter__ = MagicMock(return_value=engine)
        mock_engine_cls.return_value.__exit__ = MagicMock(return_value=False)

        runner = CliRunner()
        runner.invoke(cli, ["detect", "video.mp4", "--preset", "--backend", "pytorch"])

        call_kwargs = mock_preset.call_args[1]
        assert call_kwargs["backend"] == BackendType.PYTORCH
