"subclass basethread for SANE"

import gc
import logging
import math
import threading
from types import SimpleNamespace

import sane
from basethread import BaseThread
from frontend import enums

logger = logging.getLogger(__name__)

# Global flag to track if sane.init() has been called
# Using a list so we can modify it from within functions
_sane_initialized = [False]


def _set_default_callbacks(kwargs):
    for callback in [
        "started_callback",
        "running_callback",
        "error_callback",
        "new_page_callback",
        "finished_callback",
    ]:
        if callback not in kwargs:
            kwargs[callback] = None


class SaneThread(BaseThread):
    "subclass basethread for SANE"

    device_handle = None
    device_name = None
    num_pages_scanned = 0
    num_pages = 0

    def handler_wrapper(self, request, handler):
        "override the handler wrapper logic to deal with SANE_STATUS_NO_DOCS"
        try:
            request.finished(handler(request))
            if request.process == "quit":
                return False
        except Exception as err:
            logger.error(
                "Error running process '%s': %s",
                request.process,
                err,
            )
            if (
                request.process == "scan_page"
                and str(err) == "Document feeder out of documents"
            ):
                request.finished(None, str(err))
            else:
                request.error(None, str(err))
                if request.process == "scan_page" and self.device_handle is not None:
                    self.cancel()
        return True

    def do_quit(self, _request):
        "exit"
        # Close the device handle properly before setting to None
        if self.device_handle is not None:
            try:
                self.device_handle.close()
            except:
                pass  # Ignore errors during cleanup
            self.device_handle = None

        # Force garbage collection to ensure all device handles are freed
        # This prevents segfaults when calling sane.exit()
        gc.collect()

        # Now it's safe to call sane.exit() since all handles are cleaned up
        if _sane_initialized[0]:
            try:
                sane.exit()
            except:
                pass  # Ignore errors during cleanup
            _sane_initialized[0] = False

    @classmethod
    def do_get_devices(cls, _request):
        "get devices"
        return [
            SimpleNamespace(name=x[0], vendor=x[1], model=x[2], label=x[3])
            for x in sane.get_devices()
        ]

    def do_open_device(self, request):
        "open device"
        device_name = request.args[0]
        # close the handle if it is open
        if self.device_handle is not None:
            self.device_handle.close()
            self.device_handle = None

        # Ensure SANE is initialized globally (only call once across all threads)
        if not _sane_initialized[0]:
            sane.init()
            _sane_initialized[0] = True

        self.device_handle = sane.open(device_name)
        self.device_name = device_name
        request.data(f"opened device '{self.device_name}'")

    def do_get_option(self, request):
        "get options"
        name = request.args[0]
        return getattr(self.device_handle, name.replace("-", "_"))

    def do_get_options(self, _request):
        "get options"
        return self.device_handle.get_options()

    def do_get_option_blocking(self, request):
        "read a single option value in the worker and signal the caller"
        name, holder, event = request.args
        try:
            holder.append(getattr(self.device_handle, name.replace("-", "_")))
        except Exception as err:
            holder.append(err)
        finally:
            event.set()

    def do_set_option(self, request):
        """Until sane.__setattr__() returns the INFO, put its functionality
        here to return it ourselves"""
        key, value = request.args
        key = key.replace("-", "_")
        dic = self.device_handle.__dict__
        if key in ("dev", "optlist", "area", "sane_signature", "scanner_model"):
            raise AttributeError("Read-only attribute: " + key)

        if key not in self.device_handle.opt:
            dic[key] = value
            return 0

        opt = dic["opt"][key]
        if opt.type == enums.TYPE_GROUP:
            raise AttributeError("Groups don't have values: " + key)
        if not enums.OPTION_IS_ACTIVE(opt.cap):
            raise AttributeError("Inactive option: " + key)
        if not enums.OPTION_IS_SETTABLE(opt.cap):
            raise AttributeError("Option can't be set by software: " + key)
        if isinstance(value, int) and opt.type == enums.TYPE_FIXED:
            # avoid annoying errors from backend if int is given instead float:
            value = float(value)
        info = dic["dev"].set_option(opt.index, value)

        # binary AND to find if we have to reload options:
        if info & enums.INFO_RELOAD_OPTIONS:
            if hasattr(self.device_handle, "__load_option_dict"):
                self.device_handle.__load_option_dict()
            elif hasattr(self.device_handle, "_SaneDev__load_option_dict"):
                self.device_handle._SaneDev__load_option_dict()

        logger.info(
            f"sane_set_option {opt.index} ({opt.name})"
            + ("" if opt.type == enums.TYPE_BUTTON else f" to {value}")
            + " returned info "
            + f"{info} ({decode_info(info)})"
            if info is not None
            else None
        )

        return info

    def do_scan_page(self, request):
        "scan page"
        if self.device_handle is None:
            raise ValueError("must open device before starting scan")
        cancel_between_pages = request.args[0] if request.args else False
        self.scan_page_progress = 0.0
        logger.debug("calling sane_start() on device %s", self.device_name)
        self.device_handle.start()
        logger.debug("sane_start() returned successfully")
        params = self.device_handle.get_parameters()
        logger.debug("sane_get_parameters(): %s", params)
        _, _, (_, lines), _, _ = params
        self.scan_page_total_lines = lines if lines > 0 else None

        def _progress_cb(current_line, total_lines):
            if total_lines > 0:
                self.scan_page_progress = min(1.0, current_line / total_lines)

        self._scan_progress_cb = _progress_cb
        return self.device_handle.snap(
            no_cancel=not cancel_between_pages, progress=self._scan_progress_cb
        )

    def do_cancel(self, _request):
        "cancel"
        if self.device_handle is not None:
            self.device_handle.cancel()

    def do_close_device(self, request):
        "close device"
        if self.device_handle is None:
            request.data("Ignoring close_device() call - no device open.")
        else:
            self.device_handle.close()
            self.device_handle = None
            request.data(f"closing device '{self.device_name}'")
            self.device_name = None

    def get_devices(self, **kwargs):
        "get devices"
        return self.send("get_devices", **kwargs)

    def open_device(self, device_name, **kwargs):
        "open device"
        return self.send("open_device", device_name, **kwargs)

    def get_options(self, **kwargs):
        "get options"
        return self.send("get_options", **kwargs)

    def get_option(self, name, **kwargs):
        "get option"
        return self.send("get_option", name, **kwargs)

    def set_option(self, name, value, **kwargs):
        "set option"
        return self.send("set_option", name, value, **kwargs)

    def get_option_value(self, name, timeout=10):
        "synchronously fetch a single option value via the worker thread"
        holder = []
        event = threading.Event()
        self.send("get_option_blocking", name, holder, event)
        if not event.wait(timeout):
            raise TimeoutError(f"Timed out reading option '{name}'")
        result = holder[0]
        if isinstance(result, Exception):
            raise result
        return result

    def scan_page(self, cancel_between_pages=False, **kwargs):
        "scan page"
        return self.send("scan_page", cancel_between_pages, **kwargs)

    def _scan_pages_finished_callback(self, response, **kwargs):
        _set_default_callbacks(kwargs)
        cancel_between_pages = kwargs.get("cancel_between_pages", False)
        if response.info is not None:
            self.num_pages_scanned += 1
            if kwargs["new_page_callback"] is not None:
                kwargs["new_page_callback"](response.info)
        if response.status == "Document feeder out of documents" or (
            self.num_pages != 0 and self.num_pages_scanned >= self.num_pages
        ):
            self.cancel()
            if kwargs["finished_callback"] is not None:
                kwargs["finished_callback"](response)
            return
        self.scan_page(
            cancel_between_pages=cancel_between_pages,
            started_callback=kwargs["started_callback"],
            running_callback=kwargs["running_callback"],
            error_callback=kwargs["error_callback"],
            finished_callback=lambda response: self._scan_pages_finished_callback(
                response,
                cancel_between_pages=cancel_between_pages,
                running_callback=kwargs["running_callback"],
                finished_callback=kwargs["finished_callback"],
                error_callback=kwargs["error_callback"],
                new_page_callback=kwargs["new_page_callback"],
            ),
        )

    def scan_pages(self, cancel_between_pages=False, **kwargs):
        "scan pages"
        self.num_pages_scanned = 0
        self.num_pages = kwargs["num_pages"]
        _set_default_callbacks(kwargs)
        return self.scan_page(
            cancel_between_pages=cancel_between_pages,
            started_callback=kwargs["started_callback"],
            running_callback=kwargs["running_callback"],
            error_callback=kwargs["error_callback"],
            finished_callback=lambda response: self._scan_pages_finished_callback(
                response,
                cancel_between_pages=cancel_between_pages,
                running_callback=kwargs["running_callback"],
                finished_callback=kwargs["finished_callback"],
                error_callback=kwargs["error_callback"],
                new_page_callback=kwargs["new_page_callback"],
            ),
        )

    def close_device(self, **kwargs):
        "close device"
        return self.send("close_device", **kwargs)

    def quit(self, **kwargs):
        "quit"
        return self.send("quit", **kwargs)

    def cancel(self, **kwargs):
        "Flag the scan routine to abort"

        # empty process queue first to stop any new process from starting
        while not self.requests.empty():
            self.requests.get()

        # Then send the thread a cancel signal
        # _self["abort_scan"] = 1
        # uuid = str(uuid_object())
        # callback[uuid]["cancelled"] = callback

        # Add a cancel request to ensure the reply is not blocked
        return self.send("cancel", **kwargs)


def decode_info(info):
    "decode the info binary mask for logs that are easier to read"
    if info == 0:
        return "none"
    opts = ["INEXACT", "RELOAD_OPTIONS", "RELOAD_PARAMS"]
    this = []

    # number of binary digits required
    num = math.log2(info)
    num = int(num) + (1 if num > int(num) else 0)

    i = len(opts)
    while num > i:
        if info >= 2 ** (num - 1):
            this.append("?")
            info -= 2 ** (num - 1)
        num -= 1

    while num > -1:
        if info >= 2**num:
            this.append(opts[num])
            info -= 2**num
        num -= 1

    return " + ".join(this)
