"""Base classes and utilities for calibration and profiling workflows.

It includes base worker classes, multiprocessing support, and methods for
interacting with Argyll CMS utilities like `xicclu`. The module also handles
subprocess management, logging, and temporary directory creation.
"""

from __future__ import annotations

import contextlib
import math
import os
import shlex
import shutil
import struct
import subprocess as sp
import sys
import tempfile
import textwrap
import traceback
from binascii import hexlify
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    if sys.version_info >= (3, 11):
        from typing import Self
    else:
        from typing_extensions import Self

if sys.platform == "win32":
    import win32api


from DisplayCAL import (
    colormath,
    config,
)
from DisplayCAL import (
    localization as lang,
)
from DisplayCAL.argyll import get_argyll_util, get_argyll_version
from DisplayCAL.cgats import CGATS
from DisplayCAL.colormath import (
    VidRGB_to_cLUT65,
    VidRGB_to_eeColor,
    eeColor_to_VidRGB,
)
from DisplayCAL.config import (
    # exe_ext,
    # fs_enc,
    # get_data_path,
    # getcfg,
    PROFILE_EXT,
)
from DisplayCAL.debughelpers import (
    Error,
    Info,
    # UnloggedError,
    # UnloggedInfo,
    # UnloggedWarning,
    # Warn,
)
from DisplayCAL.icc_profile import (
    ICCProfile,
    LUT16Type,
)
from DisplayCAL.log import LogFile
from DisplayCAL.meta import NAME as APPNAME
from DisplayCAL.multiprocess import MAX_CPUS, mp, pool_slice
from DisplayCAL.options import DEBUG, VERBOSE
from DisplayCAL.util_os import quote_args
from DisplayCAL.util_str import make_filename_safe, safe_basestring, safe_str


def _xicclu_mp(
    chunk,
    thread_abort_event,
    progress_queue,
    profile_filename,
    intent="r",
    direction="f",
    order="n",
    pcs=None,
    scale=1,
    cwd=None,
    startupinfo=None,
    use_icclu=False,
    use_cam_clipping=False,
    logfile=None,
    show_actual_if_clipped=False,
    input_encoding=None,
    output_encoding=None,
    abortmessage="Aborted",
    output_format=None,
    reverse=False,
    convert_video_rgb_to_clut65=False,
    verbose=1,
):
    """Xicclu multiprocessing worker."""
    if not config.CFG.items(config.configparser.DEFAULTSECT):
        config.initcfg()
    profile = ICCProfile(profile_filename)
    xicclu = Xicclu(
        profile,
        intent,
        direction,
        order,
        pcs,
        scale,
        cwd,
        startupinfo,
        use_icclu,
        use_cam_clipping,
        logfile,
        None,
        show_actual_if_clipped,
        input_encoding,
        output_encoding,
        convert_video_rgb_to_clut65,
        verbose,
    )
    prevperc = 0
    start = 0
    num_subchunks = 50
    subchunksize = float(len(chunk)) / num_subchunks
    for i in range(num_subchunks):
        if (
            thread_abort_event is not None
            and getattr(sys, "_sigbreak", False)
            and not thread_abort_event.is_set()
        ):
            thread_abort_event.set()
            print("Got SIGBREAK, aborting thread...")
        if thread_abort_event is not None and thread_abort_event.is_set():
            xicclu.exit(raise_exception=False)
            return Info(abortmessage)
        end = math.ceil(subchunksize * (i + 1))
        xicclu(chunk[start:end])
        start = end
        perc = round((i + 1.0) / num_subchunks * 100)
        if progress_queue and perc > prevperc:
            progress_queue.put(perc - prevperc)
            prevperc = perc
    xicclu.exit()
    return xicclu.get(output_format=output_format, reverse=reverse)


