"test frontend/image_sane.py"

import threading

import pytest
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import PIL
from gi.repository import GLib
from frontend import enums
from frontend.image_sane import SaneThread
from loop_helpers import safe_mainloop


class FakeBrscan5Device:
    """Emulate a backend with brscan5-style frame prefetching.

    The feeder starts successfully while pages remain buffered, otherwise it
    fails like a feeder that has run out of paper. Calling cancel() drops any
    prefetched-but-unread frames, mirroring ``sane_cancel()`` discarding the
    backend's buffer. Flatbed mode refills the glass on every ``start()``.
    """

    def __init__(self, frames=None, refill=False):
        self.buffered = list(frames) if frames is not None else []
        self.refill = refill
        self.page_counter = 0
        self.cancel_calls = 0
        self.start_calls = 0
        self.snap_no_cancel = []
        self.error_on_frame = None
        self.block_after_first = False
        self.slow_event = threading.Event()

    def start(self):
        "start a page: flatbed refills the glass, feeder runs out when empty"
        if self.refill:
            self.page_counter += 1
            self.buffered.append(self.page_counter)
        if not self.buffered:
            raise Exception("Document feeder out of documents")
        self.start_calls += 1

    def get_parameters(self):
        "report scan parameters with a small but positive line count"
        return ("color", 1, (100, 10), 8, 30)

    def snap(self, no_cancel=False, progress=None):
        "read the next buffered frame, optionally blocking to emulate a transfer"
        self.snap_no_cancel.append(no_cancel)
        frame = self.buffered.pop(0)
        if self.block_after_first:
            self.block_after_first = False
            self.slow_event.wait(2)
        if frame == self.error_on_frame:
            raise Exception("SANE device exploded")
        if not no_cancel:
            self.cancel()
        return frame

    def cancel(self):
        "cancel: drop any prefetched-but-unread frames"
        self.cancel_calls += 1
        self.buffered.clear()


def test_error_handling():
    "test frontend/image_sane.py"
    thread = SaneThread()
    thread.start()

    asserts = 0

    def scan_error_callback(response):
        nonlocal asserts
        assert (
            response.request.process == "scan_page"
        ), "scan_page without opening device"
        assert (
            response.status == "must open device before starting scan"
        ), "scan_error_callback status"
        asserts += 1

    thread.scan_page(error_callback=scan_error_callback)
    thread.send("quit", finished_callback=lambda _: mlp.quit())
    mlp = safe_mainloop(2000)
    mlp.run()
    assert asserts == 1, "checked all expected responses #1"


def test_2():
    "test frontend/image_sane.py #2"
    thread = SaneThread()
    thread.start()

    asserts = 0

    def get_devices_callback(response):
        nonlocal asserts
        assert (
            response.request.process == "get_devices"
        ), "get_devices_finished_callback"
        assert isinstance(response.info, list), "get_devices_finished_callback"
        asserts += 1

    thread.get_devices(finished_callback=get_devices_callback)
    thread.send("quit", finished_callback=lambda _: mlp.quit())
    mlp = safe_mainloop(2000)
    mlp.run()
    assert asserts == 1, "checked all expected responses #2"


def test_3():
    "test frontend/image_sane.py #3"
    thread = SaneThread()
    thread.start()

    asserts = 0

    mlp = safe_mainloop(2000)

    def open_callback(response):
        nonlocal asserts
        assert response.request.process == "open_device", "open_callback"
        asserts += 1
        mlp.quit()

    thread.open_device(device_name="test", finished_callback=open_callback)
    mlp.run()
    assert thread.device_name == "test", "set device_name"
    assert asserts == 1, "checked all expected responses #3"

    mlp = safe_mainloop(2000)

    def scan_page_finished_callback(response):
        nonlocal asserts
        assert isinstance(
            response.info, PIL.Image.Image
        ), "scan_page finished_callback returned image"
        assert response.info.size[0] > 0, "scan_page finished_callback image width"
        assert response.info.size[1] > 0, "scan_page finished_callback image height"
        asserts += 1
        mlp.quit()

    thread.scan_page(finished_callback=scan_page_finished_callback)
    mlp.run()
    assert asserts == 2, "checked all expected responses #4"

    def new_page_callback(image):
        nonlocal asserts
        assert isinstance(
            image, PIL.Image.Image
        ), "scan_page finished_callback returned image"
        assert image.size[0] > 0, "scan_page finished_callback image width"
        assert image.size[1] > 0, "scan_page finished_callback image height"
        asserts += 1

    mlp = safe_mainloop(2000)

    def scan_pages_finished_callback(response):
        nonlocal asserts
        assert response.request.process == "scan_page", "scan_pages_finished_callback"
        assert thread.num_pages_scanned == 2, "scanned 2 pages"
        asserts += 1
        mlp.quit()

    thread.scan_pages(
        num_pages=2,
        new_page_callback=new_page_callback,
        finished_callback=scan_pages_finished_callback,
    )
    mlp.run()
    assert asserts == 5, "checked all expected responses #5"

    mlp = safe_mainloop(2000)

    def open_again_callback(response):
        nonlocal asserts
        assert response.request.process == "open_device", "open without closing"
        asserts += 1
        mlp.quit()

    thread.open_device(device_name="test", finished_callback=open_again_callback)
    thread.send("quit")
    mlp.run()
    assert asserts == 6, "checked all expected responses #6"


