"""Shared fixtures and helpers for the img_show test suite.

Tests run under pytest-xdist; every test gets a clean copy of the module
globals via the autouse ``reset_state`` fixture. Optional dependencies
(torch, IPython, tkinter) are faked through ``sys.modules`` so the suite
needs no heavy installs, and cv2 GUI calls are stubbed so no real windows
open.
"""

from __future__ import annotations

import sys
import types

import numpy as np
import pytest

import img_show


@pytest.fixture(autouse=True)
def reset_state():
    """Reset module-level globals before and after each test."""
    img_show.open_window_names.clear()
    img_show._cached_display_size = None
    img_show._headless_warned = False
    yield
    img_show.open_window_names.clear()
    img_show._cached_display_size = None
    img_show._headless_warned = False


@pytest.fixture
def headless(monkeypatch):
    """Force the headless code path."""
    monkeypatch.setattr(img_show, '_has_display_env', lambda: False)


class CvRecorder:
    """Records stubbed cv2 GUI calls."""

    def __init__(self):
        self.named = []
        self.shown = []
        self.resized = []
        self.waited = []
        self.destroyed = []


@pytest.fixture
def stub_cv2(monkeypatch):
    """Stub cv2 window functions; cv2.error stays real."""
    rec = CvRecorder()
    monkeypatch.setattr(img_show.cv2, 'namedWindow', lambda name, mode: rec.named.append((name, mode)))
    monkeypatch.setattr(img_show.cv2, 'imshow', lambda name, img: rec.shown.append(name))
    monkeypatch.setattr(img_show.cv2, 'resizeWindow', lambda name, w, h: rec.resized.append((name, w, h)))
    monkeypatch.setattr(img_show.cv2, 'waitKey', lambda delay: rec.waited.append(delay))
    monkeypatch.setattr(img_show.cv2, 'destroyWindow', lambda name: rec.destroyed.append(name))
    return rec


@pytest.fixture
def fake_torch(monkeypatch):
    """Install a fake ``torch`` module exposing a Tensor wrapper."""
    mod = types.ModuleType('torch')

    class Tensor:
        def __init__(self, array):
            self._array = np.asarray(array)

        def detach(self):
            return self

        def cpu(self):
            return self

        def numpy(self):
            return self._array

    mod.Tensor = Tensor
    monkeypatch.setitem(sys.modules, 'torch', mod)
    return mod


def make_ipython(monkeypatch, shell_class_name: str | None):
    """Install a fake ``IPython`` module whose get_ipython returns the shell."""
    mod = types.ModuleType('IPython')

    if shell_class_name is None:
        shell = None
    else:
        shell = type(shell_class_name, (), {})()

    mod.get_ipython = lambda: shell
    monkeypatch.setitem(sys.modules, 'IPython', mod)
    return mod


class DisplayCapture:
    """Captures IPython.display HTML/Image/display calls."""

    def __init__(self):
        self.html = []
        self.images = []
        self.displayed = []


@pytest.fixture
def fake_ipython_display(monkeypatch):
    """Install a fake ``IPython.display`` module that records calls."""
    cap = DisplayCapture()
    mod = types.ModuleType('IPython.display')

    class HTML:
        def __init__(self, data):
            self.data = data
            cap.html.append(data)

    class Image:
        def __init__(self, data=None, format=None):
            self.data = data
            self.format = format
            cap.images.append((data, format))

    def display(obj):
        cap.displayed.append(obj)

    mod.HTML = HTML
    mod.Image = Image
    mod.display = display
    monkeypatch.setitem(sys.modules, 'IPython.display', mod)
    return cap


def rgba(h=16, w=16, color=(10, 20, 30), alpha=255):
    """Build a uint8 RGBA image."""
    img = np.zeros((h, w, 4), np.uint8)
    img[..., 0] = color[0]
    img[..., 1] = color[1]
    img[..., 2] = color[2]
    img[..., 3] = alpha
    return img
"""Tests for ``_coerce_core`` and the public ``coerce_img``."""

from __future__ import annotations

import numpy as np
import pytest

import img_show

PIL_Image = __import__('PIL.Image', fromlist=['Image'])