def _mp_generate_B2A_clut(
    chunk,
    thread_abort_event,
    progress_queue,
    profile_filename,
    intent,
    direction,
    pcs,
    use_cam_clipping,
    clutres,
    step,
    threshold,
    threshold2,
    interp,
    Linterp,
    m2,
    XYZbp,
    XYZwp,
    bpc,
    abortmessage="Aborted",
):
    """B2A cLUT generation worker.

    This should be spawned as a multiprocessing process

    """
    if DEBUG:
        print("comtypes?", "comtypes" in str(list(sys.modules.keys())))
        print("numpy?", "numpy" in str(list(sys.modules.keys())))
        print("wx?", "wx" in str(list(sys.modules.keys())))
        print("x3dom?", "x3dom" in str(list(sys.modules.keys())))
    if not config.CFG.items(config.configparser.DEFAULTSECT):
        config.initcfg()
    idata = []
    abmaxval = 255 + (255 / 256.0)
    profile = ICCProfile(profile_filename)
    xicclu1 = Xicclu(profile, intent, direction, "n", pcs, 100)
    xicclu2 = xicclu1
    if use_cam_clipping:
        # Use CAM Jab for clipping for cLUT grid points after a given
        # threshold
        xicclu2 = Xicclu(
            profile, intent, direction, "n", pcs, 100, use_cam_clipping=True
        )
    prevperc = 0
    count = 0
    chunksize = len(chunk)
    for interp_tuple in (interp, Linterp):
        if interp_tuple:
            # Use numpy for speed
            if interp_tuple is interp:
                interp_list = list(interp_tuple)
            else:
                interp_list = [interp_tuple]
            for i, ointerp in enumerate(interp_list):
                interp_list[i] = colormath.Interp(
                    ointerp.xp, ointerp.fp, use_numpy=True
                )
            if interp_tuple is interp:
                interp = interp_list
            else:
                Linterp = interp_list[0]
    m2i = m2
    if profile.connectionColorSpace == b"XYZ":
        m2i = m2.inverted()
    # Accumulate points and hand them to xicclu in batches instead of one at
    # a time. Each call writes to and flushes the xicclu subprocess's stdin
    # pipe, so calling it per grid point (up to clutres**3 times) turns into
    # a syscall-bound hot loop. Flush each batch once it reaches
    # xicclu_batch_size rather than waiting for the whole worker chunk, so
    # xicclu1 and xicclu2 (which run as separate subprocesses and can
    # compute concurrently) both keep receiving work throughout the loop
    # instead of xicclu2 sitting idle until all of xicclu1's input is sent.
    xicclu_batch_size = 1000
    xicclu1_batch = []
    xicclu2_batch = []
    for a in chunk:
        if thread_abort_event.is_set():
            if use_cam_clipping:
                xicclu2.exit()
            xicclu1.exit()
            return Info(abortmessage)
        for b in range(clutres):
            for c in range(clutres):
                d, e, f = [v * step for v in (a, b, c)]
                if profile.connectionColorSpace == b"XYZ":
                    # Apply TRC to XYZ values to distribute them optimally
                    # across cLUT grid points.
                    XYZ = [interp[i](v) for i, v in enumerate((d, e, f))]
                    # print "%3.6f %3.6f %3.6f" % tuple(XYZ), '->',
                    # Scale into PCS
                    v = m2i * XYZ
                    if bpc and XYZbp != [0, 0, 0]:
                        v = colormath.blend_blackpoint(v[0], v[1], v[2], None, XYZbp)
                    # print "%3.6f %3.6f %3.6f" % tuple(v)
                    # raw_input()
                    if intent == "a":
                        v = colormath.adapt(
                            *[*v, XYZwp, list(profile.tags.wtpt.ir.values())]
                        )
                else:
                    # Legacy CIELAB
                    L = Linterp(d * 100)
                    v = L, -128 + e * abmaxval, -128 + f * abmaxval
                idata.append("{:.6f} {:.6f} {:.6f}".format(*tuple(v)))
                # Lookup CIE -> device values through profile using xicclu
                if not use_cam_clipping or (
                    pcs == "x" and a <= threshold and b <= threshold and c <= threshold
                ):
                    xicclu1_batch.append(v)
                    if len(xicclu1_batch) >= xicclu_batch_size:
                        xicclu1(xicclu1_batch)
                        xicclu1_batch = []
                if use_cam_clipping and (
                    pcs == "l" or a > threshold2 or b > threshold2 or c > threshold2
                ):
                    xicclu2_batch.append(v)
                    if len(xicclu2_batch) >= xicclu_batch_size:
                        xicclu2(xicclu2_batch)
                        xicclu2_batch = []
                count += 1.0
            perc = round(count / (chunksize * clutres**2) * 100)
            if progress_queue and perc > prevperc:
                progress_queue.put(perc - prevperc)
                prevperc = perc
    if xicclu1_batch:
        xicclu1(xicclu1_batch)
    if use_cam_clipping:
        if xicclu2_batch:
            xicclu2(xicclu2_batch)
        xicclu2.exit()
        data2 = xicclu2.get()
    else:
        data2 = []
    xicclu1.exit()
    data1 = xicclu1.get()
    return idata, data1, data2


