"""Graphical interface for viewing and analyzing LUTs and TRCs in DisplayCAL.

It provides functionality for loading, visualizing, and interacting with LUTs
and ICC profiles, supporting various color spaces and rendering intents. The
module integrates with wxPython for plotting and user interaction.
"""

import math
import os
import re
import sys

import numpy

from DisplayCAL import (
    colormath,
    config,
)
from DisplayCAL import localization as lang
from DisplayCAL import wx_enhanced_plot as plot
from DisplayCAL.argyll import make_argyll_compatible_path
from DisplayCAL.argyll_cgats import cal_to_fake_profile, vcgt_to_cal
from DisplayCAL.config import (
    get_argyll_display_number,
    get_data_path,
    get_display_profile,
    get_display_rects,
    get_icon,
    get_verified_path,
    getcfg,
    setcfg,
)
from DisplayCAL.icc_profile import (
    CurveType,
    ICCProfile,
    ICCProfileInvalidError,
    LUT16Type,
    ParametricCurveType,
    TextDescriptionType,
    VideoCardGammaTableType,
    VideoCardGammaType,
    WcsProfilesTagType,
)
from DisplayCAL.meta import NAME as APPNAME
from DisplayCAL.util_decimal import float2dec
from DisplayCAL.util_os import waccess
from DisplayCAL.worker import (
    Error,
    UnloggedError,
    Worker,
    show_result_dialog,
)
from DisplayCAL.wx_addons import get_platform_window_decoration_size, wx
from DisplayCAL.wx_fixes import GenBitmapButton as _GenBitmapButton
from DisplayCAL.wx_fixes import wx_Panel
from DisplayCAL.wx_measure_frame import MeasureFrame
from DisplayCAL.wx_windows import (
    BaseApp,
    BaseFrame,
    BitmapBackgroundPanelText,
    BitmapBackgroundPanelTextGamut,
    CustomCheckBox,
    FileDrop,
    InfoDialog,
    TooltipWindow,
)

BGCOLOUR = "#333333"
FGCOLOUR = "#999999"
GRIDCOLOUR = "#444444"
# Use conservative controls by default because some wx/SIP/native combinations
# can crash during widget construction across platforms.
SAFE_WX_UI = os.getenv("DISPLAYCAL_UNSAFE_WX_UI", "").strip() != "1"

if sys.platform == "darwin":
    FONTSIZE_LARGE = 11
    FONTSIZE_SMALL = 10
else:
    FONTSIZE_LARGE = 9
    FONTSIZE_SMALL = 8


class CoordinateType(list):
    """List of coordinates.

    [(Y, x)] where Y is in the range 0..100 and x in the range 0..255

    """

    def __init__(self, profile=None):
        super().__init__()
        self.profile = profile
        self._transfer_function = {}

    def get_gamma(
        self,
        use_vmin_vmax=False,
        average=True,
        least_squares=False,
        slice_=(0.01, 0.99),
    ):
        """Return average or least squares gamma or a list of gamma values.

        Args:
            use_vmin_vmax (bool): If True, use the minimum and maximum values
                from the coordinates for gamma calculation.
            average (bool): If True, return the average gamma value.
            least_squares (bool): If True, return the least squares gamma value.
            slice_ (tuple): Tuple with start and end percentage to use for
                calculating the gamma.

        Returns:
            float | list: If average or least_squares is True, returns a single
                gamma value. Otherwise, returns a list of gamma values for the
                specified slice.
        """
        if len(self):
            start = slice_[0] * 100
            end = slice_[1] * 100
            values = []
            for y, x in self:
                n = colormath.XYZ2Lab(0, y, 0)[0]
                if start <= n <= end:
                    values.append((x / 255.0 * 100, y))
        else:
            # Identity
            if average or least_squares:
                return 1.0
            return [1.0]
        vmin = 0
        vmax = 100.0
        if use_vmin_vmax and len(self) > 2:
            vmin = self[0][0]
            vmax = self[-1][0]
        return colormath.get_gamma(values, 100.0, vmin, vmax, average, least_squares)

    def get_transfer_function(self, best=True, slice_=(0.05, 0.95), outoffset=None):
        """Return transfer function name, exponent and match percentage.

        Args:
            best (bool): If True, return the best match transfer function.
                If False, return a list of all transfer functions.
            slice_ (tuple): Tuple with start and end percentage to use for
                calculating the transfer function.
            outoffset (float): Output offset to apply to the transfer function.

        Returns:
            tuple: A tuple containing the transfer function name, exponent,
                and match percentage if best is True. If best is False, returns
                a list of tuples with transfer function name, exponent, and
                match percentage for each transfer function.
        """
        transfer_function = self._transfer_function.get((best, slice_))
        if transfer_function:
            return transfer_function
        xp = []
        fp = []
        for y, x in self:
            xp.append(x)
            fp.append(y)
        interp = colormath.Interp(xp, fp, use_numpy=True)
        otrc = CurveType(profile=self.profile)
        for i in range(len(self)):
            otrc.append(interp(i / (len(self) - 1.0) * 255) / 100 * 65535)
        match = otrc.get_transfer_function(best, slice_, outoffset=outoffset)
        self._transfer_function[(best, slice_)] = match
        return match

    def set_trc(self, power=2.2, values=(), vmin=0, vmax=100):
        """Set the response to a certain function.

        Positive power, or -2.4 = sRGB, -3.0 = L*, -240 = SMPTE 240M,
        -601 = Rec. 601, -709 = Rec. 709 (Rec. 601 and 709 transfer functions are
        identical)

        Args:
            power (float): The power to raise the input value to.
            values (list): List of tuples with (Y, x) values to set.
            vmin (float): Minimum value for Y.
            vmax (float): Maximum value for Y.
        """
        self[:] = [[y, x] for y, x in values]
        for i in range(len(self)):
            self[i][0] = vmin + colormath.special_pow(self[i][1] / 255.0, power) * (
                vmax - vmin
            )


class PolyBox(plot.PolyLine):
    """Box for drawing rectangles."""

    def __init__(self, x, y, w, h, **attr):
        plot.PolyLine.__init__(
            self, [(x, y), (x + w, y), (x + w, y + h), (x, y + h), (x, y)], **attr
        )