def test_numpy_array_not_from_pil():
    arr, from_pil = img_show._coerce_core(np.zeros((8, 8, 3), np.uint8))
    assert from_pil is False
    assert arr.shape == (8, 8, 3)


def test_torch_tensor_chw_coerced(fake_torch):
    tensor = fake_torch.Tensor(np.zeros((3, 8, 6), np.uint8))
    arr, from_pil = img_show._coerce_core(tensor)
    assert from_pil is False
    assert arr.shape == (8, 6, 3)


def test_pil_rgb_marks_color():
    im = PIL_Image.new('RGB', (6, 5), (10, 20, 30))
    arr, from_pil = img_show._coerce_core(im)
    assert from_pil is True
    assert arr.shape == (5, 6, 3)


def test_pil_rgba_preserved_four_channel():
    im = PIL_Image.new('RGBA', (6, 5), (10, 20, 30, 128))
    arr, from_pil = img_show._coerce_core(im)
    assert from_pil is True
    assert arr.shape == (5, 6, 4)


def test_unknown_type_raises_typeerror():
    with pytest.raises(TypeError, match='Unexpected type'):
        img_show._coerce_core('not an image')


def test_coerce_img_returns_array_only():
    out = img_show.coerce_img(np.zeros((3, 8, 6), np.uint8))
    assert isinstance(out, np.ndarray)
    assert out.shape == (8, 6, 3)


def test_coerce_img_float_normalized():
    out = img_show.coerce_img(np.array([[0.0, 9.0]], np.float64))
    assert out.max() == pytest.approx(1.0)


def test_coerce_img_rgba_stays_four_channel():
    im = PIL_Image.new('RGBA', (6, 5), (10, 20, 30, 64))
    out = img_show.coerce_img(im)
    assert out.shape == (5, 6, 4)
"""Tests for ``_coerce_pil`` using real Pillow."""

from __future__ import annotations

import numpy as np

import img_show

PIL_Image = __import__('PIL.Image', fromlist=['Image'])


def test_non_pil_returns_none():
    assert img_show._coerce_pil(np.zeros((4, 4), np.uint8)) is None
    assert img_show._coerce_pil(object()) is None


def test_mode_l_grayscale_no_color():
    im = PIL_Image.new('L', (6, 5), 120)
    arr, is_color = img_show._coerce_pil(im)
    assert arr.shape == (5, 6)
    assert is_color is False


def test_mode_1_converts_to_l():
    im = PIL_Image.new('1', (6, 5), 1)
    arr, is_color = img_show._coerce_pil(im)
    assert arr.ndim == 2
    assert is_color is False


def test_mode_rgb_is_color():
    im = PIL_Image.new('RGB', (6, 5), (10, 20, 30))
    arr, is_color = img_show._coerce_pil(im)
    assert arr.shape == (5, 6, 3)
    assert is_color is True


def test_mode_rgba_is_color():
    im = PIL_Image.new('RGBA', (6, 5), (10, 20, 30, 128))
    arr, is_color = img_show._coerce_pil(im)
    assert arr.shape == (5, 6, 4)
    assert is_color is True


def test_mode_la_promoted_to_rgba():
    im = PIL_Image.new('LA', (6, 5), (120, 200))
    arr, is_color = img_show._coerce_pil(im)
    assert arr.shape == (5, 6, 4)
    assert is_color is True


def test_mode_p_without_transparency_to_rgb():
    im = PIL_Image.new('P', (6, 5))
    arr, is_color = img_show._coerce_pil(im)
    assert arr.shape == (5, 6, 3)
    assert is_color is True


def test_mode_p_with_transparency_to_rgba():
    im = PIL_Image.new('P', (6, 5))
    im.info['transparency'] = 0
    arr, is_color = img_show._coerce_pil(im)
    assert arr.shape == (5, 6, 4)
    assert is_color is True


def test_mode_cmyk_converted_to_rgb():
    im = PIL_Image.new('CMYK', (6, 5))
    arr, is_color = img_show._coerce_pil(im)
    assert arr.shape == (5, 6, 3)
    assert is_color is True
"""Tests for ``_composite_over_checker``."""

from __future__ import annotations

import numpy as np
from conftest import rgba

import img_show


def test_output_is_opaque_three_channel():
    out = img_show._composite_over_checker(rgba(16, 16))
    assert out.shape == (16, 16, 3)
    assert out.dtype == np.uint8


