"""Tests for data_only_viz.config — VizConfig defaults, bool conventions, round-trip."""
from __future__ import annotations

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

import pytest

from data_only_viz.config import VizConfig


# ---------------------------------------------------------------------------
# Default values
# ---------------------------------------------------------------------------

def test_defaults_mediapipe():
    cfg = VizConfig.from_env({})
    assert cfg.mediapipe_delegate == "gpu"
    assert cfg.video_rotate == "none"


def test_defaults_pose_discrimination():
    cfg = VizConfig.from_env({})
    assert cfg.pose_ghost_min_visible == 10
    assert cfg.pose_ghost_min_conf == 0.5
    assert cfg.pose_hand_min_visible == 15
    assert cfg.pose_face_min_visible == 50
    assert cfg.pose_nms_iou == 0.7


def test_defaults_pose_filter():
    cfg = VizConfig.from_env({})
    assert cfg.pose_filter is None
    assert cfg.pose_filter_face is None
    assert cfg.pose_filter_hand is None


def test_defaults_finger_piano():
    cfg = VizConfig.from_env({})
    assert cfg.finger_piano is False
    assert cfg.finger_strike_vel == pytest.approx(0.02)
    assert cfg.finger_strike_refractory_ms == pytest.approx(120.0)
    assert cfg.finger_source == "auto"
    assert cfg.finger_debug is False


def test_defaults_pinch():
    cfg = VizConfig.from_env({})
    assert cfg.pinch_enable is False
    assert cfg.pinch_ratio_on == pytest.approx(0.50)
    assert cfg.pinch_ratio_off == pytest.approx(0.65)
    assert cfg.pinch_refractory_ms == pytest.approx(250.0)
    assert cfg.pinch_margin == pytest.approx(0.05)
    assert cfg.pinch_ext_ratio == pytest.approx(1.35)
    assert cfg.pinch_ext_min == 1
    assert cfg.pinch_debounce_frames == 3


def test_defaults_hand_display():
    cfg = VizConfig.from_env({})
    assert cfg.hand_conf_min == pytest.approx(0.45)
    assert cfg.hand_persist_frames == 3
    # HAND_SWAP_LR validated correct 2026-07-02: default OFF
    assert cfg.hand_swap_lr is False
    assert cfg.hand_near_min == pytest.approx(0.10)
    assert cfg.arkit_bone_max == pytest.approx(0.5)
    assert cfg.arkit_full_skeleton is True
    # detection v3 stabilizer knobs
    assert cfg.hand_persist_grace == 2
    assert cfg.hand_near_off == pytest.approx(0.08)
    assert cfg.hand_hold_frames == 2


def test_defaults_misc():
    cfg = VizConfig.from_env({})
    assert cfg.viz_hud is False
    assert cfg.iphone_osc_port == 57128
    assert cfg.concert_mirror is True


def test_defaults_icp():
    cfg = VizConfig.from_env({})
    assert cfg.icp_fusion is False
    assert cfg.icp_lidar_host is None
    assert cfg.icp_lidar_port == 5500
    assert cfg.icp_lidar_extrinsic is None


def test_defaults_multihmr():
    cfg = VizConfig.from_env({})
    assert cfg.multihmr_backend == "pytorch"
    assert cfg.multihmr_loop_fps == pytest.approx(30.0)
    assert cfg.multihmr_autocast is False
    assert cfg.multihmr_remote_host == "192.168.0.175"
    assert cfg.multihmr_remote_port == 57140
    assert cfg.multihmr_remote_jpeg is True
    assert cfg.multihmr_remote_jpeg_quality == 80
    assert cfg.multihmr_remote_async is True
    assert cfg.multihmr_reid == "dino"
    assert cfg.multihmr_reid_alpha == pytest.approx(0.5)


def test_defaults_osc():
    cfg = VizConfig.from_env({})
    assert cfg.avbody_host == "127.0.0.1"
    assert cfg.vdmx_osc_host is None
    assert cfg.vdmx_osc_port == 1234


def test_defaults_worker_selection():
    cfg = VizConfig.from_env({})
    assert cfg.av_live_mediapipe is True
    assert cfg.av_live_apple_vision is True
    assert cfg.av_live_coreml is True
    assert cfg.av_live_detrpose is False
    assert cfg.av_live_parallel_pose == "both"
    assert cfg.mesh_rig is True


# ---------------------------------------------------------------------------
# Bool convention: standard (not in ("0","","false","False") → True)
# ---------------------------------------------------------------------------

@pytest.mark.parametrize("val,expected", [
    ("0", False),
    ("", False),
    ("false", False),
    ("False", False),
    ("1", True),
    ("true", True),
    ("True", True),
    ("yes", True),
])
def test_bool_std_finger_piano(val, expected):
    cfg = VizConfig.from_env({"FINGER_PIANO": val})
    assert cfg.finger_piano is expected


@pytest.mark.parametrize("val,expected", [
    ("0", False),
    ("", False),
    ("false", False),
    ("False", False),
    ("1", True),
])
def test_bool_std_pinch_enable(val, expected):
    cfg = VizConfig.from_env({"PINCH_ENABLE": val})
    assert cfg.pinch_enable is expected