class LUTCanvas(plot.PlotCanvas):
    """Canvas for displaying LUTs and TRCs."""

    def __init__(self, *args, **kwargs):
        self.colors = {
            "RGB_R": "red",
            "RGB_G": "#00FF00",
            "RGB_B": "#0080FF",
            "CMYK_C": "cyan",
            "CMYK_M": "magenta",
            "CMYK_Y": "yellow",
            "CMYK_K": "black",
            "nCLR_0": "cyan",
            "nCLR_1": "magenta",
            "nCLR_2": "yellow",
            "nCLR_3": "black",
            "nCLR_4": "red",
            "nCLR_5": "#00FF00",
            "nCLR_6": "blue",
            "nCLR_7": "#FF8000",
            "nCLR_8": "#80FF00",
            "nCLR_9": "#00FF80",
            "XYZ_X": "red",
            "XYZ_Y": "#00FF00",
            "XYZ_Z": "#0080FF",
            "Lab_L*": "#CCCCCC",
            "Lab_a*": "#FF0080",
            "Lab_a*-": "#00CC80",
            "Lab_b*": "#FFEE00",
            "Lab_b*-": "#0080FF",
        }
        plot.PlotCanvas.__init__(self, *args, **kwargs)
        self.Unbind(wx.EVT_SCROLL_THUMBTRACK)
        self.Unbind(wx.EVT_SCROLL_PAGEUP)
        self.Unbind(wx.EVT_SCROLL_PAGEDOWN)
        self.Unbind(wx.EVT_SCROLL_LINEUP)
        self.Unbind(wx.EVT_SCROLL_LINEDOWN)
        self.HandCursor = wx.StockCursor(wx.CURSOR_CROSS)
        self.GrabHandCursor = wx.StockCursor(wx.CURSOR_SIZING)
        self.SetBackgroundColour(BGCOLOUR)
        self.SetEnableAntiAliasing(True)
        self.SetEnableHiRes(True)
        self.SetEnableCenterLines(True)
        self.SetEnableDiagonals("Bottomleft-Topright")
        self.SetEnableDrag(True)
        self.SetEnableGrid(False)
        self.SetEnablePointLabel(True)
        self.SetEnableTitle(False)
        self.SetForegroundColour(FGCOLOUR)
        self.SetFontSizeAxis(FONTSIZE_SMALL)
        self.SetFontSizeLegend(FONTSIZE_SMALL)
        self.SetFontSizeTitle(FONTSIZE_LARGE)
        self.SetGridColour(GRIDCOLOUR)
        self.setLogScale((False, False))
        self.SetPointLabelFunc(self.DrawPointLabel)
        self.canvas.BackgroundColour = BGCOLOUR
        if sys.platform == "win32" and sys.getwindowsversion() >= (6,):
            # Enable/disable double buffering on-the-fly to make pointlabel work
            self.canvas.Bind(wx.EVT_ENTER_WINDOW, self._disabledoublebuffer)
            self.canvas.Bind(wx.EVT_LEAVE_WINDOW, self._enabledoublebuffer)
        self.worker = Worker(self.TopLevelParent)
        self.errors = []
        self.resetzoom()

    def DrawLUT(
        self,
        vcgt=None,
        title=None,
        xLabel=None,
        yLabel=None,
        channels=None,
        colorspace=b"RGB",
        connection_colorspace=b"RGB",
    ):
        """Draw a LUT or TRC on the canvas.

        Args:
            vcgt (VideoCardGammaType or VideoCardGammaTableType): The video
                card gamma table or type to draw.
            title (str): Title of the plot.
            xLabel (str): Label for the X-axis.
            yLabel (str): Label for the Y-axis.
            channels (dict): Dictionary of channels to plot, e.g. {0: "R", 1:
                "G", 2: "B"}.
            colorspace (bytes): Colorspace of the LUT, e.g. b"RGB", b"CMYK".
            connection_colorspace (bytes): Colorspace used for connection,
                e.g. b"RGB".

        Raises:
            ValueError: If the colorspace is not supported or if the vcgt is
                invalid.
            UnloggedError: If the vcgt is not a valid VideoCardGammaType or
                VideoCardGammaTableType.
        """
        if not title:
            title = ""
        if not xLabel:
            xLabel = ""
        if not yLabel:
            yLabel = ""

        detect_increments = False
        Plot = plot.PolyLine
        Plot._attributes["width"] = 1

        maxv = 4095
        linear_points = []

        if colorspace in (b"YCbr", b"RGB", b"GRAY", b"HSV", b"HLS"):
            axis_y = 255.0
            axis_x = 100.0 if connection_colorspace in (b"Lab", b"XYZ") else 255.0
        else:
            axis_y = 100.0
            axis_x = 100.0
        if getattr(self, "axis_x", None) != (0, axis_x) or getattr(
            self, "axis_y", None
        ) != (0, axis_y):
            self.resetzoom()
            wx.CallAfter(self.center)
        self.axis_x, self.axis_y = (0, axis_x), (0, axis_y)
        if not self.last_draw:
            self.center_x = axis_x / 2.0
            self.center_y = axis_y / 2.0
        self.proportional = False
        self.spec_x = self.spec_y = 5

        lines = [PolyBox(0, 0, axis_x, axis_y, colour=GRIDCOLOUR, width=1)]

        # Use a point grid, so we can get the whole set of output values for
        # each channel for any single given input value.
        # The point grid keys are quantized to 12 bits to avoid floating point
        # inaccuracy
        self.point_grid = {}
        points = {}
        if not channels:
            channels = {}
        for channel in channels:
            channel_name = channels[channel]
            if channel_name:
                self.point_grid[channel] = {}
                points[channel] = []

        if not vcgt:
            irange = list(range(256))
        elif "data" in vcgt:  # table
            data = list(vcgt["data"])
            while len(data) < len(channels):
                data.append(data[0])
            if not isinstance(vcgt, VideoCardGammaTableType) and not isinstance(
                data[0], CurveType
            ):
                # Coordinate list
                irange = list(range(len(data[0])))
                set_linear_points = True
                for channel in range(len(data)):
                    if channel in points:
                        for n, y in data[channel]:
                            if not detect_increments and set_linear_points:
                                linear_points.append([n, n])
                            if connection_colorspace == b"Lab":
                                n = colormath.XYZ2Lab(0, n / axis_x * 100, 0)[0] * (
                                    axis_x / 100.0
                                )
                            points[channel].append([n, y])
                            idx = round(n / 255.0 * 4095)
                            self.point_grid[channel][idx] = y
                        set_linear_points = False
            else:
                irange = list(range(vcgt["entryCount"]))
                maxv = math.pow(256, vcgt["entrySize"]) - 1
                for i in irange:
                    j = i * (axis_y / (vcgt["entryCount"] - 1))
                    if not detect_increments:
                        linear_points.append([j, j])
                    for channel in points:
                        values = points[channel]
                        n = float(data[channel][i]) / maxv * axis_x
                        if (
                            not detect_increments
                            or not values
                            or i == vcgt["entryCount"] - 1
                            or n != i
                        ):
                            if (
                                detect_increments
                                and n != i
                                and len(values) == 1
                                and i > 1
                                and values[-1][0] == values[-1][1]
                            ):
                                values.append([i - 1, i - 1])
                            if connection_colorspace == b"Lab":
                                n = colormath.XYZ2Lab(0, n / axis_x * 100, 0)[0] * (
                                    axis_x / 100.0
                                )
                            if connection_colorspace in (b"Lab", b"XYZ"):
                                values.append([n, j])
                                idx = round(n / 255.0 * 4095)
                                self.point_grid[channel][idx] = j
                            else:
                                values.append([j, n])
                                idx = round(j / 255.0 * 4095)
                                self.point_grid[channel][idx] = n
        else:  # formula
            irange = list(range(256))
            step = 100.0 / axis_y
            for i in irange:
                # float2dec(v) fixes miniscule deviations in the calculated gamma
                # n = float2dec(n, 8)
                if not detect_increments:
                    linear_points.append([i, i])
                for channel, color in enumerate(("red", "green", "blue")):
                    if channel not in points:
                        continue
                    vmin = float2dec(vcgt[f"{color}Min"] * axis_x)
                    v = float2dec(math.pow(step * i / 100.0, vcgt[f"{color}Gamma"]))
                    vmax = float2dec(vcgt[f"{color}Max"] * axis_x)
                    n = vmin + v * (vmax - vmin)
                    if connection_colorspace == b"Lab":
                        n = colormath.XYZ2Lab(0, float(n) / axis_x * 100, 0)[0] * (
                            axis_x / 100.0
                        )
                    if connection_colorspace in (b"Lab", b"XYZ"):
                        points[channel].append([n, i])
                        idx = round(float(n) / 255.0 * 4095)
                        self.point_grid[channel][idx] = i
                    else:
                        points[channel].append([i, n])
                        idx = round(i / 255.0 * 4095)
                        self.point_grid[channel][idx] = float(n)

        # for n in sorted(self.point_grid[0]):
        #     print(
        #         n,
        #         self.point_grid[0].get(n),
        #         self.point_grid[1].get(n),
        #         self.point_grid[2].get(n)
        #     )

        self.entryCount = irange[-1] + 1

        linear = [[0, 0], [irange[-1], irange[-1]]]

        if not vcgt:
            for values in points.values():
                if detect_increments:
                    values[:] = linear
                else:
                    values[:] = linear_points

        # Note: We need to make sure each point is a float because it
        # might be a decimal.Decimal, which can't be divided by floats!
        self.unique = {}
        for channel in points:
            values = points[channel]
            self.unique[channel] = len(
                {round(float(y) / axis_y * irange[-1]) for x, y in values}
            )

        legend = []
        color = "white"

        if len(points) > 1:
            values0 = next(iter(points.values()))  # first value
            # identical = all(values == values0
            # for values in points.itervalues())
            identical = all(
                all(
                    x == values0[i][0] and abs(y - values0[i][1]) < 0.005
                    for i, (x, y) in enumerate(values)
                )
                for values in points.values()
            )
        else:
            identical = False

        if identical:
            channels_label = "".join(list(channels.values()))
            color = self.get_color(channels_label, color)
        legend.extend(
            channel_name for channel_name in list(channels.values()) if channel_name
        )
        linear_points = [(i, round(v / axis_y * maxv)) for i, v in linear_points]
        seen_values = []
        seen_labels = []
        for channel in points:
            values = points[channel]
            channel_label = channels[channel]
            if not identical:
                color = self.colors.get(
                    f"{colorspace.decode('utf-8')}_{channel_label}", "white"
                )
            # Note: We need to make sure each point is a float because it
            # might be a decimal.Decimal, which can't be divided by floats!
            points_quantized = [(i, round(float(v) / axis_y * maxv)) for i, v in values]
            line2 = None
            if identical:
                label = "=".join(legend)
                suffix = (
                    (", {}".format(lang.getstr("linear").capitalize()))
                    if points_quantized
                    == (linear if detect_increments else linear_points)
                    else ""
                )
            else:
                label = channel_label
                suffix = ""
                if channel_label in ("a*", "b*") and len(values) > 1:
                    half = len(values) / 2
                    values2 = values[:half]
                    values = values[half:]
                    center_x = (values[0][0] + values2[-1][0]) / 2.0
                    center_y = (values[0][1] + values2[-1][1]) / 2.0
                    center = [center_x, center_y]
                    if values[0] != center:
                        values.insert(0, center)
                    if values2[-1] != center:
                        values2.append(center)
                    idx = round(center_x / 255.0 * 4095)
                    self.point_grid[channel][idx] = center_y
                    color2 = self.colors[f"Lab_{channel_label}-"]
                    line2 = Plot(values2, legend=label + suffix, colour=color2)
                elif seen_values:
                    # Check if same line (+- 0.005 tolerance) has been seen
                    for seen in seen_values:
                        match = True
                        for i, (x, y) in enumerate(seen):
                            if x != values[i][0] or abs(y - values[i][1]) > 0.005:
                                match = False
                                break
                        if match:
                            break
                    else:
                        match = False
                    if match:
                        idx = len(seen_values) - 1
                        seen_label = seen_labels[idx]
                        lines[idx + 1].attributes["colour"] = self.get_color(
                            seen_label + channel_label
                        )
                        continue
            line = Plot(values, legend=label + suffix, colour=color)
            if (colorspace == "CMYK" and label == "K") or (
                colorspace == "Lab" and label in ("a*", "b*")
            ):
                # CMYK -> KCMY, Lab -> baL for better visibilty
                lines.insert(1, line)
                seen_values.insert(0, values)
                seen_labels.insert(0, label)
                if line2:
                    lines.insert(1, line2)
            else:
                lines.append(line)
                seen_values.append(values)
                seen_labels.append(label)
                if line2:
                    lines.append(line2)
            if identical:
                break

        self._DrawCanvas(
            plot.PlotGraphics(
                lines,
                title,
                " ".join([xLabel, lang.getstr("in")]),
                " ".join([yLabel, lang.getstr("out")]),
            )
        )

    def get_color(self, channels_label, default="white"):
        """Get color based on channels label.

        Args:
            channels_label (str): The label of the channels.
            default (str): Default color if no specific color is found.

        Returns:
            str: The color.
        """
        if channels_label in ("RGB", "CMYK", "XYZ"):
            return "white"
        if channels_label in ("RG", "XY"):
            return "yellow"
        if channels_label in ("RB", "XZ"):
            return "magenta"
        if channels_label in ("GB", "YZ"):
            return "cyan"
        if channels_label == "CM":
            return "#0080FF"
        if channels_label == "CY":
            return "#00FF00"
        if channels_label == "MY":
            return "red"
        return default

    def DrawPointLabel(self, dc, mDataDict):
        """Draw point labels.

        Args:
            dc (wx.DC): DC that will be passed.
            mDataDict (dict): Dictionary of data that you want to use for the
                pointLabel.
        """
        if not self.last_draw:
            return
        graphics, xAxis, yAxis = self.last_draw
        # sizes axis to axis type, create lower left and upper right corners of plot
        if xAxis is None or yAxis is None:
            # One or both axis not specified in Draw
            p1, p2 = graphics.boundingBox()  # min, max points of graphics
            if xAxis is None:
                xAxis = self._axisInterval(self._xSpec, p1[0], p2[0])  # in user units
            if yAxis is None:
                yAxis = self._axisInterval(self._ySpec, p1[1], p2[1])
            # Adjust bounding box for axis spec
            p1[0], p1[1] = (
                xAxis[0],
                yAxis[0],
            )  # lower left corner user scale (xmin,ymin)
            p2[0], p2[1] = (
                xAxis[1],
                yAxis[1],
            )  # upper right corner user scale (xmax,ymax)
        else:
            # Both axis specified in Draw
            p1 = numpy.array(
                [xAxis[0], yAxis[0]]
            )  # lower left corner user scale (xmin,ymin)
            p2 = numpy.array(
                [xAxis[1], yAxis[1]]
            )  # upper right corner user scale (xmax,ymax)
        ptx, pty, rectWidth, rectHeight = self._point2ClientCoord(p1, p2)
        # allow graph to overlap axis lines by adding units to width and height
        dc.SetClippingRegion(
            int(ptx), int(pty), int(rectWidth + 2), int(rectHeight + 2)
        )

        dc.SetPen(wx.Pen(wx.WHITE, 1, wx.PENSTYLE_DOT))
        dc.SetBrush(wx.Brush(wx.WHITE, wx.BRUSHSTYLE_SOLID))

        sx, sy = mDataDict["scaledXY"]  # Scaled x, y of closest point
        dc.DrawLine(0, int(sy), int(ptx + rectWidth + 2), int(sy))
        dc.DrawLine(int(sx), 0, int(sx), int(pty + rectHeight + 2))

    def GetClosestPoints(self, pntXY, pointScaled=True):
        """Get closest points in all curves to a given point.

        Return a list with [curveNumber, legend, index of closest point,
        pointXY, scaledXY, distance] list for each curve.
        Returns [] if no curves are being plotted.

        x, y in user coords
        if pointScaled == True based on screen coords
        if pointScaled == False based on user coords

        Args;
            pntXY (tuple): The point coordinates (x, y) to find the closest
                points to.
            pointScaled (bool): Whether the point coordinates are scaled or not.

        Returns:
            list: A list of lists, each containing information about the
                closest point in each curve.
        """
        if self.last_draw is None:
            # no graph available
            return []
        graphics, xAxis, yAxis = self.last_draw
        l = []
        for curveNum, obj in enumerate(graphics):
            # check there are points in the curve
            if len(obj.points) == 0 or isinstance(obj, PolyBox):
                continue  # go to next obj
            # [curveNumber, legend, index of closest point, pointXY, scaledXY, distance]
            cn = [curveNum, obj.getLegend(), *obj.getClosestPoint(pntXY, pointScaled)]
            l.append(cn)
        return l

    def _disabledoublebuffer(self, event):
        """Disable double buffering for the canvas and its parent windows.

        Args:
            event (wx.Event): The event that triggered this method.
        """
        window = self
        while window:
            if not isinstance(window, wx.TopLevelWindow):
                window.SetDoubleBuffered(False)
            window = window.Parent
        event.Skip()

    def _enabledoublebuffer(self, event):
        """Enable double buffering for the canvas and its parent windows.

        Args:
            event (wx.Event): The event that triggered this method.
        """
        window = self
        while window:
            if not isinstance(window, wx.TopLevelWindow):
                window.SetDoubleBuffered(True)
            window = window.Parent
        event.Skip()

    def OnMouseDoubleClick(self, event):
        """Handle mouse double click event.

        Args:
            event (wx.Event): The mouse double click event.
        """
        boundingbox = self.last_draw[0].boundingBox() if self.last_draw else None
        self.resetzoom(boundingbox=boundingbox)
        if self.last_draw:
            self.center()

    def OnMouseLeftDown(self, event):
        """Handle mouse left button down event.

        Args:
            event (wx.Event): The mouse left button down event.
        """
        self.erase_pointlabel()
        self._zoomCorner1[0], self._zoomCorner1[1] = self._getXY(event)
        self._screenCoordinates = numpy.array(event.GetPosition())
        if self._dragEnabled:
            self.SetCursor(self.GrabHandCursor)
            self.canvas.CaptureMouse()

    def OnMouseLeftUp(self, event):
        """Handle mouse left button release event.

        Args:
            event (wx.Event): The mouse left button release event.
        """
        if self._dragEnabled:
            self.SetCursor(self.HandCursor)
            if self.canvas.HasCapture():
                self.canvas.ReleaseMouse()
                self._set_center()
        if hasattr(self.TopLevelParent, "OnMotion"):
            self.TopLevelParent.OnMotion(event)

    def _DrawCanvas(self, graphics):
        """Draw proportionally correct, center and zoom."""
        w = float(self.GetSize()[0] or 1)
        h = float(self.GetSize()[1] or 1)
        if w > 45:
            w -= 45
        if h > 20:
            h -= 20
        ratio = [w / h, h / w]
        axis_x, axis_y = self.axis_x, self.axis_y
        spec_x = self.spec_x
        spec_y = self.spec_y
        if self._zoomfactor < 1:
            while spec_x * self._zoomfactor < self.spec_x / 2.0:
                spec_x *= 2
            while spec_y * self._zoomfactor < self.spec_y / 2.0:
                spec_y *= 2
        else:
            while spec_x * self._zoomfactor > self.spec_x * 2:
                spec_x /= 2
            while spec_y * self._zoomfactor > self.spec_y * 2:
                spec_y /= 2
        if self.proportional:
            if ratio[0] > ratio[1]:
                self.SetXSpec(spec_x * self._zoomfactor * ratio[0])
            else:
                self.SetXSpec(spec_x * self._zoomfactor)
            if ratio[0] > 1:
                axis_x = tuple([v * ratio[0] for v in axis_x])
            if ratio[1] > ratio[0]:
                self.SetYSpec(spec_y * self._zoomfactor * ratio[1])
            else:
                self.SetYSpec(spec_y * self._zoomfactor)
            if ratio[1] > 1:
                axis_y = tuple([v * ratio[1] for v in axis_y])
        else:
            self.SetXSpec(spec_x * self._zoomfactor)
            self.SetYSpec(spec_y * self._zoomfactor)
        x, y = self.center_x, self.center_y
        w = (axis_x[1] - axis_x[0]) * self._zoomfactor
        h = (axis_y[1] - axis_y[0]) * self._zoomfactor
        axis_x = (x - w / 2, x + w / 2)
        axis_y = (y - h / 2, y + h / 2)
        self.Draw(graphics, axis_x, axis_y)

    def _set_center(self):
        """Set center position from current X and Y axis."""
        if not self.last_draw:
            return
        axis_x = self.GetXCurrentRange()
        axis_y = self.GetYCurrentRange()
        if axis_x[0] < 0:
            if axis_x[1] < 0:
                x = axis_x[0] + (abs(axis_x[0]) - abs(axis_x[1])) / 2.0
            else:
                x = axis_x[0] + (abs(axis_x[1]) + abs(axis_x[0])) / 2.0
        else:
            x = axis_x[0] + (abs(axis_x[1]) - abs(axis_x[0])) / 2.0
        if axis_y[0] < 0:
            if axis_y[1] < 0:
                y = axis_y[0] + (abs(axis_y[0]) - abs(axis_y[1])) / 2.0
            else:
                y = axis_y[0] + (abs(axis_y[1]) + abs(axis_y[0])) / 2.0
        else:
            y = axis_y[0] + (abs(axis_y[1]) - abs(axis_y[0])) / 2.0
        self.center_x, self.center_y = x, y

    def center(self, boundingbox=None):
        """Center the current graphic.

        Args:
            boundingbox (tuple): Optional bounding box to center the graphic to.
                If not provided, the current axis range will be used.
        """
        if boundingbox:
            # Min, max points of graphics
            p1, p2 = boundingbox
            # In user units
            min_x, max_x = self._axisInterval(self._xSpec, p1[0], p2[0])
            min_y, max_y = self._axisInterval(self._ySpec, p1[1], p2[1])
        else:
            min_x, max_x = self.GetXMaxRange()
            min_y, max_y = self.GetYMaxRange()
        self.center_x = 0 + sum((min_x, max_x)) / 2.0
        self.center_y = 0 + sum((min_y, max_y)) / 2.0
        if not boundingbox:
            self.erase_pointlabel()
            self._DrawCanvas(self.last_draw[0])

    def erase_pointlabel(self):
        """Erase the point label if it exists."""
        if self.GetEnablePointLabel() and self.last_PointLabel:
            # Erase point label
            self._drawPointLabel(self.last_PointLabel)
            self.last_PointLabel = None

    def resetzoom(self, boundingbox=None):
        """Reset zoom to fit bounding box.

        Args:
            boundingbox (tuple): Optional bounding box to fit the zoom to.
                If not provided, the current axis range will be used.
        """
        self.center_x = 0
        self.center_y = 0
        self._zoomfactor = 1.0
        if not boundingbox:
            return
        # Min, max points of graphics
        p1, p2 = boundingbox
        # In user units
        min_x, max_x = self._axisInterval(self._xSpec, p1[0], p2[0])
        min_y, max_y = self._axisInterval(self._ySpec, p1[1], p2[1])
        max_abs_x = abs(min_x) + max_x
        max_abs_y = abs(min_y) + max_y
        max_abs_axis_x = abs(self.axis_x[0]) + self.axis_x[1]
        max_abs_axis_y = abs(self.axis_y[0]) + self.axis_y[1]
        w = float(self.GetSize()[0] or 1)
        h = float(self.GetSize()[1] or 1)
        if w > 45:
            w -= 45
        if h > 20:
            h -= 20
        ratio = [w / h, h / w]
        if ratio[0] > 1:
            max_abs_axis_x *= ratio[0]
        if ratio[1] > 1:
            max_abs_axis_y *= ratio[1]
        if max_abs_x / max_abs_y > max_abs_axis_x / max_abs_axis_y:
            self._zoomfactor = max_abs_x / max_abs_axis_x
        else:
            self._zoomfactor = max_abs_y / max_abs_axis_y

    def zoom(self, direction=1.0):
        """Zoom in or out.

        Args:
            direction (float): 1.0 for zoom in, -1.0 for zoom out.
        """
        _zoomfactor = 0.025 * direction
        if 0 < self._zoomfactor + _zoomfactor <= 5:
            self._zoomfactor += _zoomfactor
            self._set_center()
            self.erase_pointlabel()
            self._DrawCanvas(self.last_draw[0])