def printcmdline(cmd, args=None, fn=None, cwd=None):
    """Pretty-print a command line."""
    if fn is None:
        fn = print
    if args is None:
        args = []
    if cwd is None:
        cwd = os.getcwd()
    fn(f"  {cmd}")
    lines = []
    for item in args:
        # convert all args to str
        if not isinstance(item, str):
            if isinstance(item, bytes):
                item = item.decode("utf-8")
            item = str(item)
        if item.find(os.path.sep) > -1 and os.path.dirname(item) == cwd:
            item = os.path.basename(item)
        if sys.platform == "win32":
            item = sp.list2cmdline([item])
            if not item.startswith('"'):
                item = quote_args([item])[0]
        else:
            item = shlex.quote(item)
        lines.append(item)
    for line in lines:
        fn(
            textwrap.fill(
                line,
                80,
                expand_tabs=False,
                replace_whitespace=False,
                initial_indent="    ",
                subsequent_indent="      ",
            )
        )


class ThreadAbort:
    """Thread abort event class."""

    def __init__(self):
        self.event = mp.Event()

    def __bool__(self) -> bool:
        """Check if the event is set.

        Returns:
            bool: True if the event is set, False otherwise.
        """
        return self.event.is_set()

    def __cmp__(self, other):
        """Compare the event state with another value.

        Args:
            other: The value to compare with.

        Returns:
            int: -1 if the event is set and other is not, 1 if the event is not
                 set and other is, 0 if both are in the same state.
        """
        if self.event.is_set() < other:
            return -1
        if self.event.is_set() > other:
            return 1
        return 0


class WorkerBase:
    """Base worker class for calibration and profiling tasks."""

    def __init__(self):
        self.sessionlogfile = None
        self.subprocess_abort = False
        self.tempdir = None
        self._thread_abort = ThreadAbort()

    def create_tempdir(self):
        """Create a temporary working directory and return its path.

        Returns:
            str | Exception: The path to the temporary directory, or an Error
                if creation fails.
        """
        if not self.tempdir or not os.path.isdir(self.tempdir):
            # we create the tempdir once each calibrating/profiling run
            # (deleted by 'wrapup' after each run)
            if VERBOSE >= 2:
                if not self.tempdir:
                    msg = "there is none"
                else:
                    msg = f"the previous ({self.tempdir}) no longer exists"
                print(f"{APPNAME}: Creating a new temporary directory because", msg)
            try:
                self.tempdir = tempfile.mkdtemp(prefix=f"{APPNAME}-")
            except Exception as exception:
                self.tempdir = None
                return Error(
                    "Error - couldn't create temporary directory: "
                    f"{safe_str(exception)}"
                )
        return self.tempdir

    def isalive(self, subprocess=None):
        """Check if subprocess is still alive.

        Args:
            subprocess: The subprocess to check (default: None, uses self.subprocess).
        """
        if not subprocess:
            subprocess = getattr(self, "subprocess", None)
        return subprocess and (
            (hasattr(subprocess, "poll") and subprocess.poll() is None)
            or (hasattr(subprocess, "isalive") and subprocess.isalive())
        )

    def log(self, *args, **kwargs):
        """Log to global logfile and session logfile (if any)."""
        # if we have any exceptions print the traceback, so we bust'em.
        if any(isinstance(arg, BaseException) for arg in args):
            traceback.print_exc()
        msg = " ".join(safe_basestring(arg) for arg in args)
        fn = kwargs.get("fn", print)
        fn(msg)
        if self.sessionlogfile:
            self.sessionlogfile.write(f"{msg}\n")

    @property
    def thread_abort(self):
        """Get the thread abort event.

        Returns:
            ThreadAbort: The thread abort event, which can be used to signal
                that the thread should be aborted.
        """
        return self._thread_abort

    @thread_abort.setter
    def thread_abort(self, abort):
        """Set the thread abort event.

        Args:
            abort (bool): If True, set the thread abort event to signal
                that the thread should be aborted.
        """
        if abort:
            self._thread_abort.event.set()
        else:
            self._thread_abort.event.clear()

    def xicclu(
        self,
        profile,
        idata,
        intent="r",
        direction="f",
        order="n",
        pcs=None,
        scale=1,
        cwd=None,
        startupinfo=None,
        raw=False,
        logfile=None,
        use_icclu=False,
        use_cam_clipping=False,
        get_clip=False,
        show_actual_if_clipped=False,
        input_encoding=None,
        output_encoding=None,
    ):
        """Call xicclu, feed input floats into stdin, return output floats.

        input data needs to be a list of 3-tuples (or lists) with floats,
        alternatively a list of strings.
        output data will be returned in same format, or as list of strings
        if 'raw' is true.

        Args:
            profile (ICCProfile or CGATS): The ICC profile to use.
            idata (list): Input data to be processed by xicclu.
            intent (str): The rendering intent to use (default: "r").
            direction (str): The direction of the transformation (default: "f").
            order (str): The order of the transformation (default: "n").
            pcs (str): The PCS to use (default: None).
            scale (int): The scale factor for the transformation (default: 1).
            cwd (str): The current working directory (default: None).
            startupinfo: Startup information for subprocesses (default: None).
            raw (bool): If True, return raw output as a list of strings.
            logfile: Log file for output messages (default: None).
            use_icclu (bool): Whether to use icclu instead of xicclu
                (default: False).
            use_cam_clipping (bool): Whether to use CAM clipping
                (default: False).
            get_clip (bool): If True, include clipping information in the
                output.
            show_actual_if_clipped (bool): Whether to show actual values if clipped
                (default: False).
            input_encoding (str): Input encoding for xicclu
                (default: None).
            output_encoding (str): Output encoding for xicclu
                (default: None).
        """
        with Xicclu(
            profile,
            intent,
            direction,
            order,
            pcs,
            scale,
            cwd,
            startupinfo,
            use_icclu,
            use_cam_clipping,
            logfile,
            self,
            show_actual_if_clipped,
            input_encoding,
            output_encoding,
        ) as xicclu:
            xicclu(idata)
        return xicclu.get(raw, get_clip)