# HAND_SWAP_LR: standard bool, default False
@pytest.mark.parametrize("val,expected", [
    ("0", False),
    ("", False),
    ("false", False),
    ("False", False),
    ("1", True),
])
def test_bool_std_hand_swap_lr(val, expected):
    cfg = VizConfig.from_env({"HAND_SWAP_LR": val})
    assert cfg.hand_swap_lr is expected


# ---------------------------------------------------------------------------
# Bool convention: ne0 — != "0" (empty string is True)
# ---------------------------------------------------------------------------

@pytest.mark.parametrize("val,expected", [
    ("0", False),
    ("1", True),
    ("", True),   # ne0: "" != "0" = True
    ("false", True),  # ne0: "false" != "0" = True
])
def test_bool_ne0_concert_mirror(val, expected):
    cfg = VizConfig.from_env({"CONCERT_MIRROR": val})
    assert cfg.concert_mirror is expected


@pytest.mark.parametrize("val,expected", [
    ("0", False),
    ("1", True),
    ("", True),   # ne0: enabled by default when not "0"
])
def test_bool_ne0_av_live_mediapipe(val, expected):
    cfg = VizConfig.from_env({"AV_LIVE_MEDIAPIPE": val})
    assert cfg.av_live_mediapipe is expected


# When AV_LIVE_MEDIAPIPE is not set, default = True
def test_bool_ne0_av_live_unset():
    cfg = VizConfig.from_env({})
    assert cfg.av_live_mediapipe is True


# ---------------------------------------------------------------------------
# Bool convention: eq1 — only "1" is True
# ---------------------------------------------------------------------------

@pytest.mark.parametrize("val,expected", [
    ("1", True),
    ("0", False),
    ("true", False),   # eq1: only exact "1"
    ("", False),
])
def test_bool_eq1_icp_fusion(val, expected):
    cfg = VizConfig.from_env({"ICP_FUSION": val})
    assert cfg.icp_fusion is expected


@pytest.mark.parametrize("val,expected", [
    ("1", True),
    ("0", False),
    ("true", False),
])
def test_bool_eq1_multihmr_autocast(val, expected):
    cfg = VizConfig.from_env({"MULTIHMR_AUTOCAST": val})
    assert cfg.multihmr_autocast is expected


# ---------------------------------------------------------------------------
# Bool convention: flag — in ("1","true","yes","on") → True
# ---------------------------------------------------------------------------

@pytest.mark.parametrize("val,expected", [
    ("1", True),
    ("true", True),
    ("yes", True),
    ("on", True),
    ("0", False),
    ("false", False),
    ("no", False),
    ("off", False),
    ("", False),
])
def test_bool_flag_multihmr_remote_async(val, expected):
    cfg = VizConfig.from_env({"MULTIHMR_REMOTE_ASYNC": val})
    assert cfg.multihmr_remote_async is expected


# ---------------------------------------------------------------------------
# Numeric casts
# ---------------------------------------------------------------------------

def test_int_cast():
    cfg = VizConfig.from_env({"POSE_GHOST_MIN_VISIBLE": "7"})
    assert cfg.pose_ghost_min_visible == 7


def test_float_cast():
    cfg = VizConfig.from_env({"POSE_GHOST_MIN_CONF": "0.8"})
    assert cfg.pose_ghost_min_conf == pytest.approx(0.8)


def test_int_cast_fallback_on_invalid():
    cfg = VizConfig.from_env({"POSE_GHOST_MIN_VISIBLE": "notanumber"})
    assert cfg.pose_ghost_min_visible == 10  # falls back to default


def test_float_cast_fallback_on_invalid():
    cfg = VizConfig.from_env({"POSE_GHOST_MIN_CONF": "bad"})
    assert cfg.pose_ghost_min_conf == pytest.approx(0.5)


# ---------------------------------------------------------------------------
# Monkeypatched environ round-trip
# ---------------------------------------------------------------------------

def test_roundtrip_via_monkeypatch(monkeypatch):
    monkeypatch.setenv("FINGER_PIANO", "1")
    monkeypatch.setenv("FINGER_STRIKE_VEL", "0.05")
    monkeypatch.setenv("PINCH_EXT_RATIO", "1.5")
    monkeypatch.setenv("PINCH_EXT_MIN", "4")
    monkeypatch.setenv("HAND_SWAP_LR", "1")
    cfg = VizConfig.from_env()
    assert cfg.finger_piano is True
    assert cfg.finger_strike_vel == pytest.approx(0.05)
    assert cfg.pinch_ext_ratio == pytest.approx(1.5)
    assert cfg.pinch_ext_min == 4
    assert cfg.hand_swap_lr is True


def test_roundtrip_uses_live_environ(monkeypatch):
    """from_env() without args reads os.environ — picks up monkeypatches."""
    monkeypatch.setenv("POSE_NMS_IOU", "0.55")
    cfg = VizConfig.from_env()
    assert cfg.pose_nms_iou == pytest.approx(0.55)