def test_fully_opaque_preserves_color():
    img = rgba(16, 16, color=(11, 22, 33), alpha=255)
    out = img_show._composite_over_checker(img)
    assert np.all(out[..., 0] == 11)
    assert np.all(out[..., 1] == 22)
    assert np.all(out[..., 2] == 33)


def test_fully_transparent_shows_checker():
    img = rgba(16, 16, color=(255, 255, 255), alpha=0)
    out = img_show._composite_over_checker(img)
    # Top-left tile (0,0) is light per ((0//8)+(0//8))%2==0.
    assert tuple(out[0, 0]) == img_show._CHECKER_LIGHT
    # Crossing one tile in x flips parity to the dark tone.
    assert tuple(out[0, img_show._CHECKER_TILE]) == img_show._CHECKER_DARK


def test_half_alpha_is_blend_of_color_and_checker():
    img = rgba(8, 8, color=(0, 0, 0), alpha=128)
    out = img_show._composite_over_checker(img)
    a = 128 / 255.0
    expected = round((1.0 - a) * img_show._CHECKER_LIGHT[0])
    assert out[0, 0, 0] == expected
"""Tests for ``_has_display_env``, ``_warn_once_headless`` and ``_get_display_size``."""

from __future__ import annotations

import sys
import types

import pytest

import img_show


def test_has_display_env_windows(monkeypatch):
    monkeypatch.setattr(img_show.os, 'name', 'nt')
    assert img_show._has_display_env() is True


def test_has_display_env_macos(monkeypatch):
    monkeypatch.setattr(img_show.os, 'name', 'posix')
    monkeypatch.setattr(img_show.sys, 'platform', 'darwin')
    assert img_show._has_display_env() is True


def test_has_display_env_x11(monkeypatch):
    monkeypatch.setattr(img_show.os, 'name', 'posix')
    monkeypatch.setattr(img_show.sys, 'platform', 'linux')
    monkeypatch.setenv('DISPLAY', ':0')
    monkeypatch.delenv('WAYLAND_DISPLAY', raising=False)
    assert img_show._has_display_env() is True


def test_has_display_env_wayland(monkeypatch):
    monkeypatch.setattr(img_show.os, 'name', 'posix')
    monkeypatch.setattr(img_show.sys, 'platform', 'linux')
    monkeypatch.delenv('DISPLAY', raising=False)
    monkeypatch.setenv('WAYLAND_DISPLAY', 'wayland-0')
    assert img_show._has_display_env() is True


def test_has_display_env_headless(monkeypatch):
    monkeypatch.setattr(img_show.os, 'name', 'posix')
    monkeypatch.setattr(img_show.sys, 'platform', 'linux')
    monkeypatch.delenv('DISPLAY', raising=False)
    monkeypatch.delenv('WAYLAND_DISPLAY', raising=False)
    assert img_show._has_display_env() is False


def test_warn_once_headless_emits_single_warning():
    with pytest.warns(UserWarning, match='windows are skipped'):
        img_show._warn_once_headless()
    import warnings

    with warnings.catch_warnings():
        warnings.simplefilter('error')
        img_show._warn_once_headless()  # second call must not warn


def test_get_display_size_cached_returns_immediately(monkeypatch):
    img_show._cached_display_size = (111, 222)
    assert img_show._get_display_size() == (111, 222)


def test_get_display_size_headless_fallback(headless):
    with pytest.warns(UserWarning, match='fallback display size'):
        size = img_show._get_display_size()
    assert size == img_show._FALLBACK_DISPLAY_SIZE


def _install_fake_tkinter(monkeypatch, *, screen_h=900, screen_w=1600, raise_on_init=False):
    mod = types.ModuleType('tkinter')

    class TclError(Exception):
        pass

    class Tk:
        def __init__(self):
            if raise_on_init:
                raise TclError('no display')
            self.destroyed = False

        def winfo_screenheight(self):
            return screen_h

        def winfo_screenwidth(self):
            return screen_w

        def destroy(self):
            self.destroyed = True

    mod.Tk = Tk
    mod.TclError = TclError
    monkeypatch.setitem(sys.modules, 'tkinter', mod)
    return mod