class Xicclu(WorkerBase):
    """Xicclu worker class.

    Args:
        profile (ICCProfile or CGATS): The ICC profile to use.
        intent (str): The rendering intent to use (default: "r").
        direction (str): The direction of the transformation (default: "f").
        order (str): The order of the transformation (default: "n").
        pcs (str): The PCS to use (default: None).
        scale (int): The scale factor for the transformation (default: 1).
        cwd (str): The current working directory (default: None).
        startupinfo: Startup information for subprocesses (default: None).
        use_icclu (bool): Whether to use icclu instead of xicclu (default: False).
        use_cam_clipping (bool): Whether to use CAM clipping (default: False).
        logfile: Log file for output messages (default: None).
        worker: Worker instance for multiprocessing (default: None).
        show_actual_if_clipped (bool): Whether to show actual values if clipped
            (default: False).
        input_encoding (str): Input encoding for xicclu (default: None).
        output_encoding (str): Output encoding for xicclu (default: None).
        convert_video_rgb_to_clut65 (bool): Whether to convert video RGB
            values to cLUT65 format.
        verbose (int): Verbosity level for logging.
    """

    def __init__(
        self,
        profile,
        intent="r",
        direction="f",
        order="n",
        pcs=None,
        scale=1,
        cwd=None,
        startupinfo=None,
        use_icclu=False,
        use_cam_clipping=False,
        logfile=None,
        worker=None,
        show_actual_if_clipped=False,
        input_encoding=None,
        output_encoding=None,
        convert_video_rgb_to_clut65=False,
        verbose=1,
    ):
        if not profile:
            raise Error(f"Xicclu: Profile is {profile!r}")
        WorkerBase.__init__(self)
        self.scale = scale
        self.convert_video_rgb_to_clut65 = convert_video_rgb_to_clut65
        self.logfile = logfile
        self.worker = worker
        self.temp = False
        utilname = "icclu" if use_icclu else "xicclu"
        xicclu = get_argyll_util(utilname)
        if not xicclu:
            raise Error(lang.getstr("argyll.util.not_found", utilname))
        if not isinstance(profile, (CGATS, ICCProfile)):
            if profile.lower().endswith(".cal"):
                profile = CGATS(profile)
            else:
                profile = ICCProfile(profile)
        is_profile = isinstance(profile, ICCProfile)
        if (
            is_profile
            and profile.version >= 4
            and not profile.convert_iccv4_tags_to_iccv2()
        ):
            raise Error(
                "\n".join(
                    [lang.getstr("profile.iccv4.unsupported"), profile.getDescription()]
                )
            )
        if not profile.filename or not os.path.isfile(profile.filename):
            if profile.filename:
                prefix = os.path.basename(profile.filename)
            elif is_profile:
                prefix = (
                    make_filename_safe(profile.getDescription(), concat=False)
                    + PROFILE_EXT
                )
            else:
                # CGATS (.cal)
                prefix = "cal"
            prefix += "-"
            if not cwd:
                cwd = self.create_tempdir()
                if isinstance(cwd, Exception):
                    raise cwd
            fd, profile.filename = tempfile.mkstemp("", prefix, dir=cwd)
            with os.fdopen(fd, "wb") as stream:
                profile.write(stream)
            self.temp = True
        elif not cwd:
            cwd = os.path.dirname(profile.filename)
        profile_basename = os.path.basename(profile.filename)
        profile_path = profile.filename
        if sys.platform == "win32":
            profile_path = win32api.GetShortPathName(profile_path)
        self.profile_path = safe_str(profile_path)
        if sys.platform == "win32" and not startupinfo:
            startupinfo = sp.STARTUPINFO()
            startupinfo.dwFlags |= sp.STARTF_USESHOWWINDOW
            startupinfo.wShowWindow = sp.SW_HIDE
        xicclu = safe_str(xicclu)
        cwd = safe_str(cwd)
        self.verbose = verbose
        args = [xicclu, f"-v{verbose}", f"-s{scale}"]
        self.show_actual_if_clipped = False
        if utilname == "xicclu":
            if (
                is_profile
                and show_actual_if_clipped
                and "A2B0" in profile.tags
                and ("B2A0" in profile.tags or direction == "if")
            ):
                args.append("-a")
                self.show_actual_if_clipped = True
            if use_cam_clipping:
                args.append("-b")
            if get_argyll_version("xicclu") >= [1, 6]:
                # Add encoding parameters
                # Note: Not adding -e -E can cause problems due to unitialized
                # in_tvenc and out_tvenc variables in xicclu.c for Argyll 1.6.x
                if not input_encoding:
                    input_encoding = "n"
                if not output_encoding:
                    output_encoding = "n"
                args += [
                    "-e" + safe_str(input_encoding),
                    "-E" + safe_str(output_encoding),
                ]
        args.append("-f" + direction)
        self.output_scale = 1.0
        if is_profile:
            if profile.profileClass not in (b"abst", b"link"):
                if intent:
                    args.append(f"-i{intent}")
                if order != "n":
                    args.append("-o" + order)
            if profile.profileClass != b"link":
                if direction in ("f", "ib") and (
                    pcs == "x" or (profile.connectionColorSpace == b"XYZ" and not pcs)
                ):
                    # In case of forward lookup with XYZ PCS, use 0..100 scaling
                    # internally so we get extra precision from xicclu for the
                    # decimal part. Scale back to 0..1 later.
                    pcs = "X"
                    self.output_scale = 100.0
                if pcs:
                    args.append("-p" + pcs)
        args.append(self.profile_path)
        if DEBUG or verbose > 1:
            self.sessionlogfile = LogFile(
                profile_basename + ".xicclu", os.path.dirname(profile.filename)
            )
            if is_profile:
                profile_act = ICCProfile(profile.filename)
                self.sessionlogfile.write(
                    f"Profile ID {hexlify(profile.ID)} "
                    f"(actual {hexlify(profile_act.calculate_id(False))})"
                )
            if cwd:
                self.log(lang.getstr("working_dir"))
                indent = "  "
                for name in cwd.split(os.path.sep):
                    self.log(
                        textwrap.fill(
                            name + os.path.sep,
                            80,
                            expand_tabs=False,
                            replace_whitespace=False,
                            initial_indent=indent,
                            subsequent_indent=indent,
                        )
                    )
                    indent += " "
                self.log("")
            self.log(lang.getstr("commandline"))
            printcmdline(xicclu, args[1:], fn=self.log, cwd=cwd)
            self.log("")
        self.startupinfo = startupinfo
        self.args = args
        self.cwd = cwd
        self.spawn()

    def spawn(self):
        """Spawn the xicclu subprocess."""
        self.closed = False
        self.output = []
        self.errors = []
        self.stdout = tempfile.SpooledTemporaryFile()  # noqa: SIM115
        self.stderr = tempfile.SpooledTemporaryFile()  # noqa: SIM115
        self.subprocess = sp.Popen(
            self.args,
            stdin=sp.PIPE,
            stdout=self.stdout,
            stderr=self.stderr,
            cwd=self.cwd,
            startupinfo=self.startupinfo,
        )

    def devi_devip(self, n):
        """Convert device value to device-independent value.

        Args:
            n (float): The device value to convert.

        Returns:
            float: The converted device-independent value.
        """
        if n > 236 / 256.0:
            n = colormath.convert_range(n, 236 / 256.0, 1, 236 / 256.0, 255 / 256.0)
        return VidRGB_to_cLUT65(eeColor_to_VidRGB(n))

    def __call__(self, idata):
        """Call the xicclu worker with input data.

        Args:
            idata (list or str): Input data to be processed by the xicclu worker.

        Returns:
            None: The method processes the input data and sends it to the
                xicclu subprocess.
        """
        if not isinstance(idata, str):
            verbose = self.verbose
            if self.convert_video_rgb_to_clut65:
                devi_devip = self.devi_devip
            else:

                def devi_devip(v):
                    return v

            scale = float(self.scale)
            idata = list(idata)  # Make a copy
            for i, v in enumerate(idata):
                if isinstance(v, (float, int)):
                    self([idata])
                    return
                if not isinstance(v, str):
                    if verbose:
                        for n in v:
                            if not isinstance(n, (float, int)):
                                raise TypeError(
                                    "xicclu: Expecting list of "
                                    "strings or n-tuples with "
                                    "floats"
                                )
                    idata[i] = " ".join(str(devi_devip(n / scale) * scale) for n in v)
        else:
            idata = idata.splitlines()
        numrows = len(idata)
        chunklen = 1000
        i = 0
        p = self.subprocess
        prevperc = -1
        while True:
            # Process in chunks to prevent broken pipe if input data is too
            # large
            if getattr(sys, "_sigbreak", False) and not self.subprocess_abort:
                self.subprocess_abort = True
                print("Got SIGBREAK, aborting subprocess...")
            if self.subprocess_abort or self.thread_abort:
                if p.poll() is None:
                    p.stdin.write(b"\n")
                    p.stdin.close()
                    p.wait()
                raise Info(lang.getstr("aborted"))
            if p.poll() is None:
                # We don't use communicate() because it will end the
                # process
                joined_data = "\n".join(idata[chunklen * i : chunklen * (i + 1)]) + "\n"
                p.stdin.write(joined_data.encode())
                p.stdin.flush()
            else:
                # Error
                break
            perc = round(chunklen * (i + 1) / float(numrows) * 100)
            if perc > prevperc and self.logfile:
                self.logfile.write(f"\r{int(min(perc, 100))}%")
                prevperc = perc
            if chunklen * (i + 1) > numrows - 1:
                break
            i += 1

    def __enter__(self) -> Self:
        """Enter the runtime context related to this object.

        Returns:
            Xicclu: The current instance of the Xicclu class.
        """
        return self

    def __exit__(self, exc_type, exec_value, traceback_):
        """Exit the runtime context related to this object.

        Args:
            exc_type: The exception type.
            exec_value: The exception value.
            traceback_: The traceback object.

        Returns:
            bool: True if the exception should be suppressed, False otherwise.
        """
        self.exit()
        return not traceback_

    def close(self, raise_exception=True):
        """Close the xicclu worker process.

        Args:
            raise_exception (bool): If True, raise an exception if the worker
                has errors.

        Raises:
            OSError: If the worker has errors and raise_exception is True.
        """
        if self.closed:
            return
        p = self.subprocess
        if p.poll() is None:
            with contextlib.suppress(OSError):
                p.stdin.write(b"\n")
            p.stdin.close()
        p.wait()
        self.stdout.seek(0)
        self.output = self.stdout.readlines()
        self.stdout.close()
        self.stderr.seek(0)
        self.errors = self.stderr.readlines()
        self.stderr.close()
        if self.sessionlogfile and self.errors:
            self.sessionlogfile.write(b"\n".join(self.errors))
        if self.logfile:
            self.logfile.write(b"\n")
        self.closed = True
        if p.returncode and raise_exception:
            # Error
            raise OSError(b"\n".join(self.errors))

    def exit(self, raise_exception=True):
        """Exit the xicclu worker process.

        Args:
            raise_exception (bool): If True, raise an exception if the worker
                has errors.
        """
        self.close(raise_exception)
        if self.temp and os.path.isfile(self.profile_path):
            os.remove(self.profile_path)
            if self.tempdir and not os.listdir(self.tempdir):
                try:
                    shutil.rmtree(self.tempdir, True)
                except Exception as exception:
                    print(
                        f"Warning - temporary directory '{self.tempdir}' "
                        f"could not be removed: {exception}"
                    )

    def get(self, raw=False, get_clip=False, output_format=None, reverse=False):
        """Get the output from xicclu.

        Args:
            raw (bool): If True, return raw output as a list of strings.
            get_clip (bool): If True, include clipping information in the
                output.
            output_format (tuple): A tuple specifying the output format.
            reverse (bool): If True, reverse the order of the output.

        Returns:
            list: The processed output from xicclu, either as raw strings or
                formatted data.
        """
        if raw:
            if self.sessionlogfile:
                self.sessionlogfile.write("\n".join(self.output))
                self.sessionlogfile.close()
            return self.output
        parsed = []
        j = 0
        verbose = self.verbose
        scale = float(self.scale)
        output_scale = float(self.output_scale)
        if self.convert_video_rgb_to_clut65:
            devop_devo = VidRGB_to_eeColor
        else:

            def devop_devo(v):
                return v

        fmt = ""
        maxv = ""
        if output_format:
            fmt = output_format[0]
            maxv = output_format[1]
        # Interesting: In CPython, testing for 'if not x' is slightly quicker
        # than testing for 'if x'. (EOY: Yeah I measured it is ~3% faster)
        # Also, struct.pack is faster if the second argument is passed as an integer.
        clip = None
        for line in self.output:
            if verbose:
                line = line.strip()
                if line.startswith(b"["):
                    if parsed and get_clip and self.show_actual_if_clipped:
                        parts = line.strip(b"[]").split(b",")
                        actual = [float(v) for v in parts[0].split()[1:4]]  # Actual CIE
                        actual.append(float(parts[1].split()[-1]))  # deltaE
                        parsed[-1].append(actual)
                    elif self.sessionlogfile:
                        self.sessionlogfile.write(line)
                    continue
                if b"->" not in line:
                    if self.sessionlogfile and line:
                        self.sessionlogfile.write(line)
                    continue
                if self.sessionlogfile:
                    self.sessionlogfile.write(f"#{j} {line}")
                parts = line.split(b"->")[-1].strip().split()
                clip = parts.pop() == b"(clip)"
                if clip:
                    parts.pop()
                j += 1
            else:
                parts = line.split()
            if reverse:
                parts = reversed(parts)
            if not output_format:
                out = [devop_devo(float(v) / output_scale) for v in parts]
                if get_clip and not self.show_actual_if_clipped:
                    out.append(clip)
            else:
                out = b"".join(
                    struct.pack(fmt, round(devop_devo(float(v) / scale) * maxv))
                    for v in parts
                )
            parsed.append(out)
        if self.sessionlogfile:
            self.sessionlogfile.close()
        return parsed

    @property
    def subprocess_abort(self):
        """Get the subprocess abort flag.

        Returns:
            bool: True if the subprocess abort flag is set, False otherwise.
        """
        if self.worker:
            return self.worker.subprocess_abort
        return False

    @subprocess_abort.setter
    def subprocess_abort(self, v):
        """Set the subprocess abort flag.

        Args:
            v (bool): If True, set the subprocess abort flag to signal
                that the subprocess should be aborted.
        """

    @property
    def thread_abort(self):
        """Get the thread abort event.

        Returns:
            ThreadAbort: The thread abort event, which can be used to signal
                that the thread should be aborted.
        """
        if self.worker:
            return self.worker.thread_abort
        return None

    @thread_abort.setter
    def thread_abort(self, v):
        """Set the thread abort event.

        Args:
            v (bool): If True, set the thread abort event to signal
                that the thread should be aborted.
        """