def test_4():
    "test frontend/image_sane.py #4"
    thread = SaneThread()
    thread.start()

    mlp = safe_mainloop(2000)

    asserts = 0

    def get_options_callback(response):
        nonlocal asserts
        assert response.request.process == "get_options", "get_options"
        assert isinstance(response.info, list), "get_options return a list of options"
        assert response.info[21][1] == "enable-test-options"
        asserts += 1
        mlp.quit()

    thread.open_device(device_name="test")
    thread.get_options(finished_callback=get_options_callback)
    mlp.run()
    assert asserts == 1, "checked all expected responses #7"

    mlp = safe_mainloop(2000)

    def get_option_callback(response):
        nonlocal asserts
        assert response.info == 0, "enable-test-options defaults to False"
        asserts += 1
        mlp.quit()

    thread.get_option("enable-test-options", finished_callback=get_option_callback)
    mlp.run()
    assert asserts == 2, "checked all expected responses #8"

    mlp = safe_mainloop(2000)
    thread.set_option(
        "enable-test-options", True, finished_callback=lambda response: mlp.quit()
    )
    mlp.run()

    mlp = safe_mainloop(2000)

    def get_option_callback2(response):
        nonlocal asserts
        assert response.info == 1, "enable-test-options now True"
        asserts += 1
        mlp.quit()

    thread.get_option("enable-test-options", finished_callback=get_option_callback2)
    mlp.run()
    assert asserts == 3, "checked all expected responses #9"

    # FIXME: get cancel() working
    # ran_finished = False

    # mlp = GLib.MainLoop()
    # def get_options_callback2(response):
    #     nonlocal ran_finished
    #     ran_finished = True
    #     mlp.quit()

    # ran_cancelled = False

    # def cancelled_callback(response):
    #     nonlocal ran_cancelled
    #     ran_cancelled = True
    #     mlp.quit()

    # thread.get_options(
    #     finished_callback=get_options_callback2, cancelled_callback=cancelled_callback
    # )
    # thread.cancel(cancelled_callback=cancelled_callback)
    # GLib.timeout_add(2000, mlp.quit)  # to prevent it hanging
    # mlp.run()

    # assert not ran_finished, "cancelled jobs don't run the finished callback"
    # assert ran_cancelled, "ran the cancelled callback"

    mlp = safe_mainloop(2000)

    def close_device_callback(response):
        nonlocal asserts
        assert response.request.process == "close_device", "close_device"
        asserts += 1
        mlp.quit()

    thread.close_device(finished_callback=close_device_callback)
    mlp.run()
    assert thread.device_handle is None, "closed device"
    thread.send("quit")
    assert asserts == 4, "checked all expected responses #10"


def test_5_edge_cases_part_1():
    "test frontend/image_sane.py edge cases part 1"
    thread = SaneThread()
    thread.start()

    mlp = safe_mainloop(2000)
    asserts = 0

    # Open device
    def open_callback(response):
        nonlocal asserts
        assert response.request.process == "open_device"
        asserts += 1
        mlp.quit()

    thread.open_device(device_name="test", finished_callback=open_callback)
    mlp.run()
    assert asserts == 1, "opened device"

    # 1. Read-only attribute
    def error_callback_readonly(response):
        nonlocal asserts
        assert "Read-only attribute: dev" in response.status
        asserts += 1
        mlp.quit()

    thread.set_option("dev", "foo", error_callback=error_callback_readonly)
    mlp.run()
    assert asserts == 2, "checked read-only attribute"

    # 2. Inactive option
    def error_callback_inactive(response):
        nonlocal asserts
        assert "Inactive option: three_pass" in response.status
        asserts += 1
        mlp.quit()

    thread.set_option("three-pass", True, error_callback=error_callback_inactive)
    mlp.run()
    assert asserts == 3, "checked inactive option"

    thread.send("quit", finished_callback=lambda _: mlp.quit())
    mlp.run()