def test_get_display_size_queries_tkinter(monkeypatch):
    monkeypatch.setattr(img_show, '_has_display_env', lambda: True)
    _install_fake_tkinter(monkeypatch, screen_h=900, screen_w=1600)
    assert img_show._get_display_size() == (900, 1600)


def test_get_display_size_tkinter_failure_fallback(monkeypatch):
    monkeypatch.setattr(img_show, '_has_display_env', lambda: True)
    _install_fake_tkinter(monkeypatch, raise_on_init=True)
    with pytest.warns(UserWarning, match='Unable to query screen size'):
        size = img_show._get_display_size()
    assert size == img_show._FALLBACK_DISPLAY_SIZE
"""Tests for ``_is_jupyter``, ``_show_img_inline`` and inline routing."""

from __future__ import annotations

import sys

import numpy as np
import pytest
from conftest import make_ipython
from conftest import rgba

import img_show


def test_is_jupyter_zmq_shell(monkeypatch):
    make_ipython(monkeypatch, 'ZMQInteractiveShell')
    assert img_show._is_jupyter() is True


def test_is_jupyter_terminal_shell(monkeypatch):
    make_ipython(monkeypatch, 'TerminalInteractiveShell')
    assert img_show._is_jupyter() is False


def test_is_jupyter_no_shell(monkeypatch):
    make_ipython(monkeypatch, None)
    assert img_show._is_jupyter() is False


def test_is_jupyter_no_ipython(monkeypatch):
    monkeypatch.setitem(sys.modules, 'IPython', None)
    assert img_show._is_jupyter() is False


def test_show_img_inline_captions_named(fake_ipython_display):
    img = np.zeros((8, 8, 3), np.uint8)
    img_show._show_img_inline(img, 'My Window')
    assert fake_ipython_display.html == ['<b>My Window</b>']
    assert len(fake_ipython_display.images) == 1
    data, fmt = fake_ipython_display.images[0]
    assert fmt == 'png'
    assert isinstance(data, bytes)


def test_show_img_inline_escapes_html(fake_ipython_display):
    img_show._show_img_inline(np.zeros((4, 4, 3), np.uint8), '<script>&')
    assert fake_ipython_display.html == ['<b>&lt;script&gt;&amp;</b>']


def test_show_img_inline_blank_name_no_caption(fake_ipython_display):
    img_show._show_img_inline(np.zeros((4, 4, 3), np.uint8), ' ')
    assert fake_ipython_display.html == []
    assert len(fake_ipython_display.images) == 1


def test_show_img_inline_encode_failure_raises(fake_ipython_display, monkeypatch):
    monkeypatch.setattr(img_show.cv2, 'imencode', lambda ext, im: (False, None))
    with pytest.raises(RuntimeError, match='imencode failed'):
        img_show._show_img_inline(np.zeros((4, 4, 3), np.uint8), 'x')


def test_render_routes_inline_in_jupyter(monkeypatch, fake_ipython_display):
    make_ipython(monkeypatch, 'ZMQInteractiveShell')
    opened = img_show._render([('a', rgba(4, 4)[..., :3].copy()), ('b', np.zeros((4, 4, 3), np.uint8))])
    assert opened == []
    assert len(fake_ipython_display.images) == 2
    assert fake_ipython_display.html == ['<b>a</b>', '<b>b</b>']
"""Tests for ``_normalize_int``, ``_normalize_float`` and ``_normalize_dtype``."""

from __future__ import annotations

import numpy as np
import pytest

import img_show


def test_normalize_int_binary():
    img = np.array([[0, 1], [1, 0]], np.int32)
    out = img_show._normalize_int(img)
    assert out.dtype == np.uint8
    assert out.max() == 255
    assert out.min() == 0


def test_normalize_int_constant_nonzero_white():
    img = np.full((3, 3), 7, np.int32)
    out = img_show._normalize_int(img)
    assert out.dtype == np.uint8
    assert np.all(out == 255)


def test_normalize_int_all_zero_black():
    img = np.zeros((3, 3), np.int32)
    out = img_show._normalize_int(img)
    assert out.dtype == np.uint8
    assert np.all(out == 0)