class LUTFrame(BaseFrame):
    """LUTFrame."""

    def _create_toolbar_button(
        self,
        parent,
        bitmap_provider,
        label,
        tooltip,
        handler,
        sizer,
        add_kwargs=None,
    ):
        """Create a toolbar button with a safe fallback."""
        add_kwargs = add_kwargs or {}
        if SAFE_WX_UI:
            button = wx.Button(parent, -1, label=label, style=wx.BU_EXACTFIT)
        else:
            bitmap = bitmap_provider()
            button = _GenBitmapButton(parent, -1, bitmap, style=wx.NO_BORDER)
            button.SetBackgroundColour(BGCOLOUR)
        sizer.Add(button, **add_kwargs)
        button.Bind(wx.EVT_BUTTON, handler)
        button.SetToolTip(tooltip)
        return button

    def __init__(self, *args, **kwargs):
        if len(args) < 3 and "title" not in kwargs:
            kwargs["title"] = lang.getstr("calibration.lut_viewer.title")
        if "name" not in kwargs:
            kwargs["name"] = "lut_viewer"

        BaseFrame.__init__(self, *args, **kwargs)

        if not SAFE_WX_UI:
            self.SetIcons(
                config.get_icon_bundle([256, 48, 32, 16], f"{APPNAME}-curve-viewer")
            )

        self.profile = None
        self.xLabel = lang.getstr("in")
        self.yLabel = lang.getstr("out")

        self.sizer = wx.BoxSizer(wx.VERTICAL)
        self.SetSizer(self.sizer)

        panel = wx_Panel(self)
        panel.SetBackgroundColour(BGCOLOUR)
        self.sizer.Add(panel, flag=wx.EXPAND)
        panel.SetSizer(wx.FlexGridSizer(0, 5, 0, 8))
        panel.Sizer.AddGrowableCol(0)
        panel.Sizer.AddGrowableCol(4)

        panel.Sizer.Add((1, 1))
        panel.Sizer.Add((1, 12))
        panel.Sizer.Add((1, 12))
        panel.Sizer.Add((1, 12))
        panel.Sizer.Add((1, 1))

        panel.Sizer.Add((1, 1))

        self.plot_mode_select = wx.Choice(panel, -1, size=(-1, -1), choices=[])
        panel.Sizer.Add(self.plot_mode_select, flag=wx.ALIGN_CENTER_VERTICAL)
        self.Bind(
            wx.EVT_CHOICE,
            self.plot_mode_select_handler,
            id=self.plot_mode_select.GetId(),
        )

        self.tooltip_btn = self._create_toolbar_button(
            panel,
            lambda: get_icon(16, "question-inverted"),
            "?",
            lang.getstr("gamut_plot.tooltip"),
            self.tooltip_handler,
            panel.Sizer,
            {"flag": wx.ALIGN_CENTER_VERTICAL},
        )

        self.save_plot_btn = self._create_toolbar_button(
            panel,
            lambda: get_icon(16, "image-x-generic-inverted"),
            "Save",
            f"{lang.getstr('save_as')} (*.bmp, *.xbm, *.xpm, *.jpg, *.png)",
            self.SaveFile,
            panel.Sizer,
            {"flag": wx.ALIGN_CENTER_VERTICAL},
        )
        self.save_plot_btn.Disable()

        panel.Sizer.Add((1, 1))

        panel.Sizer.Add((1, 1))
        panel.Sizer.Add((1, 4))
        panel.Sizer.Add((1, 4))
        panel.Sizer.Add((1, 4))
        panel.Sizer.Add((1, 1))

        self.client = LUTCanvas(self)
        self.sizer.Add(self.client, 1, wx.EXPAND)

        self.box_panel = wx_Panel(self)
        self.box_panel.SetBackgroundColour(BGCOLOUR)
        self.sizer.Add(self.box_panel, flag=wx.EXPAND)

        self.status = BitmapBackgroundPanelText(self, name="statuspanel")
        self.gamut_status = BitmapBackgroundPanelTextGamut(self, name="statuspanel")
        self.status.SetMaxFontSize(11)
        self.status.label_y = 8
        self.status.textshadow = False
        self.status.SetBackgroundColour(BGCOLOUR)
        self.status.SetForegroundColour(FGCOLOUR)
        h = self.status.GetTextExtent("Ig")[1]
        self.status.SetMinSize((0, h * 2 + 18))
        self.sizer.Add(self.status, flag=wx.EXPAND)

        self.box_sizer = wx.FlexGridSizer(0, 3, 4, 4)
        self.box_sizer.AddGrowableCol(0)
        self.box_sizer.AddGrowableCol(2)
        self.box_panel.SetSizer(self.box_sizer)

        self.box_sizer.Add((0, 0))

        hsizer = wx.BoxSizer(wx.HORIZONTAL)

        self.box_sizer.Add(
            hsizer,
            # flag=wx.ALIGN_CENTER | wx.BOTTOM | wx.TOP, border=4)
            flag=wx.BOTTOM | wx.TOP,
            border=4,
        )

        self.rendering_intent_select = wx.Choice(
            self.box_panel,
            -1,
            choices=[
                lang.getstr("gamap.intents.a"),
                lang.getstr("gamap.intents.r"),
                lang.getstr("gamap.intents.p"),
                lang.getstr("gamap.intents.s"),
            ],
        )
        hsizer.Add((10, self.rendering_intent_select.Size[1]))
        hsizer.Add(self.rendering_intent_select, flag=wx.ALIGN_CENTER_VERTICAL)
        self.rendering_intent_select.Bind(
            wx.EVT_CHOICE, self.rendering_intent_select_handler
        )
        self.rendering_intent_select.SetSelection(1)

        self.direction_select = wx.Choice(
            self.box_panel,
            -1,
            choices=[
                lang.getstr("direction.backward"),
                lang.getstr("direction.forward.inverted"),
            ],
        )
        hsizer.Add(
            self.direction_select, flag=wx.ALIGN_CENTER_VERTICAL | wx.LEFT, border=10
        )
        self.direction_select.Bind(wx.EVT_CHOICE, self.direction_select_handler)
        self.direction_select.SetSelection(0)

        self.show_actual_lut_cb = CustomCheckBox(
            self.box_panel, -1, lang.getstr("calibration.show_actual_lut")
        )
        self.show_actual_lut_cb.SetForegroundColour(FGCOLOUR)
        self.show_actual_lut_cb.SetMaxFontSize(11)
        hsizer.Add(
            self.show_actual_lut_cb,
            # flag=wx.ALIGN_CENTER |
            flag=wx.ALIGN_CENTER_VERTICAL,
        )
        self.show_actual_lut_cb.Bind(wx.EVT_CHECKBOX, self.show_actual_lut_handler)

        self.box_sizer.Add((0, 0))

        self.box_sizer.Add((0, 0))

        self.cbox_sizer = wx.BoxSizer(wx.HORIZONTAL)
        self.box_sizer.Add(
            self.cbox_sizer,
            # flag=wx.ALIGN_CENTER | wx.ALIGN_CENTER_VERTICAL | wx.TOP,
            flag=wx.ALIGN_CENTER_VERTICAL | wx.TOP,
            border=4,
        )

        self.box_sizer.Add((0, 0))

        self.cbox_sizer.Add((10, 0))

        self.reload_vcgt_btn = self._create_toolbar_button(
            self.box_panel,
            lambda: get_icon(16, "stock_refresh-inverted"),
            "Reload",
            lang.getstr("calibration.load_from_display_profile"),
            self.reload_vcgt_handler,
            self.cbox_sizer,
            {"flag": wx.ALIGN_CENTER_VERTICAL, "border": 8},
        )
        self.reload_vcgt_btn.Disable()

        self.apply_bpc_btn = self._create_toolbar_button(
            self.box_panel,
            lambda: get_icon(16, "color-inverted"),
            "BPC",
            lang.getstr("black_point_compensation"),
            self.apply_bpc_handler,
            self.cbox_sizer,
            {"flag": wx.ALIGN_CENTER_VERTICAL, "border": 8},
        )
        self.apply_bpc_btn.Disable()

        self.install_vcgt_btn = self._create_toolbar_button(
            self.box_panel,
            lambda: get_icon(16, "install-inverted"),
            "Apply",
            lang.getstr("apply_cal"),
            self.install_vcgt_handler,
            self.cbox_sizer,
            {"flag": wx.ALIGN_CENTER_VERTICAL, "border": 8},
        )
        self.install_vcgt_btn.Disable()

        self.save_vcgt_btn = self._create_toolbar_button(
            self.box_panel,
            lambda: get_icon(16, "document-save-as-inverted"),
            "Save CAL",
            f"{lang.getstr('save_as')} (*.cal)",
            self.SaveFile,
            self.cbox_sizer,
            {"flag": wx.ALIGN_CENTER_VERTICAL, "border": 20},
        )
        self.save_vcgt_btn.Disable()

        self.show_as_L = CustomCheckBox(self.box_panel, -1, "L* \u2192")
        self.show_as_L.SetForegroundColour(FGCOLOUR)
        self.show_as_L.SetMaxFontSize(11)
        self.show_as_L.SetValue(True)
        self.cbox_sizer.Add(
            self.show_as_L,
            # flag=wx.ALIGN_CENTER_VERTICAL | wx.RIGHT,
            flag=wx.ALIGN_CENTER_VERTICAL,
            border=4,
        )
        self.show_as_L.Bind(wx.EVT_CHECKBOX, self.DrawLUT)

        self.add_toggles(self.box_panel, self.cbox_sizer)

        self.toggle_clut = CustomCheckBox(self.box_panel, -1, "LUT")
        self.toggle_clut.SetForegroundColour(FGCOLOUR)
        self.toggle_clut.SetMaxFontSize(11)
        self.cbox_sizer.Add(
            self.toggle_clut,
            # flag=wx.ALIGN_CENTER_VERTICAL | wx.LEFT,
            flag=wx.ALIGN_CENTER_VERTICAL,
            border=16,
        )
        self.toggle_clut.Bind(wx.EVT_CHECKBOX, self.toggle_clut_handler)

        self.client.canvas.Bind(wx.EVT_MOTION, self.OnMotion)

        self.droptarget = FileDrop(self)
        self.droptarget.drophandlers = {
            ".cal": self.drop_handler,
            ".icc": self.drop_handler,
            ".icm": self.drop_handler,
        }
        self.client.SetDropTarget(self.droptarget)

        border, titlebar = get_platform_window_decoration_size()
        self.MinSize = (
            config.DEFAULTS["size.lut_viewer.w"] + border * 2,
            config.DEFAULTS["size.lut_viewer.h"] + titlebar + border,
        )
        self.SetSaneGeometry(
            getcfg("position.lut_viewer.x"),
            getcfg("position.lut_viewer.y"),
            getcfg("size.lut_viewer.w"),
            getcfg("size.lut_viewer.h"),
        )

        self.Bind(wx.EVT_MOVE, self.OnMove)
        self.Bind(wx.EVT_SIZE, self.OnSize)

        children = list(self.GetAllChildren())

        self.Bind(wx.EVT_KEY_DOWN, self.key_handler)
        for child in children:
            if isinstance(child, wx.Choice):
                child.SetMaxFontSize(11)
            child.Bind(wx.EVT_KEY_DOWN, self.key_handler)
            child.Bind(wx.EVT_MOUSEWHEEL, self.OnWheel)
            if (
                sys.platform == "win32"
                and sys.getwindowsversion() >= (6,)
                and isinstance(child, wx.Panel)
            ):
                # No need to enable double buffering under Linux and Mac OS X.
                # Under Windows, enabling double buffering on the panel seems
                # to work best to reduce flicker.
                child.SetDoubleBuffered(True)

        self.display_no = -1
        self.display_rects = get_display_rects()

    def apply_bpc_handler(self, event):
        """Apply black point compensation to the current profile.

        Args:
            event (wx.Event): The event triggered by the button.
        """
        cal = vcgt_to_cal(self.profile)
        cal.filename = self.profile.filename or ""
        cal.apply_bpc(weight=True)
        self.LoadProfile(cal_to_fake_profile(cal))

    def drop_handler(self, path):
        """Handle drag'n'drop for .cal/.icc/.icm files."""
        filename, ext = os.path.splitext(path)
        if ext.lower() not in (".icc", ".icm"):
            profile = cal_to_fake_profile(path)
            if not profile:
                InfoDialog(
                    self,
                    msg=lang.getstr("error.file.open", path),
                    ok=lang.getstr("ok"),
                    bitmap=get_icon(32, "dialog-error"),
                )
                profile = None
        else:
            try:
                profile = ICCProfile(path)
            except (OSError, ICCProfileInvalidError):
                InfoDialog(
                    self,
                    msg=f"{lang.getstr('profile.invalid')}\n{path}",
                    ok=lang.getstr("ok"),
                    bitmap=get_icon(32, "dialog-error"),
                )
                profile = None
        self.show_actual_lut_cb.SetValue(False)
        self.current_cal = profile
        self.LoadProfile(profile)

    get_display = MeasureFrame.__dict__["get_display"]

    def handle_errors(self):
        """Handle errors from the client."""
        if not self.client.errors:
            return
        show_result_dialog(
            Error(
                "\n\n".join([f"{e}" for e in set(self.client.errors) if e is not None])
            ),
            self,
        )
        self.client.errors = []

    def install_vcgt_handler(self, event):
        """Install the VCGT from the current profile to the display.

        Args:
            event (wx.Event): The event triggered by the button.
        """
        cwd = self.worker.create_tempdir()
        if isinstance(cwd, Exception):
            show_result_dialog(cwd, self)
        else:
            cal = os.path.join(
                cwd,
                re.sub(
                    r"[\\/:*?\"<>|]+",
                    "",
                    make_argyll_compatible_path(
                        self.profile.getDescription() or "Video LUT"
                    ),
                ),
            )
            vcgt_to_cal(self.profile).write(cal)
            cmd, args = self.worker.prepare_dispwin(cal)
            if isinstance(cmd, Exception):
                show_result_dialog(cmd, self)
            elif cmd:
                result = self.worker.exec_cmd(
                    cmd, args, capture_output=True, skip_scripts=True
                )
                if isinstance(result, Exception):
                    show_result_dialog(result, self)
                elif not result:
                    show_result_dialog(Error("".join(self.worker.errors)), self)
            # Important:
            # Make sure to only delete the temporary cal file we created
            try:
                os.remove(cal)
            except Exception as exception:
                print(
                    f"Warning - temporary file '{cal}' could not be removed: "
                    f"{exception}"
                )

    def key_handler(self, event):
        """Handle key events for the LUT viewer.

        Args:
            event (wx.Event): The event triggered by the key press.
        """
        # AltDown
        # CmdDown
        # ControlDown
        # GetKeyCode
        # GetModifiers
        # GetPosition
        # GetRawKeyCode
        # GetRawKeyFlags
        # GetUniChar
        # GetUnicodeKey
        # GetX
        # GetY
        # HasModifiers
        # KeyCode
        # MetaDown
        # Modifiers
        # Position
        # RawKeyCode
        # RawKeyFlags
        # ShiftDown
        # UnicodeKey
        # X
        # Y
        key = event.GetKeyCode()
        if (
            event.ControlDown() or event.CmdDown()
        ):  # CTRL (Linux/Mac/Windows) / CMD (Mac)
            if key == 83 and self.profile:  # S
                self.SaveFile()
                return
            event.Skip()
        elif key in (43, wx.WXK_NUMPAD_ADD):
            # + key zoom in
            self.client.zoom(-1)
        elif key in (45, wx.WXK_NUMPAD_SUBTRACT):
            # - key zoom out
            self.client.zoom(1)
        else:
            event.Skip()

    def direction_select_handler(self, event):
        """Handle the direction selection.

        Args:
            event (wx.Event): The event triggered by the choice selection.
        """
        self.toggle_clut_handler(event)

    def rendering_intent_select_handler(self, event):
        """Handle the rendering intent selection.

        Args:
            event (wx.Event): The event triggered by the choice selection.
        """
        self.toggle_clut_handler(event)

    def toggle_clut_handler(self, event):
        """Handle the toggle CLUT checkbox.

        Args:
            event (wx.Event): The event triggered by the checkbox.
        """
        try:
            self.lookup_tone_response_curves()
        except Exception as exception:
            import traceback

            print(traceback.format_exc())
            show_result_dialog(exception, self)
        else:
            self.trc = None
            self.DrawLUT()
            self.handle_errors()

    def tooltip_handler(self, event):
        """Handle the tooltip button.

        Args:
            event (wx.Event): The event triggered by the button.
        """
        if not hasattr(self, "tooltip_window"):
            self.tooltip_window = TooltipWindow(
                self,
                msg=event.EventObject.ToolTip.Tip,
                title=event.EventObject.TopLevelParent.Title,
                bitmap=get_icon(32, "dialog-information"),
            )
        else:
            self.tooltip_window.Show()
            self.tooltip_window.Raise()

    def show_actual_lut_handler(self, event):
        """Handle the show actual LUT checkbox.

        Args:
            event (wx.Event): The event triggered by the checkbox.
        """
        setcfg("lut_viewer.show_actual_lut", int(self.show_actual_lut_cb.GetValue()))
        profile = self.current_cal if hasattr(self, "current_cal") else None
        self.load_lut(profile=profile)

    def load_lut(self, profile=None):
        """Load LUT from profile.

        Args:
            profile (ICCProfile): The profile to load the LUT from.
        """
        self.current_cal = profile
        if (
            getcfg("lut_viewer.show_actual_lut")
            and (
                self.worker.argyll_version[0:3] > [1, 1, 0]
                or (
                    self.worker.argyll_version[0:3] == [1, 1, 0]
                    and "Beta" not in self.worker.argyll_version_string
                )
            )
            and not config.is_untethered_display()
        ):
            tmp = self.worker.create_tempdir()
            if isinstance(tmp, Exception):
                show_result_dialog(tmp, self)
                return
            outfilename = os.path.join(
                tmp,
                re.sub(
                    r"[\\/:*?\"<>|]+",
                    "",
                    make_argyll_compatible_path(
                        config.get_display_name(include_geometry=True) or "Video LUT"
                    ),
                ),
            )
            result = self.worker.save_current_video_lut(
                self.worker.get_display(),
                outfilename,
                silent=__name__ != "__main__",
            )
            if not isinstance(result, Exception) and result:
                profile = cal_to_fake_profile(outfilename)
            elif isinstance(result, Exception):
                print(result)
            # Important: lut_viewer_load_lut is called after measurements,
            # so make sure to only delete the temporary cal file we created
            try:
                os.remove(outfilename)
            except Exception as exception:
                print(
                    f"Warning - temporary file '{outfilename}' could not be removed: "
                    f"{exception}"
                )
        if profile and (
            profile.is_loaded
            or not profile.filename
            or os.path.isfile(profile.filename)
        ):
            if (
                not self.profile
                or self.profile.filename != profile.filename
                or not self.profile.is_same(profile)
            ):
                self.LoadProfile(profile)
        else:
            self.LoadProfile(None)

    def lookup_tone_response_curves(self, intent="r"):
        """Lookup Y -> RGB tone values through TRC tags or LUT.

        Args:
            intent (str): Rendering intent, one of 'a', 'r', 'p', 's'.
        """
        profile = self.profile

        # Final number of coordinates
        if profile.connectionColorSpace == b"RGB":
            size = 256
        elif profile.connectionColorSpace == b"Lab":
            size = 1001
        else:
            size = 1024

        if hasattr(self, "rendering_intent_select"):
            intent = {0: "a", 1: "r", 2: "p", 3: "s"}.get(
                self.rendering_intent_select.GetSelection()
            )

        use_trc_tags = (
            intent == "r"
            and (
                not ("B2A0" in self.profile.tags or "A2B0" in self.profile.tags)
                or not self.toggle_clut.GetValue()
            )
            and isinstance(self.rTRC, CurveType)
            and isinstance(self.gTRC, CurveType)
            and isinstance(self.bTRC, CurveType)
            and len(self.rTRC) == len(self.gTRC) == len(self.bTRC)
        )
        has_same_trc = self.rTRC == self.gTRC == self.bTRC

        if profile.version >= 4 and not profile.convert_iccv4_tags_to_iccv2():
            self.client.errors.append(
                Error(
                    "\n".join(
                        [
                            lang.getstr("profile.iccv4.unsupported"),
                            profile.getDescription(),
                        ]
                    )
                )
            )
            return

        if profile.colorSpace not in (
            b"RGB",
            b"GRAY",
            b"CMYK",
        ) or profile.connectionColorSpace not in (b"Lab", b"XYZ", b"RGB"):
            if profile.colorSpace not in (b"RGB", b"GRAY", b"CMYK"):
                unsupported_colorspace = profile.colorSpace
            else:
                unsupported_colorspace = profile.connectionColorSpace
            self.client.errors.append(
                Error(
                    lang.getstr(
                        "profile.unsupported",
                        (profile.profileClass, unsupported_colorspace),
                    )
                )
            )
            return

        if profile.colorSpace == b"GRAY":
            direction = "b"
        elif profile.connectionColorSpace == b"RGB":
            direction = "f"
        elif "B2A0" in profile.tags:
            index = self.direction_select.GetSelection()
            if index == -1:  # Fix for #31
                index = 0
                self.direction_select.SetSelection(index)
            direction = {0: "b", 1: "if", 2: "f", 3: "ib"}.get(
                self.direction_select.GetSelection()
            )
        else:
            direction = "if"

        # Prepare input Lab values
        XYZ_triplets = []
        Lab_triplets = []
        devicevalues = []
        for i in range(size):
            if direction in ("b", "if"):
                if intent == "a":
                    # For display profiles, identical to relcol
                    # For print profiles, makes max L* match paper white
                    XYZwp_ir = list(profile.tags.wtpt.ir.values())
                    Labwp_ir = profile.tags.wtpt.ir.Lab
                    XYZwp_D50 = colormath.Lab2XYZ(Labwp_ir[0], 0, 0)
                    X, Y, Z = colormath.Lab2XYZ(
                        min(i * (100.0 / (size - 1)), Labwp_ir[0]), 0, 0
                    )
                    L, a, b = colormath.XYZ2Lab(
                        *[
                            v * 100
                            for v in colormath.adapt(X, Y, Z, XYZwp_D50, XYZwp_ir)
                        ]
                    )
                else:
                    L = i * (100.0 / (size - 1))
                    a = b = 0
                Lab_triplets.append([L, a, b])
            else:
                devicevalues.append([i * (1.0 / (size - 1))] * len(profile.colorSpace))
        if profile.colorSpace == "GRAY":
            use_icclu = True
            pcs = "x"
            XYZ_triplets.extend(colormath.Lab2XYZ(*Lab) for Lab in Lab_triplets)
        elif profile.connectionColorSpace == b"RGB":
            use_icclu = False
            pcs = None
            intent = None
        else:
            use_icclu = False
            pcs = "l"
        if direction in ("b", "if"):
            idata = Lab_triplets if pcs == "l" else XYZ_triplets
        else:
            idata = devicevalues

        order = {True: "n", False: "r"}.get(
            ("B2A0" in self.profile.tags or "A2B0" in self.profile.tags)
            and self.toggle_clut.GetValue()
        )

        # Lookup values through 'input' profile using xicclu
        try:
            odata = self.worker.xicclu(
                profile,
                idata,
                intent,
                direction,
                order,
                pcs,
                use_icclu=use_icclu,
                get_clip=direction == "if",
            )
        except Exception as exception:
            self.client.errors.append(Error(exception))

        if self.client.errors:
            return

        if direction in ("b", "if") or profile.connectionColorSpace == b"RGB":
            if direction == "if" and profile.colorSpace == b"RGB":
                Lbp = self.worker.xicclu(
                    profile, [[0, 0, 0]], intent, "f", order, "l", use_icclu=use_icclu
                )[0][0]
                maxval = size - 1.0

                # Deal with values that got clipped (below black as well as white)
                # XXX: We should not clip these when plotting, because our input
                # values are all a = b = 0
                do_low_clip = False
                make_mono = True
                # Keep the max amount of useful information from Lbp onwards
                mono_end = 0
                for i, values in enumerate(odata):
                    if values[3] is True or i == 0:
                        if (do_low_clip and (i / maxval * 100 < Lbp or i == 0)) or (
                            make_mono and i == 0
                        ):
                            # Set to black
                            values[:] = [0.0, 0.0, 0.0]
                        elif i == maxval and [round(v, 4) for v in values[:3]] == [
                            1,
                            1,
                            1,
                        ]:
                            # Set to white
                            values[:] = [1.0, 1.0, 1.0]
                        elif i / maxval * 100 < Lbp:
                            mono_end = i + 2
                    else:
                        # First non-clipping value disables low clipping
                        do_low_clip = False
                    if len(values) > 3:
                        values.pop()

                if mono_end and mono_end < len(odata):
                    # Make segment from first non-zero value to Lbp
                    # monotonically increasing
                    mono = [[], [], []]
                    for values in odata:
                        for j in range(3):
                            mono[j].append(values[j])
                    for j, values in enumerate(mono):
                        for v in values:
                            if v:
                                break
                        if i:
                            i -= 1
                        if i + 2 < mono_end:
                            mono[j][i:mono_end] = (
                                colormath.make_monotonically_increasing(
                                    values[i:mono_end]
                                )
                            )
                    odata = []
                    for i in range(len(mono[0])):
                        values = []
                        for j in range(3):
                            values.append(mono[j][i])
                        odata.append(values)

            devicevalues = odata
        else:
            Lab_triplets = odata

        maxv = 255 if profile.colorSpace in (b"RGB", b"GRAY") else 100
        self.rTRC = CoordinateType(self.profile)
        self.gTRC = CoordinateType(self.profile)
        self.bTRC = CoordinateType(self.profile)
        self.kTRC = CoordinateType(self.profile)
        for j, sample in enumerate(devicevalues):
            for i, v in enumerate(sample):
                v = min(v, 1.0)
                if (
                    not v
                    and j < len(devicevalues) - 1
                    and not min(devicevalues[j + 1][i], 1.0)
                ):
                    continue
                v *= maxv
                if profile.connectionColorSpace == b"RGB":
                    x = j / (size - 1.0) * maxv
                    if i == 0:
                        self.rTRC.append([x, v])
                    elif i == 1:
                        self.gTRC.append([x, v])
                    elif i == 2:
                        self.bTRC.append([x, v])
                else:
                    X, Y, Z = colormath.Lab2XYZ(*Lab_triplets[j], scale=100)
                    if direction in ("b", "if"):
                        X = Z = Y
                    elif intent == "a":
                        wp = list(profile.tags.wtpt.ir.values())
                        X, Y, Z = colormath.adapt(X, Y, Z, wp, (1, 1, 1))
                    elif intent != "a":
                        wp = list(profile.tags.wtpt.ir.values())
                        X, Y, Z = colormath.adapt(X, Y, Z, "D50", (1, 1, 1))
                    if i == 0:
                        self.rTRC.append([X, v])
                    elif i == 1:
                        self.gTRC.append([Y, v])
                    elif i == 2:
                        self.bTRC.append([Z, v])
                    elif i == 3:
                        self.kTRC.append([Y, v])
        if profile.connectionColorSpace == b"RGB":
            return
        if use_trc_tags:
            if has_same_trc:
                self.bTRC = self.gTRC = self.rTRC
            return
        # Generate interpolated TRCs for transfer function detection
        prev = None
        for sig in ("rTRC", "gTRC", "bTRC", "kTRC"):
            x, xp, y, yp = [], [], [], []
            # First, get actual values
            for Y, v in getattr(self, sig):
                # if not i or Y >= trc[sig][i - 1]:
                xp.append(v)
                yp.append(Y)
            setattr(self, f"tf_{sig}", CoordinateType(self.profile))
            if not xp or not yp:
                if prev:
                    getattr(self, f"tf_{sig}")[:] = prev
                continue
            # Second, interpolate to given size and use the same y axis
            # for all channels
            for i in range(size):
                x.append(i / (size - 1.0) * maxv)
                y.append(colormath.Lab2XYZ(i / (size - 1.0) * 100, 0, 0)[1] * 100)
            xi = numpy.interp(y, yp, xp)
            yi = numpy.interp(x, xi, y)
            prev = getattr(self, f"tf_{sig}")
            for Y, v in zip(yi, x):
                Y = max(yp[0], Y)  # noqa: SIM300
                prev.append([Y, v])

    def move_handler(self, event):
        """Handle the move event to update the display number and load the LUT.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        if not self.IsShownOnScreen():
            return
        display_no, geometry, client_area = self.get_display()
        if display_no != self.display_no:
            self.display_no = display_no
            # Translate from wx display index to Argyll display index
            n = get_argyll_display_number(geometry)
            if n is not None:
                # Save Argyll display index to configuration
                setcfg("display.number", n + 1)
                # Load profile
                self.load_lut(get_display_profile(n))
        event.Skip()

    def plot_mode_select_handler(self, event):
        """Handle selection of the plot mode from the dropdown.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        # self.client.resetzoom()
        self.DrawLUT()

    def get_commands(self):
        """Get a list of commands that can be used with this frame."""
        return [
            *self.get_common_commands(),
            "curve-viewer [filename]",
            "load <filename>",
        ]

    def process_data(self, data):
        """Process commands from the command line or other sources.

        Args:
            data (list): A list of strings containing the command and its
                arguments.

        Returns:
            str: "ok" if the command was processed successfully, "fail" if
                the command failed, or "invalid" if the command is not
                recognized.
        """
        if (data[0] == "curve-viewer" and len(data) < 3) or (
            data[0] == "load" and len(data) == 2
        ):
            if self.IsIconized():
                self.Restore()
            self.Raise()
            if len(data) == 2:
                path = data[1]
                if not os.path.isfile(path) and not os.path.isabs(path):
                    path = get_data_path(path)
                if not path:
                    return "fail"
                self.droptarget.OnDropFiles(0, 0, [path])
            return "ok"
        return "invalid"

    def reload_vcgt_handler(self, event):
        """Reload the VCGT from the display profile.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        cmd, args = self.worker.prepare_dispwin(True)
        if isinstance(cmd, Exception):
            show_result_dialog(cmd, self)
        elif cmd:
            result = self.worker.exec_cmd(
                cmd, args, capture_output=True, skip_scripts=True
            )
            if isinstance(result, Exception):
                show_result_dialog(result, self)
            elif not result:
                show_result_dialog(UnloggedError("".join(self.worker.errors)), self)
            else:
                self.load_lut(get_display_profile())

    def LoadProfile(self, profile):
        """Load a profile and update the LUT viewer.

        Args:
            profile (ICCProfile or str): The ICC profile to load, either as an
                ICCProfile object or a file path.
        """
        if profile and not isinstance(profile, ICCProfile):
            try:
                profile = ICCProfile(profile)
            except (OSError, ICCProfileInvalidError):
                show_result_dialog(
                    Error(f"{lang.getstr('profile.invalid')}\n{profile}"), self
                )
                profile = None
        if not profile:
            profile = ICCProfile()
            profile._data = "\0" * 128
            profile._tags.desc = TextDescriptionType(b"", "desc")
            profile.size = len(profile.data)
            profile.is_loaded = True
        center = False
        if (
            getattr(self, "profile", None)
            and self.profile.colorSpace != profile.colorSpace
        ):
            center = True
        if profile.getDescription():
            title = " \u2014 ".join(
                [lang.getstr("calibration.lut_viewer.title"), profile.getDescription()]
            )
        else:
            title = lang.getstr("calibration.lut_viewer.title")
        self.SetTitle(title)
        self.profile = profile
        for channel in "rgb":
            trc = profile.tags.get(f"{channel}TRC", profile.tags.get("kTRC"))
            if isinstance(trc, ParametricCurveType):
                trc = trc.get_trc()
            setattr(self, f"{channel}TRC", trc)
            setattr(self, f"tf_{channel}TRC", trc)
        self.trc = None
        curves = []
        if (
            "vcgt" in profile.tags
            or "MS00" in profile.tags
            or self.__class__ is LUTFrame
        ):
            curves.append(lang.getstr("vcgt"))
        self.client.errors = []
        self.toggle_clut.SetValue("B2A0" in profile.tags or "A2B0" in profile.tags)
        if (self.rTRC and self.gTRC and self.bTRC) or (
            self.toggle_clut.GetValue()
            and profile.colorSpace in (b"RGB", b"GRAY", b"CMYK")
        ):
            try:
                self.lookup_tone_response_curves()
            except Exception as exception:
                wx.CallAfter(show_result_dialog, exception, self)
            else:
                curves.append(lang.getstr("[rgb]TRC"))
        curves = self.add_shaper_curves(curves)
        selection = self.plot_mode_select.GetStringSelection()
        self.plot_mode_select.SetItems(curves)
        self.plot_mode_select.Enable(len(curves) > 1)
        if curves and not self.plot_mode_select.SetStringSelection(selection):
            self.plot_mode_select.SetSelection(0)
            center = True
        self.plot_mode_select.ContainingSizer.Layout()
        self.cbox_sizer.Layout()
        self.box_sizer.Layout()
        self.DrawLUT()
        if center:
            wx.CallAfter(self.client.center)
        wx.CallAfter(self.handle_errors)

    def add_shaper_curves(self, curves):
        """Add shaper curves to the list of curves if available.

        Args:
            curves (list): The list of curves to which shaper curves will be
                added.

        Returns:
            list: The updated list of curves including shaper curves if they
                are available.
        """
        if getcfg("show_advanced_options"):
            if isinstance(self.profile.tags.get("A2B0"), LUT16Type):
                curves.append(lang.getstr("profile.tags.A2B0.shaper_curves.input"))
                curves.append(lang.getstr("profile.tags.A2B0.shaper_curves.output"))
            if isinstance(self.profile.tags.get("A2B1"), LUT16Type):
                curves.append(lang.getstr("profile.tags.A2B1.shaper_curves.input"))
                curves.append(lang.getstr("profile.tags.A2B1.shaper_curves.output"))
            if isinstance(self.profile.tags.get("A2B2"), LUT16Type):
                curves.append(lang.getstr("profile.tags.A2B2.shaper_curves.input"))
                curves.append(lang.getstr("profile.tags.A2B2.shaper_curves.output"))
            if isinstance(self.profile.tags.get("B2A0"), LUT16Type):
                curves.append(lang.getstr("profile.tags.B2A0.shaper_curves.input"))
                curves.append(lang.getstr("profile.tags.B2A0.shaper_curves.output"))
            if isinstance(self.profile.tags.get("B2A1"), LUT16Type):
                curves.append(lang.getstr("profile.tags.B2A1.shaper_curves.input"))
                curves.append(lang.getstr("profile.tags.B2A1.shaper_curves.output"))
            if isinstance(self.profile.tags.get("B2A2"), LUT16Type):
                curves.append(lang.getstr("profile.tags.B2A2.shaper_curves.input"))
                curves.append(lang.getstr("profile.tags.B2A2.shaper_curves.output"))
        return curves

    def add_toggles(self, parent, sizer):
        """Add toggle checkboxes for up to 16 channels.

        Args:
            parent (wx.Panel): The parent panel to which the toggles will be added.
            sizer (wx.Sizer): The sizer to which the toggles will be added.
        """
        # Add toggle checkboxes for up to 16 channels
        self.toggles = []
        for i in range(16):
            if SAFE_WX_UI:
                toggle = wx.CheckBox(parent, -1, "", name=f"toggle_channel_{i}")
            else:
                toggle = CustomCheckBox(parent, -1, "", name=f"toggle_channel_{i}")
            toggle.SetForegroundColour(FGCOLOUR)
            if hasattr(toggle, "SetMaxFontSize"):
                toggle.SetMaxFontSize(11)
            toggle.SetValue(True)
            sizer.Add(
                toggle,
                flag=wx.ALIGN_CENTER_VERTICAL | wx.RIGHT,
                border=4 if i < 15 else 0,
            )
            toggle.Bind(wx.EVT_CHECKBOX, self.DrawLUT)
            self.toggles.append(toggle)
            toggle.Hide()

    def add_tone_values(self, legend):
        """Add tone values to the legend.

        Args:
            legend (list): The legend to which tone values will be added.
        """
        if not self.profile:
            return
        colorants = legend[0]
        if (
            self.plot_mode_select.GetStringSelection() == lang.getstr("vcgt")
            and "vcgt" in self.profile.tags
        ):
            if "R" in colorants or "G" in colorants or "B" in colorants:
                legend.append(lang.getstr("tone_values"))
                if False:  # "=" in colorants:  # NEVER
                    unique = []
                    if 0 in self.client.unique:  # Red
                        unique.append(self.client.unique[0])
                    if 1 in self.client.unique:  # Green
                        unique.append(self.client.unique[1])
                    if 2 in self.client.unique:  # Blue
                        unique.append(self.client.unique[2])
                    unique = min(unique)
                    legend[-1] += " {:.1f}% ({}/{})".format(  # noqa: UP032
                        unique / (self.client.entryCount / 100.0),
                        unique,
                        self.client.entryCount,
                    )
                else:
                    if 0 in self.client.unique:  # Red
                        legend[-1] += " {:.1f}% ({}/{})".format(  # noqa: UP032
                            self.client.unique[0] / (self.client.entryCount / 100.0),
                            self.client.unique[0],
                            self.client.entryCount,
                        )
                    if 1 in self.client.unique:  # Green
                        legend[-1] += " {:.1f}% ({}/{})".format(  # noqa: UP032
                            self.client.unique[1] / (self.client.entryCount / 100.0),
                            self.client.unique[1],
                            self.client.entryCount,
                        )
                    if 2 in self.client.unique:  # Blue
                        legend[-1] += " {:.1f}% ({}/{})".format(  # noqa: UP032
                            self.client.unique[2] / (self.client.entryCount / 100.0),
                            self.client.unique[2],
                            self.client.entryCount,
                        )
                unique = list(self.client.unique.values())
                if 0 not in unique and "R=G=B" not in colorants:
                    unique = min(unique)
                    legend[-1] += ", {} {:.1f}% ({}/{})".format(
                        lang.getstr("grayscale"),
                        unique / (self.client.entryCount / 100.0),
                        unique,
                        self.client.entryCount,
                    )
        elif (
            self.plot_mode_select.GetStringSelection() == lang.getstr("[rgb]TRC")
            and isinstance(self.tf_rTRC, (CurveType, CoordinateType))
            and len(self.tf_rTRC) > 1
            and isinstance(self.tf_gTRC, (CurveType, CoordinateType))
            and len(self.tf_gTRC) > 1
            and isinstance(self.tf_bTRC, (CurveType, CoordinateType))
            and len(self.tf_bTRC) > 1
        ):
            transfer_function = None
            if not getattr(self, "trc", None) and len(self.tf_rTRC) == len(
                self.tf_gTRC
            ) == len(self.tf_bTRC):
                if isinstance(self.tf_rTRC, CurveType):
                    self.trc = CurveType(profile=self.profile)
                    for i in range(len(self.tf_rTRC)):
                        self.trc.append(
                            (self.tf_rTRC[i] + self.tf_gTRC[i] + self.tf_bTRC[i]) / 3.0
                        )
                else:
                    self.trc = CoordinateType(self.profile)
                    for i in range(len(self.tf_rTRC)):
                        self.trc.append(
                            [
                                (
                                    self.tf_rTRC[i][0]
                                    + self.tf_gTRC[i][0]
                                    + self.tf_bTRC[i][0]
                                )
                                / 3.0,
                                (
                                    self.tf_rTRC[i][1]
                                    + self.tf_gTRC[i][1]
                                    + self.tf_bTRC[i][1]
                                )
                                / 3.0,
                            ]
                        )
            if getattr(self, "trc", None):
                transfer_function = self.trc.get_transfer_function(
                    slice_=(0.00, 1.00), outoffset=1.0
                )
            # if "R" in colorants and "G" in colorants and "B" in colorants:
            #     if (self.profile.tags.rTRC == self.profile.tags.gTRC ==
            #        self.profile.tags.bTRC):
            #         transfer_function = self.profile.tags.rTRC.get_transfer_function()
            # elif ("R" in colorants and (not "G" in colorants or
            #   self.profile.tags.rTRC == self.profile.tags.gTRC) and
            #   (not "B" in colorants or
            #   self.profile.tags.rTRC == self.profile.tags.bTRC)):
            #     transfer_function = self.profile.tags.rTRC.get_transfer_function()
            # elif ("G" in colorants and (not "R" in colorants or
            #   self.profile.tags.gTRC == self.profile.tags.rTRC) and
            #   (not "B" in colorants or
            #   self.profile.tags.gTRC == self.profile.tags.bTRC)):
            #     transfer_function = self.profile.tags.gTRC.get_transfer_function()
            # elif ("B" in colorants and (not "G" in colorants or
            #   self.profile.tags.bTRC == self.profile.tags.gTRC) and
            #   (not "R" in colorants or
            #   self.profile.tags.bTRC == self.profile.tags.rTRC)):
            #     transfer_function = self.profile.tags.bTRC.get_transfer_function()
            if transfer_function and transfer_function[1] >= 0.95:
                if self.tf_rTRC == self.tf_gTRC == self.tf_bTRC:
                    label = lang.getstr("rgb.trc")
                else:
                    label = lang.getstr("rgb.trc.averaged")
                if round(transfer_function[1], 2) == 1.0:
                    value = f"{transfer_function[0][0]}"
                else:
                    value = (
                        f"≈ {transfer_function[0][0]} "
                        f"(Δ {1 - transfer_function[1]:.2%})"
                    )
                legend.append(f"{label} {value}")

    def DrawLUT(self, event=None):
        """Draw the LUT in the client area.

        Args:
            event: The event that triggered the redraw, if any.
        """
        self.SetStatusText("")
        self.Freeze()
        curves = None
        curves_colorspace = self.profile.colorSpace
        connection_colorspace = b"RGB"
        if self.profile and self.plot_mode_select.Items:
            if self.plot_mode_select.GetStringSelection() == lang.getstr("vcgt"):
                # Convert calibration information from embedded WCS profile
                # (if present) to VideCardFormulaType if the latter is not present
                if (
                    isinstance(self.profile.tags.get("MS00"), WcsProfilesTagType)
                    and "vcgt" not in self.profile.tags
                ):
                    vcgt = self.profile.tags["MS00"].get_vcgt()
                    if vcgt:
                        self.profile.tags["vcgt"] = vcgt
                if "vcgt" in self.profile.tags:
                    curves = self.profile.tags["vcgt"]
                else:
                    curves = None
            elif self.plot_mode_select.GetStringSelection() == lang.getstr("[rgb]TRC"):
                if not (
                    isinstance(self.rTRC, (CurveType, CoordinateType))
                    and isinstance(self.gTRC, (CurveType, CoordinateType))
                    and isinstance(self.bTRC, (CurveType, CoordinateType))
                ):
                    curves = None
                elif (
                    len(self.rTRC) == 1 and len(self.gTRC) == 1 and len(self.bTRC) == 1
                ):
                    # gamma
                    curves = {
                        "redMin": 0.0,
                        "redGamma": self.rTRC[0],
                        "redMax": 1.0,
                        "greenMin": 0.0,
                        "greenGamma": self.gTRC[0],
                        "greenMax": 1.0,
                        "blueMin": 0.0,
                        "blueGamma": self.bTRC[0],
                        "blueMax": 1.0,
                    }
                else:
                    # curves
                    data = [self.rTRC, self.gTRC, self.bTRC]
                    if hasattr(self, "kTRC"):
                        data.append(self.kTRC)
                    curves = {
                        "data": data,
                        "entryCount": len(self.rTRC),
                        "entrySize": 2,
                    }
            elif self.plot_mode_select.GetStringSelection() != lang.getstr("gamut"):
                to_pcs = False
                if self.plot_mode_select.GetStringSelection() == lang.getstr(
                    "profile.tags.A2B0.shaper_curves.input"
                ):
                    tables = self.profile.tags.A2B0.input
                elif self.plot_mode_select.GetStringSelection() == lang.getstr(
                    "profile.tags.A2B0.shaper_curves.output"
                ):
                    tables = self.profile.tags.A2B0.output
                    curves_colorspace = self.profile.connectionColorSpace
                    to_pcs = True
                elif self.plot_mode_select.GetStringSelection() == lang.getstr(
                    "profile.tags.A2B1.shaper_curves.input"
                ):
                    tables = self.profile.tags.A2B1.input
                elif self.plot_mode_select.GetStringSelection() == lang.getstr(
                    "profile.tags.A2B1.shaper_curves.output"
                ):
                    tables = self.profile.tags.A2B1.output
                    curves_colorspace = self.profile.connectionColorSpace
                    to_pcs = True
                elif self.plot_mode_select.GetStringSelection() == lang.getstr(
                    "profile.tags.A2B2.shaper_curves.input"
                ):
                    tables = self.profile.tags.A2B2.input
                elif self.plot_mode_select.GetStringSelection() == lang.getstr(
                    "profile.tags.A2B2.shaper_curves.output"
                ):
                    tables = self.profile.tags.A2B2.output
                    curves_colorspace = self.profile.connectionColorSpace
                    to_pcs = True
                elif self.plot_mode_select.GetStringSelection() == lang.getstr(
                    "profile.tags.B2A0.shaper_curves.input"
                ):
                    tables = self.profile.tags.B2A0.input
                    curves_colorspace = self.profile.connectionColorSpace
                elif self.plot_mode_select.GetStringSelection() == lang.getstr(
                    "profile.tags.B2A0.shaper_curves.output"
                ):
                    tables = self.profile.tags.B2A0.output
                elif self.plot_mode_select.GetStringSelection() == lang.getstr(
                    "profile.tags.B2A1.shaper_curves.input"
                ):
                    tables = self.profile.tags.B2A1.input
                    curves_colorspace = self.profile.connectionColorSpace
                elif self.plot_mode_select.GetStringSelection() == lang.getstr(
                    "profile.tags.B2A1.shaper_curves.output"
                ):
                    tables = self.profile.tags.B2A1.output
                elif self.plot_mode_select.GetStringSelection() == lang.getstr(
                    "profile.tags.B2A2.shaper_curves.input"
                ):
                    tables = self.profile.tags.B2A2.input
                    curves_colorspace = self.profile.connectionColorSpace
                elif self.plot_mode_select.GetStringSelection() == lang.getstr(
                    "profile.tags.B2A2.shaper_curves.output"
                ):
                    tables = self.profile.tags.B2A2.output
                entry_count = len(tables[0])
                maxv = 100 if curves_colorspace != b"RGB" else 255
                lin = [v / (entry_count - 1.0) * maxv for v in range(entry_count)]
                data = []
                for i, table in enumerate(tables):
                    xp = lin
                    if curves_colorspace == b"Lab" and i == 0:
                        if to_pcs:
                            table = [v / 65280.0 * 65535.0 for v in table]
                        else:
                            xp = [
                                min(
                                    v / (entry_count - 1.0) * (100 + 25500 / 65280.0),
                                    maxv,
                                )
                                for v in range(entry_count)
                            ]
                    yp = [v / 65535.0 * maxv for v in table]
                    if curves_colorspace == b"Lab" and i == 0:
                        # Interpolate to given size and use the same y axis
                        # for all channels
                        xi = numpy.interp(lin, yp, xp)
                        yi = numpy.interp(lin, xi, lin)
                    else:
                        yi = yp
                    xy = []
                    for Y, v in zip(yi, lin):
                        Y = max(yp[0], Y)  # noqa: SIM300
                        xy.append([v, Y])
                    data.append(xy)
                curves = {"data": data, "entryCount": entry_count, "entrySize": 2}
        yLabel = []
        numchannels = {
            b"XYZ": 3,
            b"Lab": 3,
            b"Luv": 3,
            b"YCbr": 3,
            b"Yxy": 3,
            b"RGB": 3,
            b"GRAY": 1,
            b"HSV": 3,
            b"HLS": 3,
            b"CMYK": 4,
            b"CMY": 3,
            b"2CLR": 2,
            b"3CLR": 3,
            b"4CLR": 4,
            b"5CLR": 5,
            b"6CLR": 6,
            b"7CLR": 7,
            b"8CLR": 8,
            b"9CLR": 9,
            b"ACLR": 10,
            b"BCLR": 11,
            b"CCLR": 12,
            b"DCLR": 13,
            b"ECLR": 14,
            b"FCLR": 15,
        }.get(curves_colorspace, 3)
        is_ktrc = (
            self.plot_mode_select.GetStringSelection() == lang.getstr("[rgb]TRC")
            and curves_colorspace != b"RGB"
            and False
        )
        if is_ktrc:
            curves_colorspace = b"GRAY"
            numchannels = 1
        for channel, toggle in enumerate(self.toggles):
            toggle.Show(channel < numchannels)
            if channel < numchannels:
                if curves_colorspace == b"GRAY":
                    toggle.Label = "R=G=B"
                elif curves_colorspace == b"YCbr":
                    toggle.Label = ("Y", "Cb", "Cr")[channel]
                elif curves_colorspace.endswith(b"CLR"):
                    curves_colorspace = b"nCLR"
                    toggle.Label = f"{channel}"
                elif curves_colorspace == b"Lab":
                    toggle.Label = ("L*", "a*", "b*")[channel]
                else:
                    toggle.Label = curves_colorspace[channel : channel + 1].decode(
                        "utf-8"
                    )
                if toggle.GetValue():
                    yLabel.append(toggle.Label)
            toggle.Enable(bool(curves))
        if self.plot_mode_select.GetStringSelection() not in (
            lang.getstr("[rgb]TRC"),
            lang.getstr("gamut"),
        ) or (self.profile and self.profile.connectionColorSpace == b"RGB"):
            self.xLabel = "".join(yLabel)
        elif self.show_as_L.GetValue():
            connection_colorspace = b"Lab"
            self.xLabel = "L*"
        else:
            connection_colorspace = b"XYZ"
            self.xLabel = "Y"
        self.yLabel = "".join(yLabel)

        self.show_as_L.Enable(bool(curves))
        self.show_as_L.Show(
            self.plot_mode_select.GetStringSelection() == lang.getstr("[rgb]TRC")
            and self.profile.connectionColorSpace != b"RGB"
        )
        self.toggle_clut.Show(
            self.profile.colorSpace == b"RGB"
            and self.profile.connectionColorSpace != b"RGB"
            and self.plot_mode_select.GetStringSelection() == lang.getstr("[rgb]TRC")
            and ("B2A0" in self.profile.tags or b"A2B0" in self.profile.tags)
        )
        self.toggle_clut.Enable(
            self.profile.connectionColorSpace != b"RGB"
            and self.plot_mode_select.GetStringSelection() == lang.getstr("[rgb]TRC")
            and isinstance(self.profile.tags.get("rTRC"), CurveType)
            and isinstance(self.profile.tags.get("gTRC"), CurveType)
            and isinstance(self.profile.tags.get("bTRC"), CurveType)
        )
        self.save_plot_btn.Enable(bool(curves))
        if hasattr(self, "reload_vcgt_btn"):
            self.reload_vcgt_btn.Enable(
                self.plot_mode_select.GetStringSelection() == lang.getstr("vcgt")
                and bool(self.profile)
            )
            self.reload_vcgt_btn.Show(
                self.plot_mode_select.GetStringSelection() == lang.getstr("vcgt")
            )
        if hasattr(self, "apply_bpc_btn"):
            enable_bpc = (
                self.plot_mode_select.GetStringSelection() == lang.getstr("vcgt")
                and bool(self.profile)
                and isinstance(self.profile.tags.get("vcgt"), VideoCardGammaType)
            )
            if enable_bpc:
                values = self.profile.tags.vcgt.getNormalizedValues()
            self.apply_bpc_btn.Enable(enable_bpc and values[0] != (0, 0, 0))
            self.apply_bpc_btn.Show(
                self.plot_mode_select.GetStringSelection() == lang.getstr("vcgt")
            )
        if hasattr(self, "install_vcgt_btn"):
            self.install_vcgt_btn.Enable(
                self.plot_mode_select.GetStringSelection() == lang.getstr("vcgt")
                and bool(self.profile)
                and isinstance(self.profile.tags.get("vcgt"), VideoCardGammaType)
            )
            self.install_vcgt_btn.Show(
                self.plot_mode_select.GetStringSelection() == lang.getstr("vcgt")
            )
        if hasattr(self, "save_vcgt_btn"):
            self.save_vcgt_btn.Enable(
                self.plot_mode_select.GetStringSelection() == lang.getstr("vcgt")
                and bool(self.profile)
                and isinstance(self.profile.tags.get("vcgt"), VideoCardGammaType)
            )
            self.save_vcgt_btn.Show(
                self.plot_mode_select.GetStringSelection() == lang.getstr("vcgt")
            )
        if hasattr(self, "show_actual_lut_cb"):
            self.show_actual_lut_cb.Show(
                self.plot_mode_select.GetStringSelection() == lang.getstr("vcgt")
            )
        if hasattr(self, "rendering_intent_select"):
            self.rendering_intent_select.Show(
                self.plot_mode_select.GetStringSelection() == lang.getstr("[rgb]TRC")
                and self.profile.connectionColorSpace != b"RGB"
            )
        if hasattr(self, "direction_select"):
            self.direction_select.Show(
                self.rendering_intent_select.IsShown()
                and "B2A0" in self.profile.tags
                and "A2B0" in self.profile.tags
            )
        self.show_as_L.GetContainingSizer().Layout()
        if hasattr(self, "cbox_sizer"):
            self.cbox_sizer.Layout()
        if hasattr(self, "box_sizer"):
            self.box_sizer.Layout()
        if self.client.last_PointLabel is not None:
            self.client._drawPointLabel(self.client.last_PointLabel)  # erase old
            self.client.last_PointLabel = None
        channels = {}
        for channel, toggle in enumerate(self.toggles):
            channels[channel] = (
                toggle.IsShown() and toggle.GetValue() and toggle.Label
            ) or ""
        wx.CallAfter(
            self.client.DrawLUT,
            curves,
            xLabel=self.xLabel,
            yLabel=self.yLabel,
            channels=channels,
            colorspace=curves_colorspace,
            connection_colorspace=connection_colorspace,
        )
        self.Thaw()

    def OnClose(self, event):
        """Handle the window close event.

        Args:
            event (wx.CloseEvent): The close event.
        """
        self.listening = False
        if self.worker.tempdir and os.path.isdir(self.worker.tempdir):
            self.worker.wrapup(False)
        config.writecfg(
            module="curve-viewer",
            options=("display.number", "position.lut_viewer", "size.lut_viewer"),
        )
        # Hide first (looks nicer)
        self.Hide()
        # Need to use CallAfter to prevent hang under Windows if minimized
        wx.CallAfter(self.Destroy)

    def OnMotion(self, event):
        """Handle mouse motion events.

        Args:
            event (wx.MouseEvent): The mouse motion event.
        """
        if not isinstance(event, wx.MouseEvent):
            return
        if not event.LeftIsDown():
            self.UpdatePointLabel(self.client._getXY(event))
        else:
            self.client.erase_pointlabel()
        event.Skip()  # Go to next handler

    def OnMove(self, event=None):
        """Handle window move events.

        Args:
            event (wx.MoveEvent, optional): The move event. Defaults to None.
        """
        if self.IsShownOnScreen() and not self.IsMaximized() and not self.IsIconized():
            x, y = self.GetScreenPosition()
            setcfg("position.lut_viewer.x", x)
            setcfg("position.lut_viewer.y", y)
        if event:
            event.Skip()

    def OnSize(self, event=None):
        """Handle window size changes.

        Args:
            event (wx.SizeEvent, optional): The size event. Defaults to None.
        """
        if self.IsShownOnScreen() and not self.IsMaximized() and not self.IsIconized():
            w, h = self.ClientSize
            setcfg("size.lut_viewer.w", w)
            setcfg("size.lut_viewer.h", h)
        if event:
            event.Skip()
            if sys.platform == "win32":
                # Needed under Windows when using double buffering
                self.Refresh()

    def OnWheel(self, event):
        """Handle mouse wheel events for zooming.

        Args:
            event (wx.MouseEvent): The mouse wheel event.
        """
        _xy = wx.GetMousePosition()
        if self.client.last_draw:
            direction = 1.0 if event.WheelRotation < 0 else -1.0
            self.client.zoom(direction)

    def SaveFile(self, event=None):
        """Save the file to the type specified in the extension.

        If no file name is specified a dialog box is provided.  Returns True if
        sucessful, otherwise False.

        .bmp  Save a Windows bitmap file.
        .xbm  Save an X bitmap file.
        .xpm  Save an XPM bitmap file.
        .png  Save a Portable Network Graphics file.
        .jpg  Save a Joint Photographic Experts Group file.
        """
        fileName = " ".join(
            [
                self.plot_mode_select.GetStringSelection(),
                os.path.basename(
                    os.path.splitext(self.profile.filename or lang.getstr("unnamed"))[0]
                ),
            ]
        )
        if (
            event
            and hasattr(self, "save_vcgt_btn")
            and event.GetId() == self.save_vcgt_btn.GetId()
        ):
            extensions = {"cal": 1}
            defType = "cal"
        else:
            extensions = {
                "bmp": wx.BITMAP_TYPE_BMP,  # Save a Windows bitmap file.
                "xbm": wx.BITMAP_TYPE_XBM,  # Save an X bitmap file.
                "xpm": wx.BITMAP_TYPE_XPM,  # Save an XPM bitmap file.
                "jpg": wx.BITMAP_TYPE_JPEG,  # Save a JPG file.
                "png": wx.BITMAP_TYPE_PNG,  # Save a PNG file.
            }
            defType = "png"

        fileName = f"{fileName}.{defType}"

        fType = None
        dlg1 = None
        while fType not in extensions:
            if dlg1:  # FileDialog exists: Check for extension
                InfoDialog(
                    self,
                    msg=lang.getstr("error.file_type_unsupported"),
                    ok=lang.getstr("ok"),
                    bitmap=get_icon(32, "dialog-error"),
                )
            else:  # FileDialog doesn't exist: just check one
                dlg1 = wx.FileDialog(
                    self,
                    lang.getstr("save_as"),
                    get_verified_path("last_filedialog_path")[0],
                    fileName,
                    "|".join(
                        [
                            f"{ext.upper()} (*.{ext})|*.{ext}"
                            for ext in sorted(extensions.keys())
                        ]
                    ),
                    wx.SAVE | wx.OVERWRITE_PROMPT,
                )
                dlg1.SetFilterIndex(sorted(extensions.keys()).index(defType))

            if dlg1.ShowModal() == wx.ID_OK:
                fileName = dlg1.GetPath()
                if not waccess(fileName, os.W_OK):
                    show_result_dialog(
                        Error(lang.getstr("error.access_denied.write", fileName)), self
                    )
                    return None
                fType = fileName[-3:].lower()
                setcfg("last_filedialog_path", fileName)
            else:  # exit without saving
                dlg1.Destroy()
                return False

        if dlg1:
            dlg1.Destroy()

        # Save Bitmap
        if (
            event
            and hasattr(self, "save_vcgt_btn")
            and event.GetId() == self.save_vcgt_btn.GetId()
        ):
            res = vcgt_to_cal(self.profile)
            res.write(fileName)
        else:
            res = self.client._Buffer.SaveFile(fileName, extensions.get(fType, ".png"))
        return res

    def SetStatusText(self, text):
        """Set the text for the status label.

        Args:
            text (str): The text to display in the status label.
        """
        self.status.Label = text
        self.status.Refresh()

    def SetGamutStatusText(self, text):
        """Set the text for the gamut status label.

        Args:
            text (str): The text to display in the gamut status label.
        """
        self.gamut_status.Label = text
        self.status.Refresh()

    def UpdatePointLabel(self, xy):
        """Update point label with the closest point information.

        Args:
            xy (tuple): The x and y coordinates of the point to label.
        """
        if not self.client.GetEnablePointLabel():
            return
        # Show closest point (when enbled)
        # Make up dict with info for the point label
        dlst = self.client.GetClosestPoint(xy, pointScaled=True)
        if dlst == [] or not hasattr(self.client, "point_grid"):
            return
        curveNum, legend, pIndex, pointXY, scaledXY, distance = dlst
        legend = legend.split(", ")
        channels = {}
        value = []
        for channel in self.client.point_grid:
            toggle = self.toggles[channel]
            channels[channel] = toggle.Label or ""
            x = round(pointXY[0] / 255.0 * 4095)
            v = self.client.point_grid[channel].get(x, 0)
            if toggle.Label in ("a*", "b*"):
                v = -128 + v / 100.0 * (255 + 255 / 256.0)
            value.append((toggle.Label, v))
        identical = len(value) > 1 and all(v[1] == value[0][1] for v in value)
        if True:
            joiner = " \u2192 "
            label = [_f for _f in list(channels.values()) if _f]
            if (
                self.plot_mode_select.GetStringSelection() == lang.getstr("[rgb]TRC")
                and self.profile.connectionColorSpace != b"RGB"
            ):
                if self.show_as_L.GetValue():
                    format_ = "L* %.2f", "%s %.2f"
                else:
                    format_ = "Y %.4f", "%s %.2f"
                axis_y = 100.0
            elif "L*" in label and ("a*" in label or "b*" in label):
                a = b = -128 + pointXY[0] / 100.0 * (255 + 255 / 256.0)
                if label == ["L*", "a*", "b*"]:
                    format_ = f"L* %.2f a* {a:.2f} b* {b:.2f}", "%s %.2f"
                elif label == ["L*", "a*"]:
                    format_ = f"L* %.2f a* {a:.2f}", "%s %.2f"
                elif label == ["L*", "b*"]:
                    format_ = f"L* %.2f b* {b:.2f}", "%s %.2f"
                axis_y = self.client.axis_y[1]
            else:
                format_ = "{} %.2f".format("=".join(label)), "%s %.2f"
                axis_y = self.client.axis_y[1]
            if identical:
                # if value[0][1] is None:
                vout = pointXY[1]
                if "L*" not in label and ("a*" in label or "b*" in label):
                    vout = -128 + vout / 100.0 * (255 + 255 / 256.0)
                RGB = " ".join(["=".join(label), f"{vout:.2f}"])
            # else:
            # RGB = "R=G=B %.2f" % value[0][1]
            else:
                RGB = " ".join(
                    [
                        format_[1] % (v, s)
                        for v, s in [v for v in value if v[1] is not None]
                    ]
                )
            vin = pointXY[0]
            if "L*" not in label and ("a*" in label or "b*" in label):
                vin = -128 + pointXY[0] / 100.0 * (255 + 255 / 256.0)
            legend[0] = joiner.join([format_[0] % vin, RGB])
            if (
                self.plot_mode_select.GetStringSelection() == lang.getstr("[rgb]TRC")
                and self.profile.connectionColorSpace != b"RGB"
            ):
                pointXY = pointXY[1], pointXY[0]
        else:
            joiner = " \u2192 "
            format_ = "{:.2f}", "{:.2f}"
            axis_y = self.client.axis_y[1]
            legend[0] = "{} {}".format(
                legend[0],
                joiner.join(
                    [format_[i].format(point) for i, point in enumerate(pointXY)]
                ),
            )
        if len(legend) == 1:
            # Calculate gamma
            axis_x = self.client.axis_y[1]
            gamma = []
            for label, v in value:
                if v is None or label in ("a*", "b*"):
                    continue
                if (
                    self.plot_mode_select.GetStringSelection()
                    == lang.getstr("[rgb]TRC")
                    and self.profile.connectionColorSpace != b"RGB"
                ):
                    x = v
                    y = pointXY[1]
                    if self.show_as_L.GetValue():
                        y = colormath.Lab2XYZ(y, 0, 0)[1] * 100
                else:
                    x = pointXY[0]
                    y = v
                if x <= 0 or x >= axis_x or y <= 0 or y >= axis_y:
                    continue
                if identical:
                    label = "=".join(
                        [
                            f"{s}"
                            for s, v in [s_v for s_v in value if s_v[1] is not None]
                        ]
                    )
                # Note: We need to make sure each point is a float because it
                # might be a decimal.Decimal, which can't be divided by floats!
                gamma.append(
                    "{} {:.2f}".format(
                        label,
                        round(
                            math.log(float(y) / axis_y) / math.log(x / axis_x),
                            2,
                        ),
                    )
                )
                if "=" in label:
                    break
            if gamma:
                legend.append("Gamma {}".format(" ".join(gamma)))
        if self.profile.connectionColorSpace != b"RGB":
            self.add_tone_values(legend)
        legend = [", ".join(legend[:-1]), legend[-1]]
        self.SetStatusText("\n".join(legend))
        # Make up dictionary to pass to DrawPointLabel
        mDataDict = {
            "curveNum": curveNum,
            "legend": legend,
            "pIndex": pIndex,
            "pointXY": pointXY,
            "scaledXY": scaledXY,
        }
        # Pass dict to update the point label
        self.client.UpdatePointLabel(mDataDict)

    def update_controls(self):
        """Update the controls based on the current state of the worker."""
        self.show_actual_lut_cb.Enable(
            (
                self.worker.argyll_version[0:3] > [1, 1, 0]
                or (
                    self.worker.argyll_version[0:3] == [1, 1, 0]
                    and "Beta" not in self.worker.argyll_version_string
                )
            )
            and not config.is_untethered_display()
        )
        self.show_actual_lut_cb.SetValue(
            bool(getcfg("lut_viewer.show_actual_lut"))
            and not config.is_untethered_display()
        )

    @property
    def worker(self):
        """Get the worker instance for the LUT viewer.

        Returns:
            Worker: The worker instance.
        """
        return self.client.worker

    def display_changed(self, event):
        """Handle display change events.

        Args:
            event (wx.Event): The display change event.
        """
        self.worker.enumerate_displays_and_ports(
            check_lut_access=False, enumerate_ports=False, include_network_devices=False
        )


def main():
    """Main function to run the LUT viewer application."""
    config.initcfg("curve-viewer")
    # Backup display config
    _cfg_display = getcfg("display.number")
    lang.init()
    lang.update_defaults()
    app = BaseApp(0)
    app.TopWindow = LUTFrame(None, -1)
    app.TopWindow.Bind(wx.EVT_CLOSE, app.TopWindow.OnClose, app.TopWindow)
    if sys.platform == "darwin":
        app.TopWindow.init_menubar()
    wx.CallLater(1, _main, app)
    app.MainLoop()


def _main(app):
    app.TopWindow.listen()
    app.TopWindow.display_changed(None)
    app.TopWindow.Bind(wx.EVT_DISPLAY_CHANGED, app.TopWindow.display_changed)
    app.TopWindow.display_no, geometry, client_area = app.TopWindow.get_display()
    app.TopWindow.Bind(wx.EVT_MOVE, app.TopWindow.move_handler, app.TopWindow)
    display_no = get_argyll_display_number(geometry)
    if display_no is not None:
        setcfg("display.number", display_no + 1)
    app.TopWindow.update_controls()
    for arg in sys.argv[1:]:
        if os.path.isfile(arg):
            app.TopWindow.drop_handler(os.path.abspath(arg))
            break
    else:
        app.TopWindow.load_lut(get_display_profile(display_no))
    app.TopWindow.Show()


if __name__ == "__main__":
    main()