def test_5_edge_cases_part_2():
    "test frontend/image_sane.py edge cases part 2"
    thread = SaneThread()
    thread.start()

    mlp = safe_mainloop(2000)
    asserts = 0

    # Open device
    def open_callback(response):
        nonlocal asserts
        assert response.request.process == "open_device"
        asserts += 1
        mlp.quit()

    thread.open_device(device_name="test", finished_callback=open_callback)
    mlp.run()

    # 3. Non-existent option
    def finished_callback_nonexistent(response):
        nonlocal asserts
        assert response.info == 0
        asserts += 1
        mlp.quit()

    thread.set_option(
        "nonexistent-opt", 123, finished_callback=finished_callback_nonexistent
    )
    mlp.run()
    assert asserts == 2, "checked nonexistent option"

    # 4. Enable test options
    def finished_callback_enable(response):
        nonlocal asserts
        assert isinstance(response.info, int)
        asserts += 1
        mlp.quit()

    thread.set_option(
        "enable-test-options", True, finished_callback=finished_callback_enable
    )
    mlp.run()
    assert asserts == 3, "enabled test options"

    # 5. Fixed type conversion
    def finished_callback_fixed(response):
        nonlocal asserts
        assert isinstance(response.info, int)
        asserts += 1
        mlp.quit()

    thread.set_option("fixed", 42, finished_callback=finished_callback_fixed)
    mlp.run()
    assert asserts == 4, "checked fixed type conversion"

    thread.send("quit", finished_callback=lambda _: mlp.quit())
    mlp.run()


def test_6_mock_device():
    "test with mocked device for specific edge cases"

    class MockDevice:
        "Custom mock device class to avoid MagicMock hasattr issues and ensure structure"

        def __init__(self):
            "Initialize mock device with options and dev mock"
            self.opt = {}
            self.dev = MagicMock()
            # Initial setup for first reload test
            # We need __load_option_dict for hasattr check
            self.__dict__["__load_option_dict"] = MagicMock()
            # And _SaneThread__load_option_dict because SaneThread mangles the call
            self.__dict__["_SaneThread__load_option_dict"] = self.__dict__[
                "__load_option_dict"
            ]

        def close(self):
            "Mock close method"

    with patch("sane.open") as mock_open:
        mock_dev_instance = MockDevice()
        mock_open.return_value = mock_dev_instance

        # Setup options
        opt_group = SimpleNamespace(
            name="group-option",
            type=enums.TYPE_GROUP,
            cap=enums.CAP_SOFT_SELECT,
            index=1,
        )

        opt_unsettable = SimpleNamespace(
            name="unsettable-option",
            type=enums.TYPE_BOOL,
            cap=enums.CAP_SOFT_DETECT,  # Not SOFT_SELECT (1)
            index=2,
        )

        opt_reload = SimpleNamespace(
            name="reload-option",
            type=enums.TYPE_BOOL,
            cap=enums.CAP_SOFT_SELECT,
            index=3,
        )

        mock_dev_instance.opt = {
            "group_option": opt_group,
            "unsettable_option": opt_unsettable,
            "reload_option": opt_reload,
        }

        # Setup set_option return values on the inner 'dev' mock
        def set_option_side_effect(index, _value):
            return enums.INFO_RELOAD_OPTIONS

        mock_dev_instance.dev.set_option.side_effect = set_option_side_effect

        thread = SaneThread()
        thread.start()

        mlp = safe_mainloop(2000)
        asserts = 0

        # Open device (mocks sane.open)
        def open_cb(response):
            nonlocal asserts
            assert response.request.process == "open_device"
            asserts += 1
            mlp.quit()

        thread.open_device("mock_device", finished_callback=open_cb)
        mlp.run()

        # 1. Test Group Option (Line 103)
        def error_cb_group(response):
            nonlocal asserts
            assert "Groups don't have values: group_option" in response.status
            asserts += 1
            mlp.quit()

        thread.set_option("group-option", "val", error_callback=error_cb_group)
        mlp.run()

        # 2. Test Unsettable Option (Line 107)
        def error_cb_unsettable(response):
            nonlocal asserts
            assert (
                "Option can't be set by software: unsettable_option" in response.status
            )
            asserts += 1
            mlp.quit()

        thread.set_option("unsettable-option", True, error_callback=error_cb_unsettable)
        mlp.run()

        # 3. Test Reload Option with __load_option_dict (Line 116)
        def finished_cb_reload(_response):
            nonlocal asserts
            asserts += 1
            mlp.quit()

        thread.set_option("reload-option", True, finished_callback=finished_cb_reload)
        mlp.run()

        mock_dev_instance.__dict__["__load_option_dict"].assert_called_once()

        # Reset for next part
        mock_dev_instance.dev.set_option.reset_mock()
        mock_dev_instance.dev.set_option.side_effect = set_option_side_effect

        # 4. Test Reload Option with _SaneDev__load_option_dict (Line 117-118)
        # Remove __load_option_dict and aliases
        del mock_dev_instance.__dict__["__load_option_dict"]
        del mock_dev_instance.__dict__["_SaneThread__load_option_dict"]

        mock_dev_instance.__dict__["_SaneDev__load_option_dict"] = MagicMock()

        thread.set_option("reload-option", False, finished_callback=finished_cb_reload)
        mlp.run()

        mock_dev_instance.__dict__["_SaneDev__load_option_dict"].assert_called_once()

        assert asserts == 5

        thread.send("quit", finished_callback=lambda _: mlp.quit())
        mlp.run()