def test_normalize_int_range_scaled_to_unit():
    img = np.array([[0, 128], [255, 64]], np.int32)
    out = img_show._normalize_int(img)
    assert out.dtype == np.float64
    assert out.max() == pytest.approx(1.0)
    assert out.min() == pytest.approx(0.0)


def test_normalize_float_widens_float16():
    img = np.array([[0.0, 0.5], [1.0, 0.25]], np.float16)
    out = img_show._normalize_float(img)
    assert out.dtype == np.float32


def test_normalize_float_in_range_unchanged():
    img = np.array([[0.0, 0.5], [1.0, 0.2]], np.float32)
    out = img_show._normalize_float(img)
    np.testing.assert_allclose(out, img)


def test_normalize_float_rescales_out_of_range():
    img = np.array([[-1.0, 0.0], [4.0, 2.0]], np.float64)
    out = img_show._normalize_float(img)
    assert out.max() == pytest.approx(1.0)
    assert out.min() == pytest.approx(0.0)


def test_normalize_float_rescales_tiny_span():
    img = np.array([[0.50, 0.51], [0.52, 0.50]], np.float64)
    out = img_show._normalize_float(img)
    assert out.max() == pytest.approx(1.0)
    assert out.min() == pytest.approx(0.0)


def test_normalize_dtype_uint8_passthrough():
    img = np.zeros((2, 2), np.uint8)
    out = img_show._normalize_dtype(img)
    assert out is img


def test_normalize_dtype_uint16_passthrough():
    img = np.zeros((2, 2), np.uint16)
    out = img_show._normalize_dtype(img)
    assert out is img


def test_normalize_dtype_bool_scaled():
    img = np.array([[True, False]], np.bool_)
    out = img_show._normalize_dtype(img)
    assert out.dtype == np.uint8
    assert out[0, 0] == 255
    assert out[0, 1] == 0


def test_normalize_dtype_integer_delegates():
    img = np.array([[0, 100], [200, 50]], np.int32)
    out = img_show._normalize_dtype(img)
    assert out.dtype == np.float64


def test_normalize_dtype_float_delegates():
    img = np.array([[0.0, 9.0]], np.float64)
    out = img_show._normalize_dtype(img)
    assert out.max() == pytest.approx(1.0)


def test_normalize_dtype_unsupported_raises():
    img = np.array([[1 + 2j]], np.complex128)
    with pytest.raises(Exception, match='DONT KNOW'):
        img_show._normalize_dtype(img)
"""Tests for the ``_prepare_for_display`` pipeline."""

from __future__ import annotations

import numpy as np
from conftest import rgba

import img_show

PIL_Image = __import__('PIL.Image', fromlist=['Image'])


def test_numpy_uint8_unchanged_order():
    img = np.zeros((8, 8, 3), np.uint8)
    img[..., 0] = 200
    out = img_show._prepare_for_display(img)
    assert out.dtype == np.uint8
    # Non-PIL arrays keep channel order; no BGR reorder applied.
    assert np.all(out[..., 0] == 200)


def test_four_channel_array_composited_to_three():
    out = img_show._prepare_for_display(rgba(8, 8, alpha=0))
    assert out.shape == (8, 8, 3)


def test_pil_rgb_reordered_to_bgr():
    im = PIL_Image.new('RGB', (12, 10), (255, 0, 0))
    out = img_show._prepare_for_display(im)
    assert out.shape == (10, 12, 3)
    # Red in RGB becomes (0, 0, 255) after the PIL-only BGR reorder.
    assert tuple(out[0, 0]) == (0, 0, 255)


def test_pil_rgba_composited_then_reordered():
    im = PIL_Image.new('RGBA', (12, 10), (255, 0, 0, 255))
    out = img_show._prepare_for_display(im)
    assert out.shape == (10, 12, 3)
    assert tuple(out[0, 0]) == (0, 0, 255)


def test_pil_grayscale_not_reordered():
    im = PIL_Image.new('L', (12, 10), 90)
    out = img_show._prepare_for_display(im)
    assert out.ndim == 2
    assert np.all(out == 90)
"""Tests for the public ``show_img``, ``show_imgs``, ``_auto_window_names`` and ``close_all``."""

from __future__ import annotations

import numpy as np
import pytest

import img_show