class XiccluMP(Xicclu):
    """Xicclu multiprocessing worker.

    Args:
        profile (ICCProfile): The ICC profile to use.
        intent (str): The rendering intent to use (default: "r").
        direction (str): The direction of the transformation (default: "f").
        order (str): The order of the transformation (default: "n").
        pcs (str): The PCS to use (default: None).
        scale (int): The scale factor for the transformation (default: 1).
        cwd (str): The current working directory (default: None).
        startupinfo: Startup information for subprocesses (default: None).
        use_icclu (bool): Whether to use icclu instead of xicclu (default: False).
        use_cam_clipping (bool): Whether to use CAM clipping (default: False).
        logfile: Log file for output messages (default: None).
        worker: Worker instance for multiprocessing (default: None).
        show_actual_if_clipped (bool): Whether to show actual values if clipped
            (default: False).
        input_encoding (str): Input encoding for xicclu (default: None).
        output_encoding (str): Output encoding for xicclu (default: None).
        output_format (tuple): Output format for xicclu results
            (default: None).
        reverse (bool): Whether to reverse the output order
            (default: False).
        output_stream: Stream to write output to, if any
            (default: None).
        convert_video_rgb_to_clut65 (bool): Whether to convert video RGB
            values to cLUT65 format.
        verbose (int): Verbosity level for logging.
    """

    def __init__(
        self,
        profile,
        intent="r",
        direction="f",
        order="n",
        pcs=None,
        scale=1,
        cwd=None,
        startupinfo=None,
        use_icclu=False,
        use_cam_clipping=False,
        logfile=None,
        worker=None,
        show_actual_if_clipped=False,
        input_encoding=None,
        output_encoding=None,
        output_format=None,
        reverse=False,
        output_stream=None,
        convert_video_rgb_to_clut65=False,
        verbose=1,
    ):
        WorkerBase.__init__(self)
        self.logfile = logfile
        self.worker = worker
        self.output_stream = output_stream
        self._in = []
        self._args = (
            profile.filename,
            intent,
            direction,
            order,
            pcs,
            scale,
            cwd,
            startupinfo,
            use_icclu,
            use_cam_clipping,
            None,
            show_actual_if_clipped,
            input_encoding,
            output_encoding,
            lang.getstr("aborted"),
            output_format,
            reverse,
            convert_video_rgb_to_clut65,
            verbose,
        )
        self._out = []

        num_cpus = min(mp.cpu_count(), MAX_CPUS)
        if isinstance(profile.tags.get("A2B0"), LUT16Type):
            size = profile.tags.A2B0.clut_grid_steps
            self.num_workers = min(max(num_cpus, 1), size)
            if num_cpus > 2:
                self.num_workers = int(self.num_workers * 0.75)
            self.num_batches = size // 9
        else:
            if num_cpus > 2:
                self.num_workers = 2
            else:
                self.num_workers = num_cpus
            self.num_batches = 1

    def __call__(self, idata):
        """Call the xicclu worker with input data.

        Args:
            idata (list): Input data to be processed by the xicclu worker.
        """
        self._in.append(idata)

    def close(self, raise_exception=True):
        """Close the xicclu worker process.

        Args:
            raise_exception (bool): If True, raise an exception if the worker
                has errors.
        """

    def exit(self, raise_exception=True):
        """Exit the xicclu worker process.

        Args:
            raise_exception (bool): If True, raise an exception if the worker
                has errors.
        """

    def spawn(self):
        """Spawn the xicclu worker process."""

    def get(self, raw=False, get_clip=False, output_format=None, reverse=False):
        """Get the output from the xicclu worker.

        Args:
            raw (bool): If True, return raw output as a list of strings.
            get_clip (bool): If True, include clipping information in the
                output.
            output_format (tuple): A tuple specifying the output format.
            reverse (bool): If True, reverse the order of the output.

        Returns:
            list: The processed output from the xicclu worker, either as raw
                strings or formatted data.
        """
        for slices in pool_slice(
            _xicclu_mp,
            self._in,
            self._args,
            {},
            self.num_workers,
            self.thread_abort,
            self.logfile,
            num_batches=self.num_batches,
        ):
            if self.output_stream:
                for row in slices:
                    self.output_stream.write(row)
            else:
                self._out.extend(slices)
        return self._out