def test_7_close_device_not_open():
    "test do_close_device when device is not open (line 145)"
    thread = SaneThread()
    thread.start()

    mlp = safe_mainloop(2000)
    asserts = 0

    def data_callback(response):
        nonlocal asserts
        assert response.info == "Ignoring close_device() call - no device open."
        asserts += 1

    def finished_callback(response):
        nonlocal asserts
        assert response.request.process == "close_device"
        asserts += 1
        mlp.quit()

    thread.close_device(
        data_callback=data_callback, finished_callback=finished_callback
    )
    mlp.run()

    assert asserts == 2
    thread.send("quit", finished_callback=lambda _: mlp.quit())
    mlp.run()


def test_8_cancel_empties_queue():
    "test cancel() empties the requests queue (line 240)"
    thread = SaneThread()
    # Don't start the thread yet, so requests stay in the queue

    thread.get_devices()
    thread.get_devices()
    assert thread.requests.qsize() == 2

    thread.cancel()

    # cancel() calls requests.get() for each existing request, then send("cancel")
    # send("cancel") puts 1 request in the queue.
    assert thread.requests.qsize() == 1

    request = thread.requests.get()
    assert request.process == "cancel"


def test_9_quit_handles_sane_exit_exception():
    "test do_quit handles exception from sane.exit() (lines 78-79)"
    with patch("sane.exit") as mock_exit:
        # Make sane.exit() raise an exception
        mock_exit.side_effect = RuntimeError("sane.exit() failed")

        thread = SaneThread()
        thread.start()

        mlp = safe_mainloop(2000)

        def open_callback(response):
            assert response.request.process == "open_device"
            mlp.quit()

        # Open a device to ensure sane is initialized
        thread.open_device(device_name="test", finished_callback=open_callback)
        mlp.run()

        # Quit should handle the exception from sane.exit() gracefully
        thread.send("quit", finished_callback=lambda _: mlp.quit())
        mlp.run()

        # Verify sane.exit() was called and the exception was handled
        mock_exit.assert_called_once()


def test_get_option_value_timeout():
    "get_option_value raises TimeoutError when the worker does not respond in time"
    thread = SaneThread()
    thread.start()
    # Replace the worker-side handler with one that never sets the completion
    # event, so the blocking wait must time out.
    with patch.object(thread, "do_get_option_blocking", lambda request: None):
        with pytest.raises(TimeoutError):
            thread.get_option_value("enable-test-options", timeout=0.05)
    thread.send("quit")
    thread.join(timeout=1)