def test_public_exports():
    assert set(img_show.__all__) == {'show_img', 'show_imgs', 'coerce_img', 'close_all'}


def test_show_img_windowed(monkeypatch, stub_cv2):
    monkeypatch.setattr(img_show, '_is_jupyter', lambda: False)
    monkeypatch.setattr(img_show, '_has_display_env', lambda: True)
    monkeypatch.setattr(img_show, '_get_display_size', lambda: (1080, 1920))
    img_show.show_img(np.zeros((8, 8, 3), np.uint8), 'one', do_wait=True, destroy_window=False)
    assert stub_cv2.shown == ['one']
    assert 'one' in img_show.open_window_names


def test_show_img_headless_no_crash(headless):
    with pytest.warns(UserWarning):
        img_show.show_img(np.zeros((8, 8, 3), np.uint8))
    assert len(img_show.open_window_names) == 0


def test_show_imgs_windowed_auto_names(monkeypatch, stub_cv2):
    monkeypatch.setattr(img_show, '_is_jupyter', lambda: False)
    monkeypatch.setattr(img_show, '_has_display_env', lambda: True)
    monkeypatch.setattr(img_show, '_get_display_size', lambda: (1080, 1920))
    imgs = [np.zeros((8, 8, 3), np.uint8), np.zeros((8, 8, 3), np.uint8)]
    img_show.show_imgs(imgs, do_wait=True, destroy_windows=False)
    assert stub_cv2.shown == ['Image 1', 'Image 2']
    assert img_show.open_window_names == {'Image 1', 'Image 2'}


def test_show_imgs_explicit_names(monkeypatch, stub_cv2):
    monkeypatch.setattr(img_show, '_is_jupyter', lambda: False)
    monkeypatch.setattr(img_show, '_has_display_env', lambda: True)
    monkeypatch.setattr(img_show, '_get_display_size', lambda: (1080, 1920))
    img_show.show_imgs([np.zeros((8, 8, 3), np.uint8)], ['only'], destroy_windows=True)
    assert stub_cv2.shown == ['only']


def test_show_imgs_length_mismatch_raises():
    with pytest.raises(ValueError, match='does not match'):
        img_show.show_imgs([np.zeros((8, 8, 3), np.uint8)], ['a', 'b'])


def test_auto_window_names_basic():
    assert img_show._auto_window_names(3) == ['Image 1', 'Image 2', 'Image 3']


def test_auto_window_names_collision_suffix():
    img_show.open_window_names.update({'Image 1', 'Image 2'})
    names = img_show._auto_window_names(2)
    assert names == ['Image 1 (2)', 'Image 2 (2)']


def test_close_all_destroys_tracked(stub_cv2):
    img_show.open_window_names.update({'a', 'b'})
    img_show.close_all()
    assert sorted(stub_cv2.destroyed) == ['a', 'b']
    assert len(img_show.open_window_names) == 0


def test_close_all_swallows_cv2_error(monkeypatch):
    def boom(name):
        raise img_show.cv2.error('gone')

    monkeypatch.setattr(img_show.cv2, 'destroyWindow', boom)
    img_show.open_window_names.add('a')
    img_show.close_all()
    assert len(img_show.open_window_names) == 0


def test_show_img_jupyter_inline(monkeypatch, fake_ipython_display):
    from conftest import make_ipython

    make_ipython(monkeypatch, 'ZMQInteractiveShell')
    img_show.show_img(np.zeros((8, 8, 3), np.uint8), 'cap')
    assert fake_ipython_display.html == ['<b>cap</b>']
    assert len(fake_ipython_display.images) == 1
    assert len(img_show.open_window_names) == 0
"""Tests for ``_render`` window routing and ``_finalize_windows`` lifecycle."""

from __future__ import annotations

import numpy as np

import img_show


def test_render_windowed_collects_opened(monkeypatch, stub_cv2):
    monkeypatch.setattr(img_show, '_is_jupyter', lambda: False)
    monkeypatch.setattr(img_show, '_has_display_env', lambda: True)
    monkeypatch.setattr(img_show, '_get_display_size', lambda: (1080, 1920))
    opened = img_show._render([('a', np.zeros((8, 8, 3), np.uint8)), ('b', np.zeros((8, 8, 3), np.uint8))])
    assert opened == ['a', 'b']
    assert stub_cv2.shown == ['a', 'b']