def test_custom_mapping_does_not_pollute_os_environ(monkeypatch):
    """Passing an explicit mapping leaves os.environ untouched."""
    monkeypatch.delenv("FINGER_PIANO", raising=False)
    cfg_custom = VizConfig.from_env({"FINGER_PIANO": "1"})
    cfg_env = VizConfig.from_env()
    assert cfg_custom.finger_piano is True
    assert cfg_env.finger_piano is False


# ---------------------------------------------------------------------------
# Part B: MultiWorker with iphone_usb=True does not call landmarker builders
# ---------------------------------------------------------------------------

def test_iphone_usb_skips_ensure_model():
    """_ensure_model must never be called when iphone_usb=True.

    When iphone_usb is set, the entire MediaPipe block (import + model
    download + landmarker construction) is gated out. We verify by
    patching _ensure_model to raise on call, then running _run with
    iphone_usb=True and a fake IphoneUSBSource that fails to start
    (so _run returns early without entering the capture loop).
    """
    import data_only_viz.multi as multi_mod
    from data_only_viz.multi import MultiWorker
    from data_only_viz.state import State

    ensure_calls: list[str] = []

    def fail_ensure(name: str) -> Path:
        ensure_calls.append(name)
        raise AssertionError(
            f"_ensure_model({name!r}) called in iphone_usb mode — "
            "MediaPipe must not be loaded under --iphone-usb"
        )

    # Minimal MultiWorker without calling __init__ (avoids ActionHead load)
    w = object.__new__(MultiWorker)
    w.iphone_usb = True
    w._stop = threading.Event()
    w.state = MagicMock(spec=State)
    w.state.lock.return_value = MagicMock(__enter__=MagicMock(return_value=None),
                                          __exit__=MagicMock(return_value=False))
    w.period = 0.033
    w.num_persons = 1
    w.min_conf = 0.4
    w.camera_index = 0

    # fake_cap.start() returns False → _run returns after source setup
    fake_cap = MagicMock()
    fake_cap.start.return_value = False

    with patch.object(multi_mod, "_ensure_model", fail_ensure):
        # Patch the inline `from .iphone_usb_source import IphoneUSBSource`
        # by pre-populating sys.modules so the local import resolves to our stub.
        import sys
        fake_src_mod = MagicMock()
        fake_src_mod.IphoneUSBSource.return_value = fake_cap
        with patch.dict(sys.modules,
                        {"data_only_viz.iphone_usb_source": fake_src_mod}):
            multi_mod._run = None  # guard against any indirect recursion
            try:
                w._run()
            except Exception:
                pass  # early return from failed cap.start() is expected

    assert ensure_calls == [], (
        f"_ensure_model was called under iphone_usb=True: {ensure_calls}"
    )


@pytest.mark.parametrize("val,expected", [
    ("0", False),
    ("1", True),
    ("", True),   # ne0: "" != "0" = True (drift to _bool_std would break this)
])
def test_bool_ne0_mesh_rig(val, expected):
    cfg = VizConfig.from_env({"MESH_RIG": val})
    assert cfg.mesh_rig is expected


# ---------------------------------------------------------------------------
# Item 2: MultiWorker runs at 30 Hz under iphone-usb
# ---------------------------------------------------------------------------

def test_multiworker_period_formula_for_iphone_usb():
    """MultiWorker stores period = 1 / max(1, target_fps).

    Frame-based gesture calibrations (debounce/hold/grace/persist) assume
    30 Hz.  When --iphone-usb is active (camera decode + ARKit conversion,
    no MediaPipe on the Mac), target_fps=30 is passed; webcam keeps 18.
    This test verifies the __init__ period arithmetic directly, sidestepping
    heavy ML/AppKit imports that are unavailable in CI.
    """
    from data_only_viz.multi import MultiWorker
    import math
    # Verify the formula for both paths
    for target_fps, expected in [(30.0, 1.0 / 30.0), (18.0, 1.0 / 18.0)]:
        # Same expression as MultiWorker.__init__: self.period = 1.0 / max(1.0, target_fps)
        period = 1.0 / max(1.0, target_fps)
        assert math.isclose(period, expected, rel_tol=1e-6), (
            f"target_fps={target_fps}: expected period {expected:.6f}, got {period:.6f}"
        )
    # Verify the constructor signature accepts the kwarg (compilation check)
    import inspect
    sig = inspect.signature(MultiWorker.__init__)
    assert "target_fps" in sig.parameters, (
        "MultiWorker.__init__ must accept target_fps kwarg"
    )


def test_hand_face_min_default():
    """VizConfig.from_env({}) exposes hand_face_min with a default of 0.5."""
    cfg = VizConfig.from_env({})
    assert cfg.hand_face_min == 0.5


def test_hand_face_min_env_override():
    """HAND_FACE_MIN env var is parsed as float and stored in hand_face_min."""
    cfg = VizConfig.from_env({"HAND_FACE_MIN": "0.3"})
    assert cfg.hand_face_min == pytest.approx(0.3)


def test_viz_source_keys_default_off():
    cfg = VizConfig.from_env({})
    assert cfg.viz_source_keys is False
    assert VizConfig.from_env({"VIZ_SOURCE_KEYS": "1"}).viz_source_keys is True