def _run_with_fake(fake, scan_kwargs):
    "open a fake device, run scan_pages with the given kwargs, then quit"
    thread = SaneThread()
    thread.start()

    pages = []
    errors = []

    def new_page(image):
        pages.append(image)

    def error(response):
        errors.append(response.status)
        mlp.quit()

    def quit_mlp(_response):
        mlp.quit()

    mlp = safe_mainloop(2000)
    with patch("sane.open", return_value=fake):
        thread.open_device(device_name="fake", finished_callback=quit_mlp)
        mlp.run()

    mlp = safe_mainloop(2000)
    thread.scan_pages(
        new_page_callback=new_page,
        error_callback=error,
        finished_callback=quit_mlp,
        **scan_kwargs,
    )
    mlp.run()

    mlp = safe_mainloop(2000)
    thread.send("quit", finished_callback=quit_mlp)
    mlp.run()
    return thread, pages, errors


def test_duplex_feeder_imports_both_sides():
    "regression test for issue #73: a duplex feeder imports both sides"
    fake = FakeBrscan5Device(frames=[1, 2])
    thread, pages, _errors = _run_with_fake(fake, {"num_pages": 2})
    assert pages == [1, 2], "both sides of the duplex sheet imported"
    assert fake.cancel_calls == 1, "a single cancel at batch end"
    assert fake.snap_no_cancel == [True, True], "no cancel requested between pages"


def test_flatbed_cancel_between_pages_enabled():
    "flatbed with the setting enabled cancels the session between pages"
    fake = FakeBrscan5Device(refill=True)
    thread, pages, _errors = _run_with_fake(
        fake, {"num_pages": 2, "cancel_between_pages": True}
    )
    assert pages == [1, 2], "both flatbed pages imported"
    assert fake.cancel_calls == 3, "cancelled after each page and at batch end"
    assert fake.snap_no_cancel == [False, False], "cancel requested between pages"


def test_flatbed_cancel_between_pages_disabled():
    "flatbed with the setting disabled only cancels at batch end"
    fake = FakeBrscan5Device(refill=True)
    thread, pages, _errors = _run_with_fake(
        fake, {"num_pages": 2, "cancel_between_pages": False}
    )
    assert pages == [1, 2], "both flatbed pages imported"
    assert fake.cancel_calls == 1, "only one cancel at batch end"
    assert fake.snap_no_cancel == [True, True], "no cancel requested between pages"


def test_page_limit_discards_buffered_frames():
    "reaching the requested page count terminates and drops buffered frames"
    fake = FakeBrscan5Device(frames=[1, 2, 3])
    thread, pages, _errors = _run_with_fake(fake, {"num_pages": 2})
    assert pages == [1, 2], "only the requested two pages imported"
    assert fake.cancel_calls == 1, "session terminated at page count"
    assert fake.buffered == [], "prefetched page 3 discarded by the terminal cancel"


def test_mid_batch_error_reports_and_terminates():
    "an error mid-batch is reported and the session is terminated"
    fake = FakeBrscan5Device(frames=[1, 2, 3])
    fake.error_on_frame = 2
    thread, pages, errors = _run_with_fake(fake, {"num_pages": 5})
    assert pages == [1], "only the first page imported"
    assert errors == ["SANE device exploded"], "mid-batch error reported"
    assert fake.cancel_calls >= 1, "session terminated after the error"


def test_user_cancel_terminates_session():
    "a user cancel during a page transfer terminates the session"
    thread = SaneThread()
    thread.start()
    fake = FakeBrscan5Device(frames=[1, 2])
    fake.block_after_first = True
    pages = []
    cancel_sent = [False]

    def new_page(image):
        pages.append(image)

    def started(_response):
        pass

    def running(_response):
        if not cancel_sent[0]:
            cancel_sent[0] = True
            thread.cancel()
            fake.slow_event.set()

    def quit_mlp(_response):
        mlp.quit()

    mlp = safe_mainloop(2000)
    with patch("sane.open", return_value=fake):
        thread.open_device(device_name="fake", finished_callback=quit_mlp)
        mlp.run()

    mlp = safe_mainloop(2000)
    thread.scan_pages(
        num_pages=2,
        started_callback=started,
        running_callback=running,
        new_page_callback=new_page,
        error_callback=quit_mlp,
        finished_callback=quit_mlp,
    )
    mlp.run()

    mlp = safe_mainloop(2000)
    thread.send("quit", finished_callback=quit_mlp)
    mlp.run()
    assert pages == [1], "only the in-flight page imported"
    assert fake.cancel_calls >= 1, "user cancel terminated the session"