def test_render_windowed_skips_headless(monkeypatch):
    monkeypatch.setattr(img_show, '_is_jupyter', lambda: False)
    monkeypatch.setattr(img_show, '_has_display_env', lambda: False)
    import warnings

    with warnings.catch_warnings():
        warnings.simplefilter('ignore')
        opened = img_show._render([('a', np.zeros((8, 8, 3), np.uint8))])
    assert opened == []


def test_finalize_empty_is_noop(stub_cv2):
    img_show._finalize_windows([], 0, True, True)
    assert stub_cv2.waited == []
    assert stub_cv2.destroyed == []


def test_finalize_wait_blocking(stub_cv2):
    img_show._finalize_windows(['w'], 5000, True, False)
    assert stub_cv2.waited == [5000]


def test_finalize_wait_nonblocking_pumps_one_ms(stub_cv2):
    img_show._finalize_windows(['w'], 5000, False, False)
    assert stub_cv2.waited == [1]


def test_finalize_destroy_true_discards_tracking(stub_cv2):
    img_show.open_window_names.add('w')
    img_show._finalize_windows(['w'], 0, True, True)
    assert stub_cv2.destroyed == ['w']
    assert 'w' not in img_show.open_window_names


def test_finalize_destroy_false_tracks(stub_cv2):
    img_show._finalize_windows(['a', 'b'], 0, True, False)
    assert img_show.open_window_names == {'a', 'b'}
    assert stub_cv2.destroyed == []


def test_finalize_destroy_swallows_cv2_error(monkeypatch, stub_cv2):
    def boom(name):
        raise img_show.cv2.error('already gone')

    monkeypatch.setattr(img_show.cv2, 'destroyWindow', boom)
    img_show.open_window_names.add('w')
    img_show._finalize_windows(['w'], 0, True, True)
    assert 'w' not in img_show.open_window_names
"""Tests for ``_valid_img_shape`` and ``_coerce_shape``."""

from __future__ import annotations

import numpy as np
import pytest

import img_show


def test_valid_shape_2d():
    assert img_show._valid_img_shape(np.zeros((4, 5), np.uint8)) is True


def test_valid_shape_3ch():
    assert img_show._valid_img_shape(np.zeros((4, 5, 3), np.uint8)) is True


def test_valid_shape_4ch():
    assert img_show._valid_img_shape(np.zeros((4, 5, 4), np.uint8)) is True


def test_valid_shape_bad_channel_count():
    assert img_show._valid_img_shape(np.zeros((4, 5, 2), np.uint8)) is False


def test_coerce_shape_passthrough_2d():
    img = np.zeros((10, 12), np.uint8)
    out = img_show._coerce_shape(img)
    assert out.shape == (10, 12)


def test_coerce_shape_passthrough_hwc():
    img = np.zeros((10, 12, 3), np.uint8)
    out = img_show._coerce_shape(img)
    assert out.shape == (10, 12, 3)


def test_coerce_shape_leading_singleton():
    img = np.zeros((1, 10, 12, 3), np.uint8)
    out = img_show._coerce_shape(img)
    assert out.shape == (10, 12, 3)


def test_coerce_shape_trailing_singleton():
    img = np.zeros((10, 12, 1), np.uint8)
    out = img_show._coerce_shape(img)
    assert out.shape == (10, 12)


def test_coerce_shape_chw_3_transposed():
    img = np.zeros((3, 10, 12), np.uint8)
    out = img_show._coerce_shape(img)
    assert out.shape == (10, 12, 3)


def test_coerce_shape_chw_4_transposed():
    img = np.zeros((4, 10, 12), np.uint8)
    out = img_show._coerce_shape(img)
    assert out.shape == (10, 12, 4)


def test_coerce_shape_too_few_dims():
    with pytest.raises(ValueError, match='Unable to coerce shape'):
        img_show._coerce_shape(np.zeros((5,), np.uint8))


def test_coerce_shape_uncoercible():
    with pytest.raises(ValueError, match='cannot be coerced'):
        img_show._coerce_shape(np.zeros((10, 12, 5), np.uint8))
"""Tests for the internal ``_show_img`` window function."""

from __future__ import annotations

import warnings

import numpy as np
import pytest

import img_show


def test_headless_returns_false_and_warns(headless):
    with pytest.warns(UserWarning, match='windows are skipped'):
        result = img_show._show_img(np.zeros((8, 8, 3), np.uint8))
    assert result is False


def test_headless_warns_only_once(headless):
    with pytest.warns(UserWarning):
        img_show._show_img(np.zeros((8, 8, 3), np.uint8))
    with warnings.catch_warnings():
        warnings.simplefilter('error')
        assert img_show._show_img(np.zeros((8, 8, 3), np.uint8)) is False


def test_windowed_autosize(monkeypatch, stub_cv2):
    monkeypatch.setattr(img_show, '_has_display_env', lambda: True)
    monkeypatch.setattr(img_show, '_get_display_size', lambda: (1080, 1920))
    result = img_show._show_img(np.zeros((100, 100, 3), np.uint8), 'win', do_coerce=False)
    assert result is True
    assert stub_cv2.named == [('win', img_show.cv2.WINDOW_AUTOSIZE)]
    assert stub_cv2.shown == ['win']
    assert stub_cv2.resized == []


def test_windowed_resize_branch(monkeypatch, stub_cv2):
    monkeypatch.setattr(img_show, '_has_display_env', lambda: True)
    monkeypatch.setattr(img_show, '_get_display_size', lambda: (1080, 1920))
    # Height 2000 + 250 exceeds screen height 1080, triggering the resize path.
    result = img_show._show_img(np.zeros((2000, 100, 3), np.uint8), 'big', do_coerce=False)
    assert result is True
    assert stub_cv2.named == [('big', img_show.cv2.WINDOW_NORMAL)]
    assert len(stub_cv2.resized) == 1
    name, w, h = stub_cv2.resized[0]
    assert name == 'big'
    assert h == 1080 - 250


def test_cv2_error_returns_false_and_warns(monkeypatch):
    monkeypatch.setattr(img_show, '_has_display_env', lambda: True)
    monkeypatch.setattr(img_show, '_get_display_size', lambda: (1080, 1920))

    def boom(*args, **kwargs):
        raise img_show.cv2.error('no gui')

    monkeypatch.setattr(img_show.cv2, 'namedWindow', boom)
    with pytest.warns(UserWarning, match='windows are skipped'):
        result = img_show._show_img(np.zeros((10, 10, 3), np.uint8), 'x', do_coerce=False)
    assert result is False


def test_do_coerce_runs_pipeline(monkeypatch, stub_cv2):
    monkeypatch.setattr(img_show, '_has_display_env', lambda: True)
    monkeypatch.setattr(img_show, '_get_display_size', lambda: (1080, 1920))
    captured = []
    monkeypatch.setattr(img_show.cv2, 'imshow', lambda name, im: captured.append(im))
    # Float input must be coerced/scaled to uint8 before imshow.
    img_show._show_img(np.linspace(0.0, 1.0, 8 * 8 * 3).reshape(8, 8, 3), 'c', do_coerce=True)
    assert captured[0].dtype == np.uint8
"""Tests for ``_to_display_uint8``."""

from __future__ import annotations

import numpy as np

import img_show


def test_uint8_passthrough():
    img = np.array([[0, 128, 255]], np.uint8)
    out = img_show._to_display_uint8(img)
    assert out.dtype == np.uint8
    np.testing.assert_array_equal(out, img)


def test_uint16_scaled_by_257():
    img = np.array([[0, 257, 65535]], np.uint16)
    out = img_show._to_display_uint8(img)
    assert out.dtype == np.uint8
    np.testing.assert_array_equal(out, np.array([[0, 1, 255]], np.uint8))


def test_float_unit_range_scaled_up():
    img = np.array([[0.0, 0.5, 1.0]], np.float64)
    out = img_show._to_display_uint8(img)
    assert out.dtype == np.uint8
    np.testing.assert_array_equal(out, np.array([[0, 127, 255]], np.uint8))


def test_float_already_0_255_clipped():
    img = np.array([[0.0, 300.0, 200.0]], np.float64)
    out = img_show._to_display_uint8(img)
    np.testing.assert_array_equal(out, np.array([[0, 255, 200]], np.uint8))
