"""Set ICC profiles and load calibration curves for all configured display devices."""

# Standard Library Imports
from __future__ import annotations

import contextlib
import ctypes
import math
import os
import re
import subprocess as sp
import sys
import threading
import time
import traceback
import warnings
from typing import TYPE_CHECKING, Any, Callable

# Local Imports
from DisplayCAL import (
    config,
    madvr,
)
from DisplayCAL.colord import Device, device_id_from_edid
from DisplayCAL.colormath import smooth_avg
from DisplayCAL.config import (
    APPBASENAME,
    AUTOSTART,
    AUTOSTART_HOME,
    CONFIG_HOME,
    ENC,
    EXE,
    EXEDIR,
    ICCPROFILES,
    PYDIR,
    get_data_path,
    get_default_dpi,
    get_icon,
    get_icon_bundle,
    getcfg,
    setcfg,
)
from DisplayCAL.debughelpers import Error, UnloggedError, handle_error
from DisplayCAL.edid import get_edid
from DisplayCAL.icc_profile import (
    ADict,
    DictType,
    ICCProfile,
    ICCProfileInvalidError,
    VideoCardGammaFormulaType,
    VideoCardGammaType,
    WcsProfilesTagType,
    get_display_profile,
    set_display_profile,
    unset_display_profile,
)
from DisplayCAL.meta import (
    DOMAIN,
    VERSION_STRING,
)
from DisplayCAL.meta import (
    NAME as APPNAME,
)
from DisplayCAL.options import DEBUG, VERBOSE
from DisplayCAL.util_list import natsort_key_factory
from DisplayCAL.util_os import (
    getenvu,
    is_superuser,
    islink,
    readlink,
    safe_glob,
    which,
)
from DisplayCAL.util_str import safe_asciize
from DisplayCAL.wx_addons import CustomGridCellEvent
from DisplayCAL.wx_fixes import ThemedGenButton
from DisplayCAL.wx_windows import (
    BaseApp,
    BaseFrame,
    ConfirmDialog,
    CustomCellBoolRenderer,
    CustomGrid,
    InfoDialog,
    TaskBarNotification,
    get_dialogs,
    show_result_dialog,
    wx,
)

if sys.platform == "win32":
    import errno
    import winreg

    import pywintypes
    import win32api
    import win32gui
    import win32process
    import win32ts
    import winerror

    from DisplayCAL.icc_profile import _winreg_get_display_profiles
    from DisplayCAL.systrayicon import Menu, MenuItem, SysTrayIcon
    from DisplayCAL.util_win import (
        DISPLAY_DEVICE_ACTIVE,
        MONITORINFOF_PRIMARY,
        USE_REGISTRY,
        calibration_management_isenabled,
        enable_per_user_profiles,
        get_active_display_device,
        get_active_display_devices,
        get_display_devices,
        get_file_info,
        get_first_display_device,
        get_process_filename,
        get_real_display_devices_info,
        per_user_profiles_isenabled,
        run_as_admin,
        win_ver,
    )

    if islink(EXE):
        try:
            EXE = readlink(EXE)
        except Exception:
            pass
        else:
            EXEDIR = os.path.dirname(EXE)
else:
    SysTrayIcon = object  # type: ignore[misc]

    def calibration_management_isenabled() -> bool:
        """Stub for non-Windows platforms."""
        return False


if TYPE_CHECKING:
    from _win32typing import PyDISPLAY_DEVICE


def debug_print(*args, **kwargs) -> None:
    """Print debug messages."""
    if DEBUG or VERBOSE > 1:
        print(*args, **kwargs)


def setup_profile_loader_task(exe: str, exedir: str, pydir: str) -> None:
    """Setup profile loader task for Windows.

    Args:
        exe (str): Path to the executable.
        exedir (str): Directory of the executable.
        pydir (str): Directory of the Python installation.
    """
    print("setup_profile_loader_task start")
    if sys.getwindowsversion() < (6,):
        print("setup_profile_loader_task end 1")
        return

    from DisplayCAL import taskscheduler

    taskname = f"{APPNAME} Profile Loader Launcher"

    try:
        ts = taskscheduler.TaskScheduler()
    except Exception as exception:
        print("Warning - could not access task scheduler:", exception)
        print("setup_profile_loader_task end 2")
        return

    if "--task" not in sys.argv[1:] and is_superuser() and not ts.query_task(taskname):
        # Check if our task exists, and if it does not, create it.
        # (requires admin privileges)
        print(f"Trying to create task {taskname}...")
        # Note that we use a stub so the task cannot be accidentally
        # stopped (the stub launches the actual profile loader and
        # then immediately exits)
        loader_args = []
        if os.path.basename(exe).lower() in ("python.exe", "pythonw.exe"):
            cmd = os.path.join(exedir, "pythonw.exe")
            pyw = os.path.normpath(
                os.path.join(pydir, "..", f"{APPNAME}-apply-profiles.pyw")
            )
            script = get_data_path(
                "/".join(["scripts", f"{APPNAME}-apply-profiles-launcher"])
            )
            if os.path.exists(pyw):
                # Running from source or 0install
                # Check if this is a 0install implementation, in which
                # case we want to call 0launch with the appropriate
                # command
                if re.match(
                    r"sha\d+(?:new)?",
                    os.path.basename(os.path.dirname(pydir)),
                ):
                    # No stub needed as 0install-win acts as stub
                    cmd = which("0install-win.exe") or "0install-win.exe"
                    loader_args.extend(
                        [
                            "run",
                            "--batch",
                            "--no-wait",
                            "--offline",
                            "--command=run-apply-profiles",
                            "--",
                            f"http://{DOMAIN}/0install/{APPNAME}.xml",
                            "--task",
                        ]
                    )
                else:
                    # Running from source
                    loader_args.append(script)
            else:
                # Regular (site-packages) install
                loader_args.append(script)
        else:
            # Standalone executable
            cmd = os.path.join(pydir, f"{APPNAME}-apply-profiles-launcher.exe")
        # Start at login, restart when resuming from sleep,
        # restart daily at 04:00
        triggers = [
            taskscheduler.LogonTrigger(),
            taskscheduler.ResumeFromSleepTrigger(),
        ]
        daily = taskscheduler.CalendarTrigger(
            start_boundary=time.strftime("%Y-%m-%dT04:00:00"),
            days_interval=1,
        )
        actions = [taskscheduler.ExecAction(cmd, loader_args)]
        try:
            # Create the main task
            created = ts.create_task(
                taskname,
                "Open Source Developer, Florian Höch",
                "This task launches the profile "
                "loader with the applicable "
                "privileges for logged in users",
                multiple_instances_policy=taskscheduler.MULTIPLEINSTANCES_IGNORENEW,
                replace_existing=True,
                triggers=triggers,
                actions=actions,
            )
            # Create the supplementary task
            created = ts.create_task(
                f"{taskname} - Daily Restart",
                "Open Source Developer, Florian Höch",
                "This task restarts the profile "
                "loader with the applicable "
                "privileges for logged in users",
                multiple_instances_policy=taskscheduler.MULTIPLEINSTANCES_IGNORENEW,
                replace_existing=True,
                triggers=[daily],
                actions=actions,
            )
        except Exception as exception:
            if DEBUG:
                exception = traceback.format_exc()
            print(
                f"Warning - Could not create task {taskname}:",
                exception,
            )
            if ts.stdout:
                print(str(ts.stdout, encoding=ENC, errors="replace"))
        else:
            print(str(ts.stdout, encoding=ENC, errors="replace"))
            if created:
                # Remove autostart entries, if any
                name = f"{APPNAME} Profile Loader"
                entries = []
                if AUTOSTART:
                    entries.append(os.path.join(AUTOSTART, f"{name}.lnk"))
                if AUTOSTART_HOME:
                    entries.append(os.path.join(AUTOSTART_HOME, f"{name}.lnk"))
                for entry in entries:
                    if os.path.isfile(entry):
                        print("Removing", entry)
                        try:
                            os.remove(entry)
                        except OSError as exception:
                            print(exception)

    if "Windows 10" not in win_ver()[0]:
        print("setup_profile_loader_task end 3")
        return

    # Disable Windows Calibration Loader.
    # This is absolutely REQUIRED under Win10 1903 to prevent
    # banding and not applying calibration twice
    ms_cal_loader = r"\Microsoft\Windows\WindowsColorSystem\Calibration Loader"
    try:
        ts.disable(ms_cal_loader)
    except Exception as exception:
        print(
            f"Warning - Could not disable task {ms_cal_loader!r}:",
            exception,
        )
        if ts.stdout:
            print(str(ts.stdout, encoding=ENC, errors="replace"))
    else:
        print(str(ts.stdout, encoding=ENC, errors="replace"))
    print("setup_profile_loader_task end 4")


class DisplayIdentificationFrame(wx.Frame):
    """Frame to show display identification.

    Args:
        display (str): The display identifier, typically in the format
            "DISPLAY_NAME@EDID - [DESCRIPTION]".
        pos (tuple): The position of the frame on the screen.
        size (tuple): The size of the frame, typically in pixels.
    """

    def __init__(self, display: str, pos: tuple, size: tuple) -> None:
        wx.Frame.__init__(
            self,
            None,
            pos=pos,
            size=size,
            style=wx.CLIP_CHILDREN
            | wx.STAY_ON_TOP
            | wx.FRAME_NO_TASKBAR
            | wx.NO_BORDER,
            name="DisplayIdentification",
        )
        self.SetTransparent(240)
        self.Sizer = wx.BoxSizer()
        panel_outer = wx.Panel(self)
        panel_outer.BackgroundColour = "#303030"
        panel_outer.Sizer = wx.BoxSizer()
        self.Sizer.Add(panel_outer, 1, flag=wx.EXPAND)
        panel_inner = wx.Panel(panel_outer)
        panel_inner.BackgroundColour = "#0078d7"
        panel_inner.Sizer = wx.BoxSizer()
        panel_outer.Sizer.Add(
            panel_inner,
            1,
            flag=wx.ALL | wx.EXPAND,
            border=math.ceil(size[0] / 12.0 / 40),
        )
        display_parts = display.split("@", 1)
        if len(display_parts) > 1:
            info = display_parts[1].split(" - ", 1)
            display_parts[1] = "@" + " ".join(info[:1])
            if info[1:]:
                display_parts.append(" ".join(info[1:]))
        label = "\n".join(display_parts)
        text = wx.StaticText(panel_inner, -1, label, style=wx.ALIGN_CENTER)
        text.ForegroundColour = "#FFFFFF"
        font = wx.Font(
            int(text.Font.PointSize * size[0] / 12.0 / 16),
            wx.FONTFAMILY_DEFAULT,
            wx.FONTSTYLE_NORMAL,
            wx.FONTWEIGHT_LIGHT,
        )
        if not font.SetFaceName("Segoe UI Light"):
            font = text.Font
            font.PointSize *= size[0] / 12.0 / 16
            font.weight = wx.FONTWEIGHT_LIGHT
        text.Font = font
        panel_inner.Sizer.Add(text, 1, flag=wx.ALIGN_CENTER_VERTICAL)
        for element in (self, panel_outer, panel_inner, text):
            element.Bind(wx.EVT_LEFT_UP, lambda e: self.Close())
            element.Bind(wx.EVT_MIDDLE_UP, lambda e: self.Close())
            element.Bind(wx.EVT_RIGHT_UP, lambda e: self.Close())
        self.Bind(
            wx.EVT_CHAR_HOOK, lambda e: e.KeyCode == wx.WXK_ESCAPE and self.Close()
        )
        self.Layout()
        self.Show()
        self.close_timer = wx.CallLater(3000, lambda: self and self.Close())


class FixProfileAssociationsDialog(ConfirmDialog):
    """Dialog to show profile associations for displays.

    Args:
        pl (ProfileLoader): The profile loader instance.
        parent (None | wx.Window, optional): The parent window for the dialog.
            Defaults to None.
    """

    def __init__(self, pl: ProfileLoader, parent: None | wx.Window = None) -> None:
        self.pl = pl
        ConfirmDialog.__init__(
            self,
            parent,
            msg=lang.getstr("profile_loader.fix_profile_associations_warning"),
            title=pl.get_title(),
            ok=lang.getstr("profile_loader.fix_profile_associations"),
            bitmap=get_icon(32, "dialog-warning"),
            wrap=128,
        )
        dlg = self
        dlg.SetIcons(get_icon_bundle([256, 48, 32, 16], APPNAME + "-apply-profiles"))
        scale = getcfg("app.dpi") / get_default_dpi()
        scale = max(scale, 1)
        list_panel = wx.Panel(dlg, -1)
        list_panel.BackgroundColour = wx.SystemSettings.GetColour(wx.SYS_COLOUR_3DLIGHT)
        list_panel.Sizer = wx.BoxSizer(wx.HORIZONTAL)
        list_ctrl = wx.ListCtrl(
            list_panel,
            -1,
            style=wx.LC_REPORT | wx.LC_SINGLE_SEL | wx.BORDER_THEME,
            name="displays2profiles",
        )
        list_panel.Sizer.Add(list_ctrl, 1, flag=wx.ALL, border=1)
        list_ctrl.InsertColumn(0, lang.getstr("display"))
        list_ctrl.InsertColumn(1, lang.getstr("profile"))
        list_ctrl.SetColumnWidth(0, int(200 * scale))
        list_ctrl.SetColumnWidth(1, int(420 * scale))
        # Ignore item focus/selection
        list_ctrl.Bind(
            wx.EVT_LIST_ITEM_FOCUSED,
            lambda e: list_ctrl.SetItemState(e.GetIndex(), 0, wx.LIST_STATE_FOCUSED),
        )
        list_ctrl.Bind(
            wx.EVT_LIST_ITEM_SELECTED,
            lambda e: list_ctrl.SetItemState(e.GetIndex(), 0, wx.LIST_STATE_SELECTED),
        )
        self.devices2profiles_ctrl = list_ctrl
        dlg.sizer3.Insert(0, list_panel, 1, flag=wx.BOTTOM | wx.ALIGN_LEFT, border=12)
        self.update()

    def update(self, event: None | wx.Event = None) -> None:
        """Update the list of displays and profiles.

        Args:
            event (wx.Event, optional): The event that triggered this update.
                Defaults to None.
        """
        self.pl._set_display_profiles(dry_run=True)
        numdisp = min(len(self.pl.devices2profiles), 5)
        scale = getcfg("app.dpi") / get_default_dpi()
        scale = max(scale, 1)
        hscroll = wx.SystemSettings_GetMetric(wx.SYS_HSCROLL_Y)
        size = (640 * scale, (20 * numdisp + 25 + hscroll) * scale)
        list_ctrl = self.devices2profiles_ctrl
        list_ctrl.MinSize = size
        list_ctrl.DeleteAllItems()
        for i, (display_edid, profile, desc) in enumerate(
            self.pl.devices2profiles.values()
        ):
            index = list_ctrl.InsertStringItem(i, "")
            display = display_edid[0].replace(
                "[PRIMARY]", lang.getstr("display.primary")
            )
            list_ctrl.SetStringItem(index, 0, display)
            list_ctrl.SetStringItem(index, 1, desc)
            if not profile:
                continue
            try:
                profile = ICCProfile(profile)
            except (OSError, ICCProfileInvalidError):
                pass
            else:
                if isinstance(profile.tags.get("meta"), DictType):
                    # Check if profile mapping makes sense
                    id1 = device_id_from_edid(display_edid[1], quirk=True)
                    id2 = device_id_from_edid(display_edid[1], quirk=False)
                    if profile.tags.meta.getvalue("MAPPING_device_id") not in (
                        id1,
                        id2,
                    ):
                        list_ctrl.SetItemTextColour(index, "#FF8000")
        self.sizer0.SetSizeHints(self)
        self.sizer0.Layout()
        if event and not self.IsActive():
            self.RequestUserAttention()


class ProfileLoaderExceptionsDialog(ConfirmDialog):
    """Dialog to manage exceptions for the profile loader.

    Args:
        exceptions (dict): A dictionary of exceptions to manage.
        known_apps (None | set, optional): A set of known application names to
            prevent from being added as exceptions. Defaults to None.
    """

    def __init__(self, exceptions: dict, known_apps: None | set = None) -> None:
        if known_apps is None:
            known_apps = set()
        self._exceptions = {}
        self.known_apps = known_apps
        scale = getcfg("app.dpi") / config.get_default_dpi()
        scale = max(scale, 1)
        ConfirmDialog.__init__(
            self,
            None,
            title=lang.getstr("exceptions"),
            ok=lang.getstr("ok"),
            cancel=lang.getstr("cancel"),
            wrap=120,
        )

        dlg = self

        dlg.SetIcons(
            config.get_icon_bundle([256, 48, 32, 16], APPNAME + "-apply-profiles")
        )

        dlg.delete_btn = wx.Button(dlg.buttonpanel, -1, lang.getstr("delete"))
        dlg.sizer2.Insert(0, (12, 12))
        dlg.sizer2.Insert(0, dlg.delete_btn)
        dlg.delete_btn.Bind(wx.EVT_BUTTON, dlg.delete_handler)

        dlg.browse_btn = wx.Button(dlg.buttonpanel, -1, lang.getstr("browse"))
        dlg.sizer2.Insert(0, (12, 12))
        dlg.sizer2.Insert(0, dlg.browse_btn)
        dlg.browse_btn.Bind(wx.EVT_BUTTON, dlg.browse_handler)

        dlg.add_btn = wx.Button(dlg.buttonpanel, -1, lang.getstr("add"))
        dlg.sizer2.Insert(0, (12, 12))
        dlg.sizer2.Insert(0, dlg.add_btn)
        dlg.add_btn.Bind(wx.EVT_BUTTON, dlg.browse_handler)

        style = wx.BORDER_SIMPLE if "gtk3" in wx.PlatformInfo else wx.BORDER_THEME
        dlg.grid = CustomGrid(
            dlg, -1, size=wx.Size(int(648 * scale), int(200 * scale)), style=style
        )
        grid = dlg.grid
        grid.DisableDragRowSize()
        grid.SetCellHighlightPenWidth(0)
        grid.SetCellHighlightROPenWidth(0)
        grid.SetDefaultCellAlignment(wx.ALIGN_LEFT, wx.ALIGN_CENTER)
        grid.SetMargins(0, 0)
        grid.SetRowLabelAlignment(wx.ALIGN_RIGHT, wx.ALIGN_CENTER)
        grid.SetScrollRate(5, 5)
        grid.draw_horizontal_grid_lines = False
        grid.draw_vertical_grid_lines = False
        grid.CreateGrid(0, 4)
        grid.SetSelectionMode(wx.grid.Grid.wxGridSelectRows)
        font = grid.GetDefaultCellFont()
        if font.PointSize > 11:
            font.PointSize = 11
            grid.SetDefaultCellFont(font)
        grid.SetColLabelSize(round(self.grid.GetDefaultRowSize() * 1.4))
        dc = wx.MemoryDC(wx.EmptyBitmap(1, 1))
        dc.SetFont(grid.GetLabelFont())
        grid.SetRowLabelSize(max(dc.GetTextExtent("99")[0], grid.GetDefaultRowSize()))
        for i in range(grid.GetNumberCols()):
            if i > 1:
                attr = wx.grid.GridCellAttr()
                attr.SetReadOnly(True)
                grid.SetColAttr(i, attr)
            if i == 0:
                # On/off checkbox
                size = 22 * scale
            elif i == 1:
                # Profile loader state icon
                size = 22 * scale
            elif i == 2:
                # Executable basename
                size = dc.GetTextExtent("W" * 12)[0]
            else:
                # Directory component
                size = dc.GetTextExtent("W" * 34)[0]
            grid.SetColSize(i, int(size))
        for i, label in enumerate(["", "", "executable", "directory"]):
            grid.SetColLabelValue(i, lang.getstr(label))

        # On/off checkbox
        attr = wx.grid.GridCellAttr()
        renderer = CustomCellBoolRenderer()
        renderer._bitmap_unchecked = config.get_icon(16, "empty")
        attr.SetRenderer(renderer)
        grid.SetColAttr(0, attr)

        # Profile loader state icon
        attr = wx.grid.GridCellAttr()
        renderer = CustomCellBoolRenderer()
        renderer._bitmap = config.get_icon(16, "apply-profiles-reset")
        bitmap = renderer._bitmap
        image = bitmap.ConvertToImage().ConvertToGreyscale(0.75, 0.125, 0.125)
        renderer._bitmap_unchecked = image.ConvertToBitmap()
        attr.SetRenderer(renderer)
        grid.SetColAttr(1, attr)

        attr = wx.grid.GridCellAttr()
        attr.SetRenderer(wx.grid.GridCellStringRenderer())
        grid.SetColAttr(2, attr)

        attr = wx.grid.GridCellAttr()
        attr.SetRenderer(wx.grid.GridCellStringRenderer())
        grid.SetColAttr(3, attr)

        grid.EnableGridLines(False)

        grid.BeginBatch()
        for i, (key, (enabled, reset, path)) in enumerate(sorted(exceptions.items())):
            grid.AppendRows(1)
            grid.SetRowLabelValue(i, f"{i + 1:d}")
            grid.SetCellValue(i, 0, "1" if enabled else "")
            grid.SetCellValue(i, 1, "1" if reset else "")
            grid.SetCellValue(i, 2, os.path.basename(path))
            grid.SetCellValue(i, 3, os.path.dirname(path))
            self._exceptions[key] = enabled, reset, path
        grid.EndBatch()

        grid.Bind(wx.EVT_KEY_DOWN, dlg.key_handler)
        grid.Bind(wx.grid.EVT_GRID_CELL_LEFT_CLICK, dlg.cell_click_handler)
        grid.Bind(wx.grid.EVT_GRID_CELL_LEFT_DCLICK, dlg.cell_dclick_handler)
        grid.Bind(wx.grid.EVT_GRID_SELECT_CELL, dlg.cell_select_handler)

        dlg.sizer3.Add(grid, 1, flag=wx.LEFT | wx.ALIGN_LEFT, border=12)

        # Legend
        sizer = wx.FlexGridSizer(2, 2, 3, 1)
        sizer.Add(wx.StaticBitmap(dlg, -1, renderer._bitmap_unchecked))
        sizer.Add(wx.StaticText(dlg, -1, " = " + lang.getstr("profile_loader.disable")))
        sizer.Add(wx.StaticBitmap(dlg, -1, renderer._bitmap))
        sizer.Add(wx.StaticText(dlg, -1, " = " + lang.getstr("calibration.reset")))

        dlg.sizer3.Add(sizer, 1, flag=wx.LEFT | wx.TOP | wx.ALIGN_LEFT, border=12)

        dlg.buttonpanel.Layout()
        dlg.sizer0.SetSizeHints(dlg)
        dlg.sizer0.Layout()

        # This workaround is needed to update cell colours
        grid.SelectAll()
        grid.ClearSelection()

        self.check_select_status()
        dlg.ok.Disable()

        dlg.Center()

    def _get_path(self, row: int) -> str:
        """Get the full path of the exception at the specified row.

        Args:
            row (int): The row index of the exception.

        Returns:
            str: The full path of the exception.
        """
        return os.path.join(
            self.grid.GetCellValue(row, 3), self.grid.GetCellValue(row, 2)
        )

    def _update_exception(self, row: int) -> None:
        """Update the exception at the specified row.

        Args:
            row (int): The row index of the exception to update.
        """
        path = self._get_path(row)
        enabled = int(self.grid.GetCellValue(row, 0) or 0)
        reset = int(self.grid.GetCellValue(row, 1) or 0)
        self._exceptions[path.lower()] = enabled, reset, path

    def cell_click_handler(self, event: wx.Event) -> None:
        """Handle click events on the grid cells.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        if event.Col < 2:
            value = "" if self.grid.GetCellValue(event.Row, event.Col) else "1"
            self.grid.SetCellValue(event.Row, event.Col, value)
            self._update_exception(event.Row)
            self.ok.Enable()
        event.Skip()

    def cell_dclick_handler(self, event: wx.Event) -> None:
        """Handle double-click events on the grid cells.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        if event.Col > 1:
            self.browse_handler(event)
        else:
            self.cell_click_handler(event)

    def cell_select_handler(self, event: wx.Event) -> None:
        """Handle cell selection events."""
        event.Skip()
        wx.CallAfter(self.check_select_status)

    def check_select_status(self) -> None:
        """Check the selection status of the grid and enable/disable buttons."""
        rows = self.grid.GetSelectedRows()
        self.browse_btn.Enable(len(rows) == 1)
        self.delete_btn.Enable(bool(rows))

    def key_handler(self, event: wx.Event) -> None:
        """Handle key events for the grid.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        dlg = self
        if event.KeyCode == wx.WXK_SPACE:
            dlg.cell_click_handler(
                CustomGridCellEvent(
                    wx.grid.EVT_GRID_CELL_CHANGE.evtType[0],
                    dlg.grid,
                    dlg.grid.GridCursorRow,
                    dlg.grid.GridCursorCol,
                )
            )
        elif event.KeyCode in (wx.WXK_BACK, wx.WXK_DELETE):
            self.delete_handler(None)
        else:
            event.Skip()

    def browse_handler(self, event: wx.Event) -> None:
        """Browse for an executable to add or edit an exception.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        if event.GetId() == self.add_btn.Id:
            lstr = "add"
            default_dir = getenvu("ProgramW6432") or getenvu("ProgramFiles")
            default_file = ""
        else:
            lstr = "browse"
            row = self.grid.GetSelectedRows()[0]
            default_dir = self.grid.GetCellValue(row, 3)
            default_file = self.grid.GetCellValue(row, 2)
        dlg = wx.FileDialog(
            self,
            lang.getstr(lstr),
            defaultDir=default_dir,
            defaultFile=default_file,
            wildcard="*.exe",
            style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST,
        )
        dlg.Center(wx.BOTH)
        result = dlg.ShowModal()
        path = dlg.GetPath()
        dlg.Destroy()
        if result != wx.ID_OK:
            return
        if os.path.basename(path).lower() in self.known_apps:
            show_result_dialog(
                UnloggedError(
                    lang.getstr(
                        "profile_loader.exceptions.known_app.error",
                        os.path.basename(path),
                    )
                )
            )
            return
        if event.GetId() == self.add_btn.Id:
            # If it already exists, select the respective row
            if path.lower() in self._exceptions:
                for row in range(self.grid.GetNumberRows()):
                    exception = os.path.join(
                        self.grid.GetCellValue(row, 3),
                        self.grid.GetCellValue(row, 2),
                    )
                    if exception.lower() == path.lower():
                        break
            else:
                # Add new row
                self.grid.AppendRows(1)
                row = self.grid.GetNumberRows() - 1
                self.grid.SetCellValue(row, 0, "1")
                self.grid.SetCellValue(row, 1, "")
                self._exceptions[path.lower()] = 1, 0, path
        self.grid.SetCellValue(row, 2, os.path.basename(path))
        self.grid.SetCellValue(row, 3, os.path.dirname(path))
        self._update_exception(row)
        self.grid.SelectRow(row)
        self.check_select_status()
        self.grid.MakeCellVisible(row, 0)
        self.ok.Enable()

    def delete_handler(self, event: wx.Event) -> None:
        """Delete selected exceptions.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        for row in sorted(self.grid.GetSelectedRows(), reverse=True):
            del self._exceptions[self._get_path(row).lower()]
            self.grid.DeleteRows(row)
        self.check_select_status()
        self.ok.Enable()


class ProfileAssociationsDialog(InfoDialog):
    """Dialog to manage profile associations with displays.

    Args:
        pl (ProfileLoader): The profile loader instance to manage associations
            for.
    """

    def __init__(self, pl: ProfileLoader) -> None:
        self.monitors = []
        self.pl = pl
        self.profile_info = {}
        self.profiles = []
        self.current_user = False
        self.display_identification_frames = {}
        InfoDialog.__init__(
            self,
            None,
            msg="",
            title=lang.getstr("profile_associations"),
            ok=lang.getstr("close"),
            bitmap=get_icon(32, "display"),
            show=False,
            log=False,
            wrap=128,
        )
        dlg = self
        dlg.SetIcons(get_icon_bundle([256, 48, 32, 16], APPNAME + "-apply-profiles"))
        dlg.message.Hide()
        dlg.set_as_default_btn = wx.Button(
            dlg.buttonpanel, -1, lang.getstr("set_as_default")
        )
        dlg.sizer2.Insert(1, dlg.set_as_default_btn, flag=wx.RIGHT, border=12)
        dlg.set_as_default_btn.Bind(wx.EVT_BUTTON, dlg.set_as_default)
        dlg.set_as_default_btn.Disable()
        dlg.profile_info_btn = wx.Button(
            dlg.buttonpanel, -1, lang.getstr("profile.info")
        )
        dlg.sizer2.Insert(
            0,
            dlg.profile_info_btn,
            flag=wx.RIGHT | wx.ALIGN_CENTER_VERTICAL,
            border=12,
        )
        dlg.profile_info_btn.Bind(wx.EVT_BUTTON, dlg.show_profile_info)
        dlg.profile_info_btn.Disable()
        dlg.remove_btn = wx.Button(dlg.buttonpanel, -1, lang.getstr("remove"))
        dlg.sizer2.Insert(0, dlg.remove_btn, flag=wx.RIGHT | wx.LEFT, border=12)
        dlg.remove_btn.Bind(wx.EVT_BUTTON, dlg.remove_profile)
        dlg.remove_btn.Disable()
        dlg.add_btn = wx.Button(dlg.buttonpanel, -1, lang.getstr("add"))
        dlg.sizer2.Insert(0, dlg.add_btn, flag=wx.LEFT, border=32 + 12)
        dlg.add_btn.Bind(wx.EVT_BUTTON, dlg.add_profile)
        dlg.add_btn.Disable()
        scale = getcfg("app.dpi") / get_default_dpi()
        scale = max(scale, 1)
        dlg.display_ctrl = wx.Choice(dlg, -1)
        dlg.display_ctrl.Bind(wx.EVT_CHOICE, dlg.update_profiles)
        hsizer = wx.BoxSizer(wx.HORIZONTAL)
        dlg.sizer3.Add(hsizer, 1, flag=wx.ALIGN_LEFT | wx.EXPAND | wx.TOP, border=5)
        hsizer.Add(dlg.display_ctrl, 1, wx.ALIGN_CENTER_VERTICAL)
        dlg.display_ctrl.Disable()
        dlg.identify_btn = ThemedGenButton(dlg, -1, lang.getstr("displays.identify"))
        dlg.identify_btn.MinSize = -1, dlg.display_ctrl.Size[1] + 2
        dlg.identify_btn.Bind(wx.EVT_BUTTON, dlg.identify_displays)
        hsizer.Add(dlg.identify_btn, flag=wx.ALIGN_CENTER_VERTICAL | wx.LEFT, border=8)
        dlg.identify_btn.Disable()
        if sys.getwindowsversion() >= (6,):
            hsizer = wx.BoxSizer(wx.HORIZONTAL)
            dlg.sizer3.Add(hsizer, flag=wx.ALIGN_LEFT | wx.EXPAND)
            dlg.use_my_settings_cb = wx.CheckBox(
                dlg, -1, lang.getstr("profile_associations.use_my_settings")
            )
            dlg.use_my_settings_cb.Bind(wx.EVT_CHECKBOX, self.use_my_settings)
            hsizer.Add(
                dlg.use_my_settings_cb,
                flag=wx.TOP | wx.BOTTOM | wx.ALIGN_LEFT | wx.ALIGN_CENTER_VERTICAL,
                border=12,
            )
            self.use_my_settings_cb.Disable()
            dlg.warn_bmp = wx.StaticBitmap(dlg, -1, get_icon(16, "dialog-warning"))
            dlg.warning = wx.StaticText(
                dlg,
                -1,
                lang.getstr("profile_associations.changing_system_defaults.warning"),
            )
            warnsizer = wx.BoxSizer(wx.HORIZONTAL)
            hsizer.Add(warnsizer, 0, wx.LEFT | wx.ALIGN_CENTER_VERTICAL, border=12)
            warnsizer.Add(dlg.warn_bmp, 0, wx.ALIGN_CENTER_VERTICAL)
            warnsizer.Add(dlg.warning, 0, wx.ALIGN_CENTER_VERTICAL | wx.LEFT, border=4)
            self.warn_bmp.Hide()
            self.warning.Hide()
        else:
            dlg.sizer3.Add((1, 12))
        list_panel = wx.Panel(dlg, -1)
        list_panel.BackgroundColour = wx.SystemSettings.GetColour(wx.SYS_COLOUR_3DLIGHT)
        list_panel.Sizer = wx.BoxSizer(wx.HORIZONTAL)
        hscroll = wx.SystemSettings_GetMetric(wx.SYS_HSCROLL_Y)
        numrows = 10
        list_ctrl = wx.ListCtrl(
            list_panel,
            -1,
            size=(640 * scale, (20 * numrows + 25 + hscroll) * scale),
            style=wx.LC_REPORT | wx.LC_SINGLE_SEL | wx.BORDER_THEME,
            name="displays2profiles",
        )
        list_panel.Sizer.Add(list_ctrl, 1, flag=wx.ALL, border=1)
        list_ctrl.InsertColumn(0, lang.getstr("description"))
        list_ctrl.InsertColumn(1, lang.getstr("filename"))
        list_ctrl.SetColumnWidth(0, int(430 * scale))
        list_ctrl.SetColumnWidth(1, int(210 * scale))
        list_ctrl.Bind(
            wx.EVT_LIST_ITEM_SELECTED,
            lambda e: (
                dlg.remove_btn.Enable(),
                dlg.set_as_default_btn.Enable(e.GetIndex() > 0),
                dlg.profile_info_btn.Enable(),
            ),
        )
        list_ctrl.Bind(
            wx.EVT_LIST_ITEM_DESELECTED,
            lambda e: (
                dlg.remove_btn.Disable(),
                dlg.set_as_default_btn.Disable(),
                dlg.profile_info_btn.Disable(),
            ),
        )
        list_ctrl.Bind(wx.EVT_LIST_ITEM_ACTIVATED, dlg.set_as_default)
        dlg.sizer3.Add(list_panel, flag=wx.BOTTOM | wx.ALIGN_LEFT, border=12)
        dlg.profiles_ctrl = list_ctrl
        dlg.fix_profile_associations_cb = wx.CheckBox(
            dlg, -1, lang.getstr("profile_loader.fix_profile_associations")
        )
        dlg.fix_profile_associations_cb.Bind(
            wx.EVT_CHECKBOX, self.toggle_fix_profile_associations
        )
        dlg.sizer3.Add(dlg.fix_profile_associations_cb, flag=wx.ALIGN_LEFT)
        dlg.disable_btns()
        dlg.update()
        dlg.sizer0.SetSizeHints(dlg)
        dlg.sizer0.Layout()
        dlg.ok.SetDefault()
        dlg.update_profiles_timer = wx.Timer(dlg)
        dlg.Bind(wx.EVT_TIMER, dlg.update_profiles, dlg.update_profiles_timer)
        dlg.update_profiles_timer.Start(1000)

    def OnClose(self, event: wx.Event) -> None:  # noqa: N802
        """Handle the close event for the dialog.

        Args:
            event (wx.Event): The event that triggered this method.
        """
        InfoDialog.OnClose(self, event)

    def EndModal(self, retCode: int) -> None:  # noqa: N802, N803
        """End the modal dialog and stop the update timer.

        Args:
            retCode (int): The return code for the dialog.
        """
        self.update_profiles_timer.Stop()
        wx.Dialog.EndModal(self, retCode)

    def add_profile(self, event: wx.Event) -> None:
        """Add a profile to the selected display.

        Args:
            event (wx.Event): The event that triggered this method.
        """
        if self.add_btn.GetAuthNeeded():
            if self.pl.elevate():
                self.EndModal(wx.ID_CANCEL)
            return
        dlg = ConfirmDialog(
            self,
            msg=lang.getstr("profile.choose"),
            title=lang.getstr("add"),
            ok=lang.getstr("ok"),
            cancel=lang.getstr("cancel"),
            bitmap=get_icon(32, APPNAME + "-profile-info"),
            wrap=128,
        )
        dlg.SetIcons(get_icon_bundle([256, 48, 32, 16], APPNAME + "-apply-profiles"))
        scale = getcfg("app.dpi") / get_default_dpi()
        scale = max(scale, 1)
        list_panel = wx.Panel(dlg, -1)
        list_panel.BackgroundColour = wx.SystemSettings.GetColour(wx.SYS_COLOUR_3DLIGHT)
        list_panel.Sizer = wx.BoxSizer(wx.HORIZONTAL)
        hscroll = wx.SystemSettings_GetMetric(wx.SYS_HSCROLL_Y)
        numrows = 15
        list_ctrl = wx.ListCtrl(
            list_panel,
            -1,
            size=(640 * scale, (20 * numrows + 25 + hscroll) * scale),
            style=wx.LC_REPORT | wx.LC_SINGLE_SEL | wx.BORDER_THEME,
            name="displays2profiles",
        )
        list_panel.Sizer.Add(list_ctrl, 1, flag=wx.ALL, border=1)
        list_ctrl.InsertColumn(0, lang.getstr("description"))
        list_ctrl.InsertColumn(1, lang.getstr("filename"))
        list_ctrl.SetColumnWidth(0, int(430 * scale))
        list_ctrl.SetColumnWidth(1, int(210 * scale))
        list_ctrl.Bind(wx.EVT_LIST_ITEM_SELECTED, lambda e: dlg.ok.Enable())
        list_ctrl.Bind(wx.EVT_LIST_ITEM_DESELECTED, lambda e: dlg.ok.Disable())
        list_ctrl.Bind(wx.EVT_LIST_ITEM_ACTIVATED, lambda e: dlg.EndModal(wx.ID_OK))
        profiles = []
        for pth in safe_glob(os.path.join(ICCPROFILES[0], "*.ic[cm]")) + safe_glob(
            os.path.join(ICCPROFILES[0], "*.cdmp")
        ):
            try:
                profile = ICCProfile(pth)
            except ICCProfileInvalidError as exception:
                print(f"{pth}:", exception)
                traceback.print_exc()
                continue
            except OSError as exception:
                print(exception)
                continue
            if profile.profileClass == b"mntr":
                profiles.append((profile.getDescription(), os.path.basename(pth)))
        natsort_key = natsort_key_factory()
        profiles.sort(key=lambda item: natsort_key(item[0]))
        for i, (desc, profile) in enumerate(profiles):
            pindex = list_ctrl.InsertStringItem(i, "")
            list_ctrl.SetStringItem(pindex, 0, desc)
            list_ctrl.SetStringItem(pindex, 1, profile)
        dlg.profiles_ctrl = list_ctrl
        dlg.sizer3.Add(list_panel, 1, flag=wx.TOP | wx.ALIGN_LEFT, border=12)
        dlg.sizer0.SetSizeHints(dlg)
        dlg.sizer0.Layout()
        dlg.ok.SetDefault()
        dlg.ok.Disable()
        dlg.Center()
        result = dlg.ShowModal()
        if result == wx.ID_OK:
            pindex = list_ctrl.GetNextItem(-1, wx.LIST_NEXT_ALL, wx.LIST_STATE_SELECTED)
            if pindex > -1:
                self.set_profile(profiles[pindex][1])
            else:
                wx.Bell()
        dlg.Destroy()

    def identify_displays(self, event: wx.Event) -> None:
        """Identify the displays by showing a frame with their description.

        Args:
            event (wx.Event): The event that triggered this method.
        """
        for display, frame in list(self.display_identification_frames.items()):
            if not frame:
                self.display_identification_frames.pop(display)
        for display, _edid, moninfo, _device in self.monitors:
            frame = self.display_identification_frames.get(display)
            if frame:
                frame.close_timer.Stop()
                frame.close_timer.Start(3000)
            else:
                m_left, m_top, m_right, m_bottom = moninfo["Monitor"]
                m_width = abs(m_right - m_left)
                m_height = abs(m_bottom - m_top)
                pos = wx.Point(int(m_left + m_width / 4), int(m_top + m_height / 4))
                size = wx.Size(int(m_width / 2), int(m_height / 2))
                display_desc = display.replace(
                    "[PRIMARY]", lang.getstr("display.primary")
                )
                frame = DisplayIdentificationFrame(display_desc, pos, size)
                self.display_identification_frames[display] = frame

    def show_profile_info(self, event: wx.Event) -> None:
        """Show the profile information for the selected profile.

        Args:
            event (wx.Event): The event that triggered this method.
        """
        pindex = self.profiles_ctrl.GetNextItem(
            -1, wx.LIST_NEXT_ALL, wx.LIST_STATE_SELECTED
        )
        if pindex < 0:
            wx.Bell()
            return
        try:
            profile = ICCProfile(self.profiles[pindex])
        except (OSError, ICCProfileInvalidError):
            show_result_dialog(
                Error(lang.getstr("profile.invalid") + "\n" + self.profiles[pindex]),
                self,
            )
            return
        id_ = profile.calculate_id(False) if profile.ID == "\0" * 16 else profile.ID
        if id_ not in self.profile_info:
            # Create profile info window and store in hash table
            from DisplayCAL.wx_profile_info import ProfileInfoFrame

            self.profile_info[id_] = ProfileInfoFrame(None, -1)
            self.profile_info[id_].Unbind(wx.EVT_CLOSE)
            self.profile_info[id_].Bind(wx.EVT_CLOSE, self.close_profile_info)
        if (
            not self.profile_info[id_].profile
            or self.profile_info[id_].profile.calculate_id(False) != id_
        ):
            # Load profile if info window has no profile or ID is different
            self.profile_info[id_].profileID = id_
            self.profile_info[id_].LoadProfile(profile)
        if self.profile_info[id_].IsIconized():
            self.profile_info[id_].Restore()
        else:
            self.profile_info[id_].Show()
            self.profile_info[id_].Raise()
        argyll_dir = getcfg("argyll.dir")
        if getcfg("argyll.dir") != argyll_dir:
            if self.pl.frame:
                result = self.pl.frame.send_command(
                    None, 'set-argyll-dir "{}"'.format(getcfg("argyll.dir"))
                )
            else:
                result = "ok"
            if result == "ok":
                self.pl.writecfg()

    def close_profile_info(self, event: wx.Event) -> None:
        """Close the profile info window and remove it from the hash table.

        Args:
            event (wx.Event): The event that triggered this method.
        """
        # Remove the frame from the hash table
        if self:
            self.profile_info.pop(event.GetEventObject().profileID)
        # Closes the window
        event.Skip()

    def disable_btns(self) -> None:
        """Disable buttons that are not applicable."""
        self.remove_btn.Disable()
        self.profile_info_btn.Disable()
        self.set_as_default_btn.Disable()

    def remove_profile(self, event: wx.Event) -> None:
        """Remove the selected profile from the monitor.

        Args:
            event (wx.Event): The event that triggered this method.
        """
        if self.remove_btn.GetAuthNeeded():
            if self.pl.elevate():
                self.EndModal(wx.ID_CANCEL)
            return
        pindex = self.profiles_ctrl.GetNextItem(
            -1, wx.LIST_NEXT_ALL, wx.LIST_STATE_SELECTED
        )
        if pindex > -1:
            self.set_profile(self.profiles[pindex], True)
        else:
            wx.Bell()

    def set_as_default(self, event: wx.Event) -> None:
        """Set the selected profile as the default for the monitor.

        Args:
            event (wx.Event): The event that triggered this method.
        """
        if self.set_as_default_btn.GetAuthNeeded():
            if self.pl.elevate():
                self.EndModal(wx.ID_CANCEL)
            return
        pindex = self.profiles_ctrl.GetNextItem(
            -1, wx.LIST_NEXT_ALL, wx.LIST_STATE_SELECTED
        )
        if pindex > -1:
            self.set_profile(self.profiles[pindex])
        else:
            wx.Bell()

    def set_profile(self, profile: str, unset: bool = False) -> None:
        """Set the display profile for the selected monitor.

        Args:
            profile (str): The name of the profile to set.
            unset (bool, optional): Whether to unset the profile instead of
                setting it. Defaults to False.
        """
        fn = unset_display_profile if unset else set_display_profile
        self._update_configuration(fn, profile)

    def _update_configuration(self, fn: Callable, arg0: object) -> None:
        """Update the display profile configuration.

        Args:
            fn (Callable): The function to call for updating the profile.
            arg0 (object): The argument to pass to the function.
        """
        dindex = self.display_ctrl.GetSelection()
        display, edid, moninfo, device = self.monitors[dindex]
        device0 = get_first_display_device(moninfo["Device"])
        if device0 and device:
            self._update_device(fn, arg0, device.DeviceKey)
            if (
                getcfg("profile_loader.fix_profile_associations")
                and device.DeviceKey != device0.DeviceKey
                and self.pl._can_fix_profile_associations()
            ):
                self._update_device(fn, arg0, device0.DeviceKey)
            self.update_profiles(True, monitor=self.monitors[dindex], next_=True)
        else:
            wx.Bell()

    def _update_device(
        self,
        fn: Callable,
        arg0: Any,  # noqa: ANN401
        devicekey: str,
        show_error: bool = True,
    ) -> None:
        """Update the display profile for a specific device.

        Args:
            fn (callable): The function to call for updating the profile.
            arg0 (Any): The argument to pass to the function.
            devicekey (str): The device key for the display.
            show_error (bool): Whether to show an error dialog on failure.
        """
        if (
            not USE_REGISTRY
            and fn is enable_per_user_profiles
            and not per_user_profiles_isenabled(devicekey=devicekey)
        ):
            # We need to re-associate per-user profiles to the
            # display, otherwise the associations will be lost
            # after enabling per-user if a system default profile
            # was set (but only if we call WcsSetUsePerUserProfiles
            # instead of setting the underlying registry value directly)
            monkey = devicekey.split("\\")[-2:]
            profiles = _winreg_get_display_profiles(monkey, True)
        else:
            profiles = []
        try:
            fn(arg0, devicekey=devicekey)
        except Exception as exception:
            print(
                f"{fn.__name__}({arg0!r}, devicekey={devicekey!r}):",
                exception,
            )
            if show_error:
                wx.CallAfter(show_result_dialog, UnloggedError(str(exception)), self)
        for profile_name in profiles:
            set_display_profile(profile_name, devicekey=devicekey)

    def toggle_fix_profile_associations(self, event: wx.Event) -> None:
        """Toggle the fix profile associations checkbox.

        Args:
            event (wx.Event): The event that triggered this method.
        """
        self.fix_profile_associations_cb.Value = (
            self.pl._toggle_fix_profile_associations(event, self)
        )
        if self.fix_profile_associations_cb.Value == event.IsChecked():
            self.update_profiles(True)

    def update(self, event: None | wx.Event = None) -> None:
        """Update the list of monitors and their associated profiles.

        Args:
            event (None | wx.Event, optional): The event that triggered this
                method.
        """
        self.monitors = list(self.pl.monitors)
        self.display_ctrl.SetItems(
            [
                entry[0].replace("[PRIMARY]", lang.getstr("display.primary"))
                for entry in self.monitors
            ]
        )
        if self.monitors:
            self.display_ctrl.SetSelection(0)
        self.display_ctrl.Enable(bool(self.monitors))
        self.identify_btn.Enable(bool(self.monitors))
        self.add_btn.Enable(bool(self.monitors))
        fix = self.pl._can_fix_profile_associations()
        self.fix_profile_associations_cb.Enable(fix)
        if fix:
            self.fix_profile_associations_cb.SetValue(
                bool(getcfg("profile_loader.fix_profile_associations"))
            )
        self.update_profiles(event)
        if event and not self.IsActive():
            self.RequestUserAttention()

    def update_profiles(
        self,
        event: None | wx.Event = None,
        monitor: None | tuple[int, dict, dict, Device] = None,
        next_: bool = False,
    ) -> None:
        """Update the profile associations for the selected monitor.

        Args:
            event (None | wx.Event): The event that triggered this method.
            monitor (None | tuple[int, dict, dict, Device], optional): A tuple
                containing display (int), edid (dict), moninfo (dict), and
                device (Device).
            next_ (bool): Whether to proceed to the next step in the update.
        """
        if not monitor:
            dindex = self.display_ctrl.GetSelection()
            if -1 < dindex < len(self.monitors):
                monitor = self.monitors[dindex]
            else:
                if event and not isinstance(event, wx.TimerEvent):
                    wx.Bell()
                return
        _display, _edid, _moninfo, device = monitor
        if not device:
            if event and not isinstance(event, wx.TimerEvent):
                wx.Bell()
            return
        if sys.getwindowsversion() >= (6,):
            current_user = per_user_profiles_isenabled(devicekey=device.DeviceKey)
            scope_changed = current_user != self.current_user
            if scope_changed:
                self.current_user = current_user
                self.use_my_settings_cb.SetValue(current_user)
            self.use_my_settings_cb.Enable()
            superuser = is_superuser()
            warn = not current_user and superuser
            update_layout = warn is not self.warning.IsShown()
            if update_layout:
                self.warn_bmp.Show(warn)
                self.warning.Show(warn)
                self.sizer3.Layout()
            auth_needed = not (current_user or superuser)
            update_layout = self.add_btn.GetAuthNeeded() is not auth_needed
            if update_layout:
                self.buttonpanel.Freeze()
                self.add_btn.SetAuthNeeded(auth_needed)
                self.remove_btn.SetAuthNeeded(auth_needed)
                self.set_as_default_btn.SetAuthNeeded(auth_needed)
                self.buttonpanel.Layout()
                self.buttonpanel.Thaw()
        else:
            current_user = False
            scope_changed = False
        monkey = device.DeviceKey.split("\\")[-2:]
        profiles = _winreg_get_display_profiles(monkey, current_user)
        profiles.reverse()
        profiles_changed = profiles != self.profiles
        if profiles_changed:
            self.profiles_ctrl.Freeze()
            self.profiles = profiles
            self.disable_btns()
            self.profiles_ctrl.DeleteAllItems()
            for i, profile in enumerate(self.profiles):
                pindex = self.profiles_ctrl.InsertStringItem(i, "")
                description = get_profile_desc(profile, False)
                if i == 0:
                    # First profile is always default
                    description += " ({})".format(lang.getstr("default"))
                self.profiles_ctrl.SetStringItem(pindex, 0, description)
                self.profiles_ctrl.SetStringItem(pindex, 1, profile)
            self.profiles_ctrl.Thaw()
        if scope_changed or (
            profiles_changed and (next_ or isinstance(event, wx.TimerEvent))
        ):
            wx.CallAfter(self._next)

    def _next(self) -> None:
        locked = self.pl.lock.locked()
        if locked:
            print("ProfileAssociationsDialog: Waiting to acquire lock...")
        with self.pl.lock:
            if locked:
                print("ProfileAssociationsDialog: Acquired lock")
            self.pl._next = True
            if locked:
                print("ProfileAssociationsDialog: Releasing lock")

    def use_my_settings(self, event: wx.Event) -> None:
        """Enable or disable per-user profiles.

        Args:
            event (wx.Event): The event that triggered this method.
        """
        self._update_configuration(enable_per_user_profiles, event.IsChecked())


class PLFrame(BaseFrame):
    """Main frame class for the profile loader.

    Args:
        pl (ProfileLoader): The profile loader instance.
    """

    def __init__(self, pl: ProfileLoader) -> None:
        BaseFrame.__init__(self, None)
        self.pl = pl
        self.Bind(wx.EVT_CLOSE, pl.exit)
        self.Bind(wx.EVT_DISPLAY_CHANGED, self.pl._display_changed)

    def get_commands(self) -> list[str]:
        """Get the list of supported commands.

        Returns:
            list[str]: The list of supported commands.
        """
        return [
            *self.get_common_commands(),
            "apply-profiles [force | display-changed]",
            "notify <message> [silent] [sticky]",
            "reset-vcgt [force]",
            "setlanguage <languagecode>",
        ]

    def process_data(self, data: list) -> str:
        """Process incoming command data.

        Args:
            data (list): The command data to process.
        """
        if data[0] in ("apply-profiles", "reset-vcgt") and (
            len(data) == 1
            or (len(data) == 2 and data[1] in ("force", "display-changed"))
        ):
            if (
                not ("--force" in sys.argv[1:] or len(data) == 2)
                and calibration_management_isenabled()
            ):
                return lang.getstr("calibration.load.handled_by_os")
            if (
                len(data) == 1 and self.pl._is_displaycal_running()
            ) or self.pl._is_other_running(False):
                return "forbidden"
            if data[-1] == "display-changed":
                if self.pl.lock.locked():
                    print("PLFrame.process_data: Waiting to acquire lock...")
                with self.pl.lock:
                    print("PLFrame.process_data: Acquired lock")
                    if self.pl._has_display_changed:
                        # Normally calibration loading is disabled while
                        # DisplayCAL is running. Override this when the
                        # display has changed
                        self.pl._manual_restore = getcfg("profile.load_on_login") and 2
                    print("PLFrame.process_data: Releasing lock")
            elif data[0] == "reset-vcgt":
                self.pl._set_reset_gamma_ramps(None, len(data))
            else:
                self.pl._set_manual_restore(None, len(data))
            return "ok"
        if data[0] == "notify" and (
            len(data) == 2
            or (len(data) == 3 and data[2] in ("silent", "sticky"))
            or (len(data) == 4 and "silent" in data[2:] and "sticky" in data[2:])
        ):
            self.pl.notify(
                [data[1]],
                [],
                sticky="sticky" in data[2:],
                show_notification="silent" not in data[2:],
            )
            return "ok"
        if data[0] == "setlanguage" and len(data) == 2:
            config.setcfg("lang", data[1])
            wx.CallAfter(self.pl.taskbar_icon.set_visual_state)
            self.pl.writecfg()
            return "ok"
        return "invalid"


class TaskBarIcon(SysTrayIcon):
    """System tray icon class for the profile loader.

    Args:
        pl (ProfileLoader): The profile loader instance.
    """

    def __init__(self, pl: ProfileLoader) -> None:
        super().__init__()
        self.pl = pl
        self.balloon_text = None
        self.flags = 0
        self.set_icons()
        self._active_icon_reset = config.get_bitmap_as_icon(16, "apply-profiles-reset")
        self._error_icon = config.get_bitmap_as_icon(16, "apply-profiles-error")
        self._animate = False
        self.set_visual_state(True)
        self.Bind(wx.EVT_TASKBAR_LEFT_UP, self.on_left_up)
        self.Bind(wx.EVT_TASKBAR_LEFT_DCLICK, self.on_left_dclick)
        self._dclick = False
        self._show_notification_later = None

    @property
    def _active_icon(self) -> wx.Icon:
        """Get the active icon for the system tray.

        Returns:
            wx.Icon: The active icon.
        """
        debug_print(f"[DEBUG] _active_icon[{self._icon_index:d}]")
        return self._active_icons[self._icon_index]

    @_active_icon.setter
    def _active_icon(self, icon: wx.Icon) -> None:
        """Set the active icon for the system tray.

        Args:
            icon (wx.Icon): The icon to set as active.
        """
        self._active_icons.append(icon)

    def CreatePopupMenu(self) -> wx.Menu:  # noqa: N802
        """Create the popup menu for the system tray icon.

        Returns:
            wx.Menu: The created popup menu.
        """
        # Popup menu appears on right-click
        menu = Menu()

        if self.pl._is_displaycal_running() or self.pl._is_other_running(False):
            restore_auto = restore_manual = reset = None
        else:
            restore_manual = self.pl._set_manual_restore
            if "--force" in sys.argv[1:] or calibration_management_isenabled():
                restore_auto = None
            else:
                restore_auto = self.set_auto_restore
            reset = self.pl._set_reset_gamma_ramps
        if "--force" not in sys.argv[1:] and calibration_management_isenabled():
            restore_auto_kind = apply_kind = wx.ITEM_NORMAL
        else:
            if config.getcfg("profile.load_on_login"):
                apply_kind = wx.ITEM_RADIO
            else:
                apply_kind = wx.ITEM_NORMAL
            restore_auto_kind = wx.ITEM_CHECK

        fix = self.pl._can_fix_profile_associations()
        if fix:
            fix = self.pl._toggle_fix_profile_associations

        menu_items = [
            (
                "calibration.load_from_display_profiles",
                restore_manual,
                apply_kind,
                "reset_gamma_ramps",
                lambda v: not v,
            ),
            (
                "calibration.reset",
                reset,
                apply_kind,
                "reset_gamma_ramps",
                None,
            ),
            ("-", None, False, None, None),
            (
                "calibration.preserve",
                restore_auto,
                restore_auto_kind,
                "profile.load_on_login",
                None,
            ),
            (
                "profile_loader.fix_profile_associations",
                fix,
                wx.ITEM_CHECK,
                "profile_loader.fix_profile_associations",
                None,
            ),
            (
                "show_notifications",
                lambda event: setcfg(
                    "profile_loader.show_notifications",
                    int(event.IsChecked()),
                ),
                wx.ITEM_CHECK,
                "profile_loader.show_notifications",
                None,
            ),
            (
                "tray_icon_animation",
                self.set_animation,
                wx.ITEM_CHECK,
                "profile_loader.tray_icon_animation_quality",
                None,
            ),
            ("-", None, False, None, None),
            (
                "bitdepth",
                (
                    ("8", lambda event: self.set_bitdepth(event, 8)),
                    ("10", lambda event: self.set_bitdepth(event, 10)),
                    ("12", lambda event: self.set_bitdepth(event, 12)),
                    ("14", lambda event: self.set_bitdepth(event, 14)),
                    ("16", lambda event: self.set_bitdepth(event, 16)),
                ),
                wx.ITEM_CHECK,
                "profile_loader.quantize_bits",
                lambda v: {8: 0, 10: 1, 12: 2, 14: 3, 16: 4}[v],
            ),
            ("-", None, False, None, None),
            ("exceptions", self.set_exceptions, wx.ITEM_NORMAL, None, None),
            ("-", None, False, None, None),
        ]
        menu_items.append(
            (
                "profile_associations",
                self.pl._set_profile_associations,
                wx.ITEM_NORMAL,
                None,
                None,
            )
        )
        if sys.getwindowsversion() >= (6,):
            menu_items.append(
                (
                    "mswin.open_display_settings",
                    self.open_display_settings,
                    wx.ITEM_NORMAL,
                    None,
                    None,
                )
            )
        menu_items.append(("-", None, False, None, None))
        menu_items.append(("menuitem.quit", self.pl.exit, wx.ITEM_NORMAL, None, None))
        for label, method, kind, option, oxform in menu_items:
            if label == "-":
                menu.AppendSeparator()
            else:
                label = lang.getstr(label)
                if option == "profile.load_on_login":
                    lstr = lang.getstr("profile.load_on_login")
                    if lang.getcode() != "de":
                        label = label[0].lower() + label[1:]
                    label = lstr + " && " + label
                item = MenuItem(menu, -1, label, kind=kind)
                if not oxform:
                    oxform = bool
                if not method:
                    item.Enable(False)
                elif isinstance(method, tuple):
                    submenu = Menu()
                    for i, (sublabel, submethod) in enumerate(method):
                        subitem = MenuItem(
                            submenu, -1, lang.getstr(sublabel), kind=kind
                        )
                        if oxform(getcfg(option)) == i:
                            subitem.Check(True)
                        submenu.AppendItem(subitem)
                        submenu.Bind(wx.EVT_MENU, submethod, id=subitem.Id)
                    menu.AppendSubMenu(submenu, label)
                    continue
                else:
                    menu.Bind(wx.EVT_MENU, method, id=item.Id)
                menu.AppendItem(item)
                if kind != wx.ITEM_NORMAL:
                    if option == "profile.load_on_login" and "--force" in sys.argv[1:]:
                        item.Check(True)
                    else:
                        if option == "reset_gamma_ramps":
                            value = self.pl._reset_gamma_ramps
                        else:
                            value = config.getcfg(option)
                        item.Check(method and oxform(value))

        return menu

    def PopupMenu(self, menu: wx.Menu) -> None:  # noqa: N802
        """Show the popup menu.

        Args:
            menu (wx.Menu): The menu to display.
        """
        if not self.check_user_attention():
            if self.menu and self.menu is not menu:
                self.menu.Destroy()
            SysTrayIcon.PopupMenu(self, menu)

    def animate(
        self,
        enumerate_windows_and_processes: bool = False,
        idle: bool = False,
    ) -> None:
        """Animate the taskbar icon.

        Args:
            enumerate_windows_and_processes (bool): Whether to
                enumerate windows and processes.
            idle (bool): Whether the system is idle.
        """
        if not self.pl.monitoring:
            return
        debug_print(
            "[DEBUG] animate(enumerate_windows_and_processes="
            f"{enumerate_windows_and_processes}, idle={idle})"
        )
        if self._icon_index < len(self._active_icons) - 1:
            self._animate = True
            self._icon_index += 1
        else:
            self._animate = False
            self._icon_index = 0
        self.set_visual_state(enumerate_windows_and_processes, idle)
        if self._icon_index > 0:
            wx.CallLater(
                int(200 / len(self._active_icons)),
                lambda enumerate_windows_and_processes, idle: (
                    self and self.animate(enumerate_windows_and_processes, idle)
                ),
                enumerate_windows_and_processes,
                idle,
            )
        debug_print("[DEBUG] /animate")

    def get_icon(
        self,
        enumerate_windows_and_processes: bool = False,
        idle: bool = False,
    ) -> wx.Icon:
        """Get the appropriate icon based on the current state.

        Args:
            enumerate_windows_and_processes (bool): Whether to
                enumerate windows and processes.
            idle (bool): Whether the system is idle.

        Returns:
            wx.Icon: The appropriate icon.
        """
        debug_print(
            "[DEBUG] "
            "get_icon(enumerate_windows_and_processes="
            f"{enumerate_windows_and_processes}, idle={idle})"
        )
        if (
            self.pl._should_apply_profiles(
                enumerate_windows_and_processes, manual_override=None
            )
            or self._animate
        ):
            count = len(self.pl.monitors)
            if (
                len(
                    [
                        i_success
                        for i_success in sorted(self.pl.setgammaramp_success.items())[
                            : count or 1
                        ]
                        if not i_success[1]
                    ]
                )
                != 0
            ):
                icon = self._error_icon
            elif self.pl._reset_gamma_ramps:
                icon = self._active_icon_reset
            else:
                icon = self._idle_icon if idle else self._active_icon
        else:
            icon = self._inactive_icon
        debug_print("[DEBUG] /get_icon")
        return icon

    def on_left_up(self, event: wx.Event) -> None:
        """Handle left click event on the taskbar icon.

        Args:
            event (wx.Event): The event that triggered this method.
        """
        if self._dclick:
            self._dclick = False
            return
        if not getattr(self, "_notification", None):
            # Make sure the displayed info is up-to-date
            locked = self.pl.lock.locked()
            if locked:
                print("TaskBarIcon.on_left_down: Waiting to acquire lock...")
            with self.pl.lock:
                if locked:
                    print("TaskBarIcon.on_left_down: Acquired lock")
                self.pl._next = True
                if locked:
                    print("TaskBarIcon.on_left_down: Releasing lock")
            time.sleep(0.11)
            locked = self.pl.lock.locked()
            if locked:
                print("TaskBarIcon.on_left_down: Waiting to acquire lock...")
            with self.pl.lock:
                if locked:
                    print("TaskBarIcon.on_left_down: Acquired lock")
                if locked:
                    print("TaskBarIcon.on_left_down: Releasing lock")
            self._show_notification_later = wx.CallLater(40, self.show_notification)
        else:
            self.show_notification(toggle=True)

    def on_left_dclick(self, event: wx.Event) -> None:
        """Handle left double-click event on the taskbar icon.

        Args:
            event (wx.Event): The event that triggered this method.
        """
        self._dclick = True
        if self.pl._is_other_running(False):
            return
        if self._show_notification_later and self._show_notification_later.IsRunning():
            self._show_notification_later.Stop()
        locked = self.pl.lock.locked()
        if locked:
            print("TaskBarIcon.on_left_dclick: Waiting to acquire lock...")
        with self.pl.lock:
            if locked:
                print("TaskBarIcon.on_left_dclick: Acquired lock")
            self.pl._manual_restore = True
            self.pl._next = True
            if locked:
                print("TaskBarIcon.on_left_dclick: Releasing lock")

    def check_user_attention(self) -> None | wx.Dialog:
        """Check if any dialog needs user attention.

        Returns:
            None | wx.Dialog: The dialog that needs attention, or
                None if none do.
        """
        dlgs = get_dialogs()
        if not dlgs:
            return None
        wx.Bell()
        for dlg in dlgs:
            # Need to request user attention for all open
            # dialogs because calling it only on the topmost
            # one does not guarantee taskbar flash
            dlg.RequestUserAttention()
        dlg.Raise()
        return dlg

    def open_display_settings(self, event: wx.Event) -> None:
        """Open the Windows display settings.

        Args:
            event (wx.Event): The event that triggered this method.
        """
        print("Menu command: Open display settings")
        try:
            sp.call(
                [  # noqa: S607
                    "control",
                    "/name",
                    "Microsoft.Display",
                    "/page",
                    "Settings",
                ],
                close_fds=True,
            )
        except Exception as exception:
            wx.Bell()
            print(exception)

    def set_animation(self, event: None | wx.Event = None) -> None:
        """Set the tray icon animation quality.

        Args:
            event (wx.Event, optional): The event that triggered
                this method. Defaults to None.
        """
        q = getcfg("profile_loader.tray_icon_animation_quality")
        q = 0 if q else 2
        print("Menu command: Set tray icon animation", q)
        setcfg("profile_loader.tray_icon_animation_quality", q)
        self.set_icons()

    def set_auto_restore(self, event: wx.Event) -> None:
        """Set the automatic profile loading on login.

        Args:
            event (wx.Event): The event that triggered this method.
        """
        print("Menu command: Preserve calibration state", event.IsChecked())
        config.setcfg("profile.load_on_login", int(event.IsChecked()))
        self.pl.writecfg()
        if event.IsChecked():
            if self.pl.lock.locked():
                print("TaskBarIcon: Waiting to acquire lock...")
            with self.pl.lock:
                print("TaskBarIcon: Acquired lock")
                self.pl._manual_restore = True
                self.pl._next = True
                print("TaskBarIcon: Releasing lock")
        else:
            self.set_visual_state()

    def set_bitdepth(
        self,
        event: None | wx.Event = None,
        bits: int = 16,
    ) -> None:
        """Set the quantization bit depth.

        Args:
            event (wx.Event, optional): The event that triggered
                this method. Defaults to None.
            bits (int, optional): The bit depth to set. Defaults to
                16.
        """
        print("Menu command: Set quantization bitdepth", bits)
        setcfg("profile_loader.quantize_bits", bits)
        with self.pl.lock:
            self.pl._quantize = 2**bits - 1.0
            self.pl.ramps = {}
            self.pl._manual_restore = True

    def set_exceptions(self, event: wx.Event) -> None:
        """Set application exceptions for profile loading.

        Args:
            event (wx.Event): The event that triggered this method.
        """
        print("Menu command: Set exceptions")
        dlg = ProfileLoaderExceptionsDialog(self.pl._exceptions, self.pl._known_apps)
        result = dlg.ShowModal()
        if result == wx.ID_OK:
            exceptions = []
            for key in dlg._exceptions:
                enabled, reset, path = dlg._exceptions[key]
                exceptions.append(f"{enabled:d}:{reset:d}:{path}")
                print(
                    f"Enabled={bool(enabled)}",
                    "Action={}".format((reset and "Reset") or "Disable"),
                    path,
                )
            if not exceptions:
                print("Clearing exceptions")
            config.setcfg("profile_loader.exceptions", ";".join(exceptions))
            self.pl._exceptions = dlg._exceptions
            self.pl._exception_names = {
                os.path.basename(key) for key in dlg._exceptions
            }
            self.pl.writecfg()
        else:
            print("Cancelled setting exceptions")
        dlg.Destroy()

    def set_icons(self) -> None:
        """Set up the tray icons."""
        bitmap = config.get_icon(16, "apply-profiles-tray")
        image = bitmap.ConvertToImage()
        # Use Rec. 709 luma coefficients to convert to grayscale
        bitmap = image.ConvertToGreyscale(0.2126, 0.7152, 0.0722).ConvertToBitmap()
        icon = wx.IconFromBitmap(bitmap)
        self._active_icons = []
        self._icon_index = 0
        anim_quality = getcfg("profile_loader.tray_icon_animation_quality")
        if anim_quality == 2:
            numframes = 8
        elif anim_quality == 1:
            numframes = 4
        else:
            numframes = 1
        for i in range(numframes):
            if i:
                rad = i / float(numframes)
                bitmap = config.get_icon(16, f"apply-profiles-tray-{360 * rad:0.0f}")
                image = bitmap.ConvertToImage()
                image.RotateHue(-rad)
            self._active_icon = wx.IconFromBitmap(image.ConvertToBitmap())
        self._idle_icon = self._active_icon
        self._inactive_icon = icon

    def set_visual_state(
        self,
        enumerate_windows_and_processes: bool = False,
        idle: bool = False,
    ) -> None:
        """Set the visual state of the taskbar icon.

        Args:
            enumerate_windows_and_processes (bool): Whether to
                enumerate windows and processes.
            idle (bool): Whether the system is idle.
        """
        if not self.pl.monitoring:
            return
        debug_print(
            "[DEBUG] set_visual_state(enumerate_windows_and_processes="
            f"{enumerate_windows_and_processes}, idle={idle})"
        )
        self.SetIcon(
            self.get_icon(enumerate_windows_and_processes, idle),
            self.pl.get_title(),
        )
        debug_print("[DEBUG] /set_visual_state")

    def show_notification(
        self,
        text: None | str = None,
        sticky: bool = False,
        show_notification: bool = True,
        flags: int = wx.ICON_INFORMATION,
        toggle: bool = False,
    ) -> None:
        """Show a notification balloon.

        Args:
            text (str, optional): The text to display in the
                notification. If None, displays the last
                notification text. Defaults to None.
            sticky (bool, optional): Whether the notification
                should be sticky (i.e., remain until dismissed).
                Defaults to False.
            show_notification (bool, optional): Whether to actually
                show the notification. Defaults to True.
            flags (int, optional): The icon type for the
                notification. Defaults to wx.ICON_INFORMATION.
            toggle (bool, optional): Whether to toggle the
                notification (i.e., hide it if it's already shown).
                Defaults to False.
        """
        if wx.VERSION < (3,) or not self.pl._check_keep_running():
            wx.Bell()
            return
        debug_print(
            f"[DEBUG] show_notification(text={text!r}, "
            f"sticky={sticky}, show_notification={show_notification}, "
            f"flags={flags!r}, toggle={toggle})"
        )
        if (sticky or text) and show_notification:
            # Do not show notification unless enabled
            show_notification = getcfg("profile_loader.show_notifications")
        if sticky:
            self.balloon_text = text
            self.flags = flags
        elif text:
            self.balloon_text = None
            self.flags = 0
        else:
            text = self.balloon_text
            flags = self.flags or flags
        if not text:
            if "--force" not in sys.argv[1:] and calibration_management_isenabled():
                text = lang.getstr("calibration.load.handled_by_os") + "\n"
            else:
                text = ""
            if self.pl._component_name:
                text += lang.getstr("app.detected", self.pl._component_name) + "\n"
            text += lang.getstr("profile_loader.info", self.pl.reload_count)
            for i, (display, _edid, _moninfo, device) in enumerate(self.pl.monitors):
                devicekey = device.DeviceKey if device else None
                key = devicekey or str(i)
                (
                    profile_key,
                    mtime,
                    desc,
                ) = self.pl.profile_associations.get(key, (False, 0, ""))
                if profile_key is False:
                    desc = lang.getstr("unknown")
                elif not profile_key:
                    desc = lang.getstr("unassigned").lower()
                if self.pl.setgammaramp_success.get(i) and self.pl._reset_gamma_ramps:
                    desc = f"{lang.getstr('linear').capitalize()} / {desc}"
                elif not self.pl.setgammaramp_success.get(i) or not profile_key:
                    desc = f"{lang.getstr('unknown')} / {desc}"
                display = display.replace("[PRIMARY]", lang.getstr("display.primary"))
                text += f"\n{display}: {desc}"
        if not show_notification:
            debug_print("[DEBUG] /show_notification")
            return
        if getattr(self, "_notification", None):
            self._notification.fade("out")
            if toggle:
                debug_print("[DEBUG] /show_notification")
                return
        bitmap = wx.BitmapFromIcon(self.get_icon())
        self._notification = TaskBarNotification(bitmap, self.pl.get_title(), text)
        debug_print("[DEBUG] /show_notification")


class ProfileLoader:
    """Profile loader class for Windows."""

    def __init__(self) -> None:
        from DisplayCAL.wx_windows import BaseApp, wx

        if not wx.GetApp():
            app = BaseApp(0, clearSigInt=sys.platform != "win32")
            BaseApp.register_exitfunc(self.shutdown)
        else:
            app = None
        self.reload_count = 0
        self.lock = threading.Lock()
        self._is_other_running_lock = threading.Lock()
        self.monitoring = True
        self._active_displays = []
        self._display_changed_event = False
        self.monitors = []  # Display devices that can be represented as ON
        self.display_devices = {}  # All display devices
        self.child_devices_count = {}
        self._current_display_key = -1
        self.numwindows = 0
        self.profile_associations = {}
        self.profiles = {}
        self.devices2profiles = {}
        self.ramps = {}
        self.linear_vcgt_values = ([], [], [])
        for j in range(3):
            for k in range(256):
                self.linear_vcgt_values[j].append([float(k), k * 257])
        self.setgammaramp_success = {}
        self.use_madhcnet = bool(config.getcfg("profile_loader.use_madhcnet"))
        self._has_display_changed = False
        self._last_exception_args = ()
        self._shutdown = False
        self._skip = "--skip" in sys.argv[1:]
        apply_profiles = bool(
            "--force" in sys.argv[1:] or config.getcfg("profile.load_on_login")
        )
        self._manual_restore = apply_profiles
        self._reset_gamma_ramps = bool(
            config.getcfg("profile_loader.reset_gamma_ramps")
        )
        self._known_apps = {
            known_app.lower()
            for known_app in config.DEFAULTS["profile_loader.known_apps"].split(";")
            + config.getcfg("profile_loader.known_apps").split(";")
        }
        self._known_window_classes = set(
            config.DEFAULTS["profile_loader.known_window_classes"].split(";")
            + config.getcfg("profile_loader.known_window_classes").split(";")
        )
        self._buggy_video_drivers = {
            buggy_video_driver.lower()
            for buggy_video_driver in config.getcfg(
                "profile_loader.buggy_video_drivers"
            ).split(";")
        }
        self._set_exceptions()
        self._madvr_instances = []
        self._madvr_reset_cal = {}
        self._quantize = 2 ** getcfg("profile_loader.quantize_bits") - 1.0
        self._timestamp = time.time()
        self._component_name = None
        self._app_detection_msg = None
        self._hwnds_pids = set()
        self._fixed_profile_associations = set()
        self.__other_component = None, None, 0
        self.__apply_profiles = None
        if (
            sys.platform == "win32"
            and "--force" not in sys.argv[1:]
            and sys.getwindowsversion() >= (6, 1)
            and calibration_management_isenabled()
        ):
            # Incase calibration loading is handled by Windows 7 and
            # isn't forced
            self._manual_restore = False

        if (
            sys.platform != "win32"
            and apply_profiles
            and not self._skip
            and not self._is_displaycal_running()
            and not self._is_other_running(True)
        ):
            self.apply_profiles_and_warn_on_error()

        if sys.platform == "win32":
            self.setup_taskbar_icon(app)
        elif (
            sys.platform == "darwin"
            and config.getcfg("profile.load_on_login")
            and "--force" not in sys.argv[1:]
            and not self._skip
            and config.getcfg("profile_loader.macos_reapply_watch")
        ):
            # macOS has no persistent calibration loader. Keep this process
            # running and re-load calibration if the OS clobbers the VideoLUT
            # (black screen bug, issue #694). A manual one-shot apply (--force)
            # is left as a one-shot and does not enter the watch loop.
            self._macos_watch()

    def setup_taskbar_icon(self, app: None | BaseApp) -> None:
        """Set up the taskbar icon.

        Args:
            app (None | BaseApp): The wx application instance.
        """
        # We create a TSR tray program only under Windows.
        # Linux has colord/Oyranos and respective session daemons should
        # take care of calibration loading
        self._pid = os.getpid()
        self._tid = threading.current_thread().ident

        self.taskbar_icon = TaskBarIcon(self)

        try:
            self.gdi32 = ctypes.windll.gdi32
            self.gdi32.GetDeviceGammaRamp.restype = ctypes.c_bool
            self.gdi32.SetDeviceGammaRamp.restype = ctypes.c_bool
        except Exception as exception:
            self.gdi32 = None
            print(exception)
            self.taskbar_icon.show_notification(str(exception))

        if self.use_madhcnet:
            try:
                self.madvr = madvr.MadTPG()
            except Exception as exception:
                print(exception)
                if str(exception) != lang.getstr("madvr.not_found"):
                    self.taskbar_icon.show_notification(str(exception))
            else:
                self.madvr.add_connection_callback(
                    self._madvr_connection_callback, None, "madVR"
                )
                self.madvr.add_connection_callback(
                    self._madvr_connection_callback, None, "madTPG"
                )
                self.madvr.listen()
                self.madvr.announce()

        self.frame = PLFrame(self)
        self.frame.listen()

        self._check_keep_running()

        self._check_display_conf_thread = threading.Thread(
            target=self._check_display_conf_wrapper,
            name="DisplayConfigurationMonitoring",
        )
        self._check_display_conf_thread.start()

        if app:
            app.TopWindow = self.frame
            app.MainLoop()

    def apply_profiles(
        self, event: None | wx.Event = None, index: None | int = None
    ) -> None | list[str]:
        """Apply profiles to the displays.

        Args:
            event (None | wx.Event, optional): The event that triggered the
                profile application.
            index (None | int, optional): The index of the display to apply the
                profile to. If None, applies to all displays.

        Returns:
            None | list[str]: A list of errors if any, None otherwise.
        """
        from DisplayCAL.util_os import dlopen, which
        from DisplayCAL.worker import Worker, get_argyll_util

        if sys.platform == "win32":
            self.lock.acquire()

        worker = Worker()

        errors = []

        if sys.platform == "win32":
            separator = "-"
        else:
            separator = "="
        print(separator * 80)
        print(lang.getstr("calibration.loading_from_display_profile"))

        # dispwin sets the _ICC_PROFILE(_n) root window atom, per-output xrandr
        # _ICC_PROFILE property (if xrandr is working) and loads the vcgt for the
        # requested screen (ucmm backend using color.jcnf), and has to be called
        # multiple times to setup multiple screens.
        #
        # If there is no profile configured in ucmm for the requested screen (or
        # ucmm support has been removed, like in the Argyll CMS versions shipped by
        # recent Fedora releases), it falls back to a possibly existing per-output
        # xrandr _ICC_PROFILE property (if xrandr is working) or _ICC_PROFILE(_n)
        # root window atom.
        dispwin = get_argyll_util("dispwin")
        if dispwin:
            # Always enumerate so that worker.displays is populated, even when
            # applying to a single display by index (e.g. from the macOS
            # VideoLUT watch). Without this the per-display loop below would
            # have nothing to iterate over for an indexed call.
            worker.enumerate_displays_and_ports(
                silent=True,
                check_lut_access=False,
                enumerate_ports=False,
                include_network_devices=False,
            )
            if index is None:
                self.monitors = []
                if sys.platform == "win32" and worker.displays:
                    self._enumerate_monitors()
        elif index is None:
            errors.append(lang.getstr("argyll.util.not_found", "dispwin"))

        if sys.platform != "win32":
            # gcm-apply sets the _ICC_PROFILE root window atom for the first screen,
            # per-output xrandr _ICC_PROFILE properties (if xrandr is working) and
            # loads the vcgt for all configured screens (device-profiles.conf)
            # NOTE: gcm-apply is no longer part of GNOME Color Manager since the
            # introduction of colord as it's no longer needed
            gcm_apply = which("gcm-apply")
            if gcm_apply:
                worker.exec_cmd(
                    gcm_apply, capture_output=True, skip_scripts=True, silent=False
                )

            # oyranos-monitor sets _ICC_PROFILE(_n) root window atoms (oyranos
            # db backend) and loads the vcgt for all configured screens when
            # xcalib is installed
            oyranos_monitor = which("oyranos-monitor")
            xcalib = which("xcalib")

        argyll_use_colord = os.getenv("ARGYLL_USE_COLORD") and dlopen(
            "libcolordcompat.so"
        )

        results = []
        for i, display in enumerate(
            [
                display.replace("[PRIMARY]", lang.getstr("display.primary"))
                for display in worker.displays
            ]
        ):
            if config.is_virtual_display(i) or (index is not None and i != index):
                continue
            # Load profile and set vcgt
            if sys.platform != "win32" and oyranos_monitor:
                display_conf_oy_compat = worker.check_display_conf_oy_compat(i + 1)
                if display_conf_oy_compat:
                    worker.exec_cmd(
                        oyranos_monitor,
                        [
                            "-x",
                            str(worker.display_rects[i][0]),
                            "-y",
                            str(worker.display_rects[i][1]),
                        ],
                        capture_output=True,
                        skip_scripts=True,
                        silent=False,
                    )
            if dispwin:
                if not argyll_use_colord:
                    # Deal with colord ourself
                    profile_arg = worker.get_dispwin_display_profile_argument(i)
                else:
                    # Argyll deals with colord directly
                    profile_arg = "-L"
                if (
                    sys.platform == "win32"
                    or not oyranos_monitor
                    or not display_conf_oy_compat
                    or not xcalib
                    or profile_arg == "-L"
                ):
                    # Only need to run dispwin if under Windows, or if nothing else
                    # has already taken care of display profile and vcgt loading
                    # (e.g. oyranos-monitor with xcalib, or colord)
                    #
                    # On macOS the OS (WindowServer) may intermittently clobber
                    # the VideoLUT to all-black right after loading it, turning
                    # the display black (issue #694). We therefore verify that
                    # the calibration was actually loaded and re-try the load a
                    # few times if it wasn't. Verification is also done on other
                    # platforms when explicitly enabled.
                    verify_load = (
                        sys.platform == "darwin"
                        or config.getcfg("profile_loader.verify_calibration")
                        or "--verify" in sys.argv[1:]
                    ) and profile_arg != "-L"
                    max_attempts = max(
                        1, int(config.getcfg("profile_loader.load_retries"))
                    )
                    load_error = None
                    for attempt in range(max_attempts):
                        if worker.exec_cmd(
                            dispwin,
                            ["-v", f"-d{i + 1:d}", profile_arg],
                            capture_output=True,
                            skip_scripts=True,
                            silent=False,
                        ):
                            errortxt = ""
                        else:
                            errortxt = "\n".join(worker.errors).strip()
                        if errortxt and (
                            (
                                "using linear" not in errortxt
                                and "assuming linear" not in errortxt
                            )
                            or len(errortxt.split("\n")) > 1
                        ):
                            if (
                                "Failed to get the displays current ICC profile"
                                in errortxt
                            ):
                                # Maybe just not configured
                                load_error = "skip"
                                break
                            if (
                                sys.platform == "win32"
                                or "Failed to set VideoLUT" in errortxt
                                or "We don't have access to the VideoLUT" in errortxt
                            ):
                                errstr = lang.getstr("calibration.load_error")
                            else:
                                errstr = lang.getstr("profile.load_error")
                            load_error = f"{display}: {errstr}"
                            break
                        # The load command itself succeeded. Verify the
                        # calibration is actually loaded in the VideoLUT and
                        # re-try the load if it was clobbered (macOS black
                        # screen), see issue #694.
                        if not verify_load:
                            break
                        if not worker.calibration_is_clobbered(
                            dispwin, i, profile_arg
                        ):
                            # Loaded OK (or could not be determined)
                            break
                        if attempt + 1 < max_attempts:
                            # Give the OS a moment to settle, then re-load
                            print(
                                f"VideoLUT for {display} not loaded "
                                f"(attempt {attempt + 1:d}/{max_attempts:d}), "
                                "re-loading calibration"
                            )
                            time.sleep(0.5)
                            continue
                        load_error = ": ".join(
                            [display, lang.getstr("calibration.load_error")]
                        )
                    if load_error == "skip":
                        continue
                    if load_error:
                        errors.append(load_error)
                        continue
                    results.append(display)

        if sys.platform == "win32":
            self.lock.release()
            if event:
                self.notify(results, errors)

        return errors

    def _macos_watch(self) -> None:
        """Keep re-loading calibration on macOS if the VideoLUT gets clobbered.

        macOS (WindowServer) intermittently resets the VideoLUT of a display to
        all-black some time after the calibration was loaded, turning the
        display black (issue #694). Unlike Windows, macOS has no persistent
        calibration loader by default, so this keeps the apply-profiles process
        running and periodically verifies that each display's calibration is
        still loaded, re-applying it for any display where it isn't.

        This blocks until ``self._shutdown`` is set, keeping the process alive.
        """
        from DisplayCAL.worker import Worker, get_argyll_util

        dispwin = get_argyll_util("dispwin")
        if not dispwin:
            return
        interval = max(
            1, int(config.getcfg("profile_loader.macos_reapply_watch_interval"))
        )
        worker = Worker()
        debug_print("Starting macOS VideoLUT watch (issue #694 mitigation)")
        while not self._shutdown:
            # Sleep in short slices so shutdown is responsive
            for _ in range(interval * 2):
                if self._shutdown:
                    return
                time.sleep(0.5)
            # Don't touch the VideoLUT while DisplayCAL itself is calibrating or
            # profiling - it owns the VideoLUT during that time.
            if self._is_displaycal_running():
                continue
            try:
                worker.enumerate_displays_and_ports(
                    silent=True,
                    check_lut_access=False,
                    enumerate_ports=False,
                    include_network_devices=False,
                )
            except Exception as exception:
                print(exception)
                continue
            for i in range(len(worker.displays)):
                if self._shutdown:
                    return
                if config.is_virtual_display(i):
                    continue
                profile_arg = worker.get_dispwin_display_profile_argument(i)
                if profile_arg == "-L":
                    # No installed profile path to verify against
                    continue
                if worker.calibration_is_clobbered(dispwin, i, profile_arg):
                    print(
                        f"VideoLUT for display {i + 1:d} was clobbered - "
                        "re-loading calibration"
                    )
                    self.apply_profiles(index=i)

    def notify(
        self,
        results: list,
        errors: list,
        sticky: bool = False,
        show_notification: bool = False,
    ) -> None:
        """Notify the user about the results of profile application.

        Args:
            results (list): List of successful profile applications.
            errors (list): List of errors encountered during profile application.
            sticky (bool, optional): Whether the notification should be sticky.
                Defaults to False.
            show_notification (bool, optional): Whether to show the notification.
                Defaults to False.
        """
        wx.CallAfter(
            lambda: self and self._notify(results, errors, sticky, show_notification)
        )

    def _notify(
        self,
        results: list,
        errors: list,
        sticky: bool = False,
        show_notification: bool = False,
    ) -> None:
        """Handle the notification logic.

        Args:
            results (list): List of successful profile applications.
            errors (list): List of errors encountered during profile application.
            sticky (bool, optional): Whether the notification should be sticky.
                Defaults to False.
            show_notification (bool, optional): Whether to show the notification.
                Defaults to False.
        """
        debug_print(
            f"[DEBUG] notify(results={results!r}, errors={errors!r}, "
            f"sticky={sticky}, show_notification={show_notification})"
        )
        self.taskbar_icon.set_visual_state()
        results.extend(errors)
        flags = wx.ICON_ERROR if errors else wx.ICON_INFORMATION
        self.taskbar_icon.show_notification(
            "\n".join(results), sticky, show_notification, flags
        )
        debug_print("[DEBUG] /notify")

    def apply_profiles_and_warn_on_error(
        self, event: None | wx.Event = None, index: None | int = None
    ) -> None:
        """Apply profiles and show a warning dialog if there are errors.

        Args:
            event (None | wx.Event, optional): The event that triggered the
                application of profiles. Defaults to None.
            index (None | int, optional): The index of the monitor to apply profiles
                to. If None, applies to all monitors. Defaults to None.
        """
        # wx.App must already be initialized at this point!
        errors = self.apply_profiles(event, index)
        if (
            errors
            and (
                config.getcfg("profile_loader.error.show_msg")
                or "--error-dialog" in sys.argv[1:]
            )
            and "--silent" not in sys.argv[1:]
        ):
            from DisplayCAL.wx_windows import InfoDialog, wx

            dlg = InfoDialog(
                None,
                msg="\n".join(errors),
                title=self.get_title(),
                ok=lang.getstr("ok"),
                bitmap=config.get_icon(32, "dialog-error"),
                show=False,
            )
            dlg.SetIcons(
                config.get_icon_bundle([256, 48, 32, 16], APPNAME + "-apply-profiles")
            )
            dlg.do_not_show_again_cb = wx.CheckBox(
                dlg, -1, lang.getstr("dialog.do_not_show_again")
            )
            dlg.do_not_show_again_cb.SetValue(
                not bool(config.getcfg("profile_loader.error.show_msg"))
            )

            def do_not_show_again_handler(event: None | wx.Event = None) -> None:
                """Handle the 'do not show again' checkbox event.

                Args:
                    event (None | wx.Event, optional): The event that triggered
                        the handler. Defaults to None.
                """
                config.setcfg(
                    "profile_loader.error.show_msg",
                    int(not dlg.do_not_show_again_cb.GetValue()),
                )
                config.writecfg(
                    module="apply-profiles",
                    options=("argyll.dir", "profile.load_on_login", "profile_loader"),
                )

            dlg.do_not_show_again_cb.Bind(wx.EVT_CHECKBOX, do_not_show_again_handler)
            dlg.sizer3.Add(dlg.do_not_show_again_cb, flag=wx.TOP, border=12)
            dlg.sizer0.SetSizeHints(dlg)
            dlg.sizer0.Layout()
            dlg.Center(wx.BOTH)
            dlg.ok.SetDefault()
            dlg.ShowModalThenDestroy()

    def elevate(self) -> bool:
        """Elevate the process to run as administrator.

        Returns:
            bool: True if the process was elevated, False otherwise.
        """
        if sys.getwindowsversion() < (6,):
            return False

        loader_args = []
        if os.path.basename(EXE).lower() not in ("python.exe", "pythonw.exe"):
            cmd = os.path.join(PYDIR, f"{APPNAME}-apply-profiles.exe")
        else:
            # cmd = os.path.join(exedir, "pythonw.exe")
            cmd = EXE
            pyw = os.path.normpath(
                os.path.join(PYDIR, "..", f"{APPNAME}-apply-profiles.pyw")
            )
            if os.path.exists(pyw):
                # Running from source or 0install
                # Check if this is a 0install implementation, in which
                # case we want to call 0launch with the appropriate
                # command
                if re.match(
                    r"sha\d+(?:new)?", os.path.basename(os.path.dirname(PYDIR))
                ):
                    cmd = which("0install-win.exe") or "0install-win.exe"
                    loader_args.extend(
                        [
                            "run",
                            "--batch",
                            "--no-wait",
                            "--offline",
                            "--command=run-apply-profiles",
                            f"http://{DOMAIN}/0install/{APPNAME}.xml",
                        ]
                    )
                else:
                    # Running from source
                    loader_args.append(pyw)
            else:
                # Regular install
                loader_args.append(
                    get_data_path("/".join(["scripts", f"{APPNAME}-apply-profiles"]))
                )

        loader_args.append("--profile-associations")
        print(f"cmd: {cmd}")
        print(f"loader_args: {loader_args}")
        try:
            run_as_admin(cmd, loader_args)
        except pywintypes.error as exception:
            if exception.args[0] != winerror.ERROR_CANCELLED:
                show_result_dialog(exception)
        else:
            self.shutdown()
            wx.CallLater(50, self.exit)
            return True
        return False

    def exit(self, event: None | wx.Event = None) -> None:
        """Exit the application.

        Args:
            event (None | wx.Event, optional): The event that triggered the
                exit. Defaults to None.
        """
        print(f"Executing ProfileLoader.exit({event})")
        dlg = None
        for dlg in get_dialogs():
            if not isinstance(dlg, ProfileLoaderExceptionsDialog) or (
                event and hasattr(event, "CanVeto") and not event.CanVeto()
            ):
                try:
                    dlg.EndModal(wx.ID_CANCEL)
                except Exception:
                    pass
                else:
                    dlg = None
            if dlg and event and hasattr(event, "CanVeto") and event.CanVeto():
                # Need to request user attention for all open
                # dialogs because calling it only on the topmost
                # one does not guarantee taskbar flash
                dlg.RequestUserAttention()
        else:
            if dlg and event and hasattr(event, "CanVeto") and event.CanVeto():
                event.Veto()
                print("Vetoed", event)
                return
        if (
            event
            and self.frame
            and event.GetEventType() == wx.EVT_MENU.typeId
            and (
                not calibration_management_isenabled()
                or config.getcfg("profile_loader.fix_profile_associations")
            )
        ):
            dlg = ConfirmDialog(
                None,
                msg=lang.getstr("profile_loader.exit_warning"),
                title=self.get_title(),
                ok=lang.getstr("menuitem.quit"),
                bitmap=config.get_icon(32, "dialog-warning"),
            )
            dlg.SetIcons(
                config.get_icon_bundle([256, 48, 32, 16], APPNAME + "-apply-profiles")
            )
            result = dlg.ShowModal()
            dlg.Destroy()
            if result != wx.ID_OK:
                print(f"Cancelled ProfileLoader.exit({event})")
                return
        if isinstance(event, wx.CloseEvent):
            # Other event source
            event.Skip()
        else:
            # Called from menu
            wx.GetApp().ExitMainLoop()

    def get_title(self) -> str:
        """Get the title of the application.

        Returns:
            str: The title of the application.
        """
        title = "{} {} {}".format(
            APPNAME,
            lang.getstr("profile_loader").title(),
            VERSION_STRING,
        )
        if "--force" in sys.argv[1:]:
            title += " ({})".format(lang.getstr("forced"))
        return title

    def _can_fix_profile_associations(self) -> bool:
        """Check whether we can 'fix' profile associations or not.

        'Fixing' means we assign the profile of the actual active child device
        to the 1st child device so that applications using GetICMProfile get
        the correct profile (GetICMProfile always returns the profile of the
        1st child device irrespective if this device is active or not. This is
        a Windows bug).

        This only works if a child device is not attached to several adapters
        (which is something that can happen due to the inexplicable mess that
        is the Windows display enumeration API).

        Returns:
            bool: True if we can fix profile associations, False otherwise.
        """
        if not self.child_devices_count:
            for _display, _edid, moninfo, _device in self.monitors:
                child_devices = get_display_devices(moninfo["Device"])
                for child_device in child_devices:
                    if child_device.DeviceKey not in self.child_devices_count:
                        self.child_devices_count[child_device.DeviceKey] = 0
                    self.child_devices_count[child_device.DeviceKey] += 1
        return (
            bool(self.child_devices_count)
            and max(self.child_devices_count.values()) == 1
        )

    def _check_keep_running(self) -> bool:
        """Check if the application should keep running."""
        windows = []
        # print '-' * 79
        with contextlib.suppress(pywintypes.error):
            win32gui.EnumThreadWindows(
                self._tid, self._enumerate_own_windows_callback, windows
            )
        windows.extend(
            [
                window
                for window in list(wx.GetTopLevelWindows())
                if not isinstance(window, wx.Dialog)
                and window.Name
                not in {"TaskBarNotification", "DisplayIdentification", "profile_info"}
            ]
        )
        numwindows = len(windows)
        if numwindows < self.numwindows:
            # One of our windows has been closed by an external event
            # (i.e. WM_CLOSE). This is a hint that something external is trying
            # to get us to exit. Comply by closing our main top-level window to
            # initiate clean shutdown.
            print("Window count", self.numwindows, "->", numwindows)
            return False
        self.numwindows = numwindows
        return True

    def _enumerate_own_windows_callback(self, hwnd: int, windowlist: list) -> None:
        """Callback for enumerating own windows.

        Args:
            hwnd (int): The handle of the window.
            windowlist (list): The list to append the window class names to.
        """
        cls = win32gui.GetClassName(hwnd)
        # print cls
        if cls in (
            "madHcNetQueueWindow",
            "wxTLWHiddenParent",
            "wxTimerHiddenWindow",
            "wxDisplayHiddenWindow",
            "SysTrayIcon",
        ) or cls.startswith("madToolsMsgHandlerWindow"):
            windowlist.append(cls)

    def _display_changed(self, event: wx.Event) -> None:
        """Handle a display changed event.

        Args:
            event (wx.Event): The event that triggered the display change.
        """
        threading.Thread(
            target=self._process_display_changed, name="ProcessDisplayChangedEvent"
        ).start()

    def _process_display_changed(self) -> None:
        """Process a display changed event."""
        if self.lock.locked():
            print("ProcessDisplayChangedEvent: Waiting to acquire lock...")
        with self.lock:
            print("ProcessDisplayChangedEvent: Acquired lock")
            self._display_changed_event = True
            self._next = True
            if getattr(self, "profile_associations_dlg", None):
                wx.CallAfter(
                    wx.CallLater,
                    1000,
                    lambda: (
                        self.profile_associations_dlg
                        and self.profile_associations_dlg.update(True)
                    ),
                )
            if getattr(self, "fix_profile_associations_dlg", None):
                wx.CallAfter(
                    wx.CallLater,
                    1000,
                    lambda: (
                        self.fix_profile_associations_dlg
                        and self.fix_profile_associations_dlg.update(True)
                    ),
                )
            print("ProcessDisplayChangedEvent: Releasing lock")

    def _check_display_changed(
        self,
        first_run: bool = False,
        dry_run: bool = False,
    ) -> bool:
        """Check if the display configuration has changed.

        Args:
            first_run (bool, optional): Whether this is the first run of the
                application. Defaults to False.
            dry_run (bool, optional): If True, do not update internal state,
                just check if display configuration has changed. Defaults to False.

        Returns:
            bool: True if the display configuration has changed, False otherwise.
        """
        # Check registry if display configuration changed (e.g. if a display
        # was added/removed, and not just the resolution changed)
        if not (first_run or self._display_changed_event):
            # Not first run and no prior display changed event
            return False
        has_display_changed = False
        ts = time.time()
        key_name = r"SYSTEM\CurrentControlSet\Control\GraphicsDrivers\Configuration"
        try:
            key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, key_name)
            numsubkeys, numvalues, mtime = winreg.QueryInfoKey(key)
        except OSError as exception:
            # Windows XP or Win10 >= 1903 if not running elevated
            if exception.args[0] != errno.ENOENT or sys.getwindowsversion() >= (6,):
                warnings.warn(
                    rf"Registry access failed: {exception}: HKLM\{key_name}",
                    Warning,
                    stacklevel=2,
                )
            key = None
            numsubkeys = 0
            # get_active_display_devices takes around 3-10ms in contrast to
            # querying registry which takes 1-3ms
            if (
                not self._active_displays
                or self._active_displays != get_active_display_devices("DeviceKey")
            ):
                has_display_changed = True
        for i in range(numsubkeys):
            try:
                subkey_name = winreg.EnumKey(key, i)
                subkey = winreg.OpenKey(key, subkey_name)
            except OSError as exception:
                warnings.warn(
                    f"Registry access failed: {exception}: "
                    rf"HKLM\{key_name}\{subkey_name}",
                    Warning,
                    stacklevel=2,
                )
                continue
            value_name = "SetId"
            try:
                display = winreg.QueryValueEx(subkey, "SetId")[0]
                value_name = "Timestamp"
                # winreg.QueryValuesEx will directly return the int value,
                # so no need to convert the binary data as in Python2
                timestamp = winreg.QueryValueEx(subkey, "Timestamp")[0]
            except OSError as exception:
                warnings.warn(
                    f"Registry access failed: {exception}: {value_name} "
                    rf"(HKLM\{key_name}\{subkey_name})",
                    Warning,
                    stacklevel=2,
                )
                continue
            if timestamp > self._current_timestamp:
                if display != self._current_display:
                    has_display_changed = True
                if not dry_run:
                    self._current_display = display
                    self._current_timestamp = timestamp
            winreg.CloseKey(subkey)
        if key:
            winreg.CloseKey(key)
        print(
            "Display configuration change detection took {:.6f} ms".format(  # noqa: UP032
                (time.time() - ts) * 1000.0
            )
        )
        if not dry_run:
            # Display conf or resolution change
            self._enumerate_monitors()
        if has_display_changed:
            # First run or display conf change, not just resolution
            if not (first_run or dry_run):
                print(lang.getstr("display_detected"))
            if not dry_run and getcfg("profile_loader.fix_profile_associations"):
                # Work-around long-standing bug in applications
                # querying the monitor profile not making sure
                # to use the active display (this affects Windows
                # itself as well) when only one display is
                # active in a multi-monitor setup.
                if not first_run:
                    self._reset_display_profile_associations()
                self._set_display_profiles()
            if not (first_run or dry_run) and self._is_displaycal_running():
                # Normally calibration loading is disabled while
                # DisplayCAL is running. Override this when the
                # display has changed
                self._manual_restore = getcfg("profile.load_on_login") and 2
        if not dry_run:
            self._has_display_changed = has_display_changed
            self._display_changed_event = False
        return has_display_changed

    def _check_display_conf_wrapper(self) -> None:
        try:
            self._check_display_conf()
        except Exception as exception:
            if self.lock.locked():
                self.lock.release()
            wx.CallAfter(self._handle_fatal_error, exception)

    def _handle_fatal_error(self, exception: Exception) -> None:
        handle_error(exception)
        wx.CallAfter(self.exit)

    def _check_display_conf(self) -> None:
        """Thread to monitor display configuration changes."""
        self._current_display = None
        self._current_timestamp = 0
        self._next = False
        first_run = True
        apply_profiles = self._should_apply_profiles()
        displaycal_running = False
        previous_hwnds_pids = self._hwnds_pids
        while self and self.monitoring:
            result = None
            results = []
            errors = []
            idle = True
            locked = self.lock.locked()
            if locked:
                print(
                    "DisplayConfigurationMonitoringThread: Waiting to acquire lock..."
                )
            lock_acquired = self.lock.acquire()
            if lock_acquired:
                print("DisplayConfigurationMonitoringThread: Acquired lock")
            # Check if display configuration changed
            self._check_display_changed(first_run)
            profile_associations_changed, apply_profiles, idle, result = (
                self._check_profile_associations(
                    first_run, idle, previous_hwnds_pids, result, results, errors
                )
            )
            if self._next:
                # We only arrive here if a change in profile associations was
                # detected and we exited the loop early
                if locked:
                    print("DisplayConfigurationMonitoringThread: Releasing lock")
                self.lock.release()
                if locked:
                    print("DisplayConfigurationMonitoringThread: Released lock")
                continue
            previous_hwnds_pids = self._hwnds_pids
            timestamp = time.time()
            localtime = list(time.localtime(self._timestamp))
            localtime[3:6] = 23, 59, 59
            midnight = time.mktime(tuple(localtime)) + 1
            if timestamp >= midnight:
                self.reload_count = 0
                self._timestamp = timestamp
            self._handle_profile_updates(
                first_run,
                apply_profiles,
                displaycal_running,
                results,
                errors,
                idle,
                profile_associations_changed,
            )
            if apply_profiles and not idle:
                wx.CallAfter(lambda: self and self.taskbar_icon.animate())
            self.__apply_profiles = apply_profiles
            first_run = False
            if profile_associations_changed and not self._has_display_changed:
                if getattr(self, "profile_associations_dlg", None):
                    wx.CallAfter(
                        lambda: (
                            self.profile_associations_dlg
                            and self.profile_associations_dlg.update_profiles()
                        )
                    )
                if getattr(self, "fix_profile_associations_dlg", None):
                    wx.CallAfter(
                        lambda: (
                            self.fix_profile_associations_dlg
                            and self.fix_profile_associations_dlg.update()
                        )
                    )
            if result:
                self._has_display_changed = False
            self._manual_restore = False
            self._skip = False
            if locked:
                print("DisplayConfigurationMonitoringThread: Releasing lock")
            self.lock.release()
            if locked:
                print("DisplayConfigurationMonitoringThread: Released lock")
            if "--oneshot" in sys.argv[1:]:
                wx.CallAfter(self.exit)
                break
            if "--profile-associations" in sys.argv[1:]:
                sys.argv.remove("--profile-associations")
                wx.CallAfter(self._set_profile_associations, None)
            # Wait three seconds
            timeout = 0
            while (
                self
                and self.monitoring
                and timeout < 3
                and not self._manual_restore
                and not self._next
            ):
                if round(timeout * 100) % 25 == 0 and not self._check_keep_running():
                    self.monitoring = False
                    wx.CallAfter(lambda: self.frame and self.frame.Close(force=True))
                    break
                time.sleep(0.1)
                timeout += 0.1
        print("Display configuration monitoring thread finished")
        self.shutdown()

    def _check_profile_associations(
        self,
        first_run: bool,
        idle: bool,
        previous_hwnds_pids: dict,
        result: None | bool,
        results: list[str],
        errors: list[str],
    ) -> tuple[int, bool, bool, None | bool]:
        """Check profile associations and apply profiles if necessary.

        Args:
            first_run (bool): Whether this is the first run of the application.
            idle (bool): Whether the system is idle.
            previous_hwnds_pids (dict): Previous window handles and process IDs.
            result (None | bool): Result of the last profile application.
            results (list[str]): List to store results of profile applications.
            errors (list[str]): List to store errors encountered during profile
                applications.

        Returns:
            tuple[int, bool, bool, None | bool]: A tuple containing:
                - profile_associations_changed (int): Number of changed profile
                  associations.
                - apply_profiles (bool): Whether to apply profiles.
                - idle (bool): Whether the system is idle.
                - result (None | bool): Result of the last profile application.
        """
        # Check profile associations
        profile_associations_changed = 0
        apply_profiles = self._should_apply_profiles()
        for i, (display, _edid, moninfo, device) in enumerate(self.monitors):
            display_desc = display.replace("[PRIMARY]", lang.getstr("display.primary"))
            devicekey = device.DeviceKey if device else None
            key = devicekey or str(i)
            self._current_display_key = key
            exception = None
            profile_path = profile_name = None
            try:
                profile_path = get_display_profile(
                    i, path_only=True, devicekey=devicekey
                )
            except IndexError:
                debug_print(f"Display {key} ({display}) no longer present?")
                self._next = False
                break
            except Exception as exception:
                if (
                    exception.args[0] != errno.ENOENT
                    and exception.args != self._last_exception_args
                ) or DEBUG:
                    self._last_exception_args = exception.args
                    print(
                        f"Could not get display profile for display {key} ({display}):",
                        exception,
                    )
                if exception.args[0] == errno.ENOENT:
                    # Unassigned - don't show error icon
                    self.setgammaramp_success[i] = True
                    exception = None
                else:
                    self.setgammaramp_success[i] = None
            else:
                if profile_path:
                    profile_name = os.path.basename(profile_path)
            profile_key = str(profile_name or exception or "")
            association = self.profile_associations.get(key, (False, 0, ""))
            if (
                getcfg("profile_loader.fix_profile_associations")
                and not first_run
                and not self._has_display_changed
                and not self._next
                and association[0] != profile_key
            ):
                # At this point we do not yet know if only the profile
                # association has changed or the display configuration.
                # One second delay to allow display configuration
                # to settle
                if not self._check_display_changed(dry_run=True):
                    debug_print("Delay 1s")
                    timeout = 0
                    while (
                        self
                        and self.monitoring
                        and timeout < 1
                        and self._manual_restore != 2
                        and not self._next
                    ):
                        time.sleep(0.1)
                        timeout += 0.1
                    self._next = True
                break
            mtime = (
                os.stat(profile_path).st_mtime
                if (profile_path and os.path.isfile(profile_path))
                else 0
            )
            profile_association_changed = False
            profile_associations_changed, profile_association_changed, desc = (
                self._update_profile_associations(
                    display,
                    first_run,
                    i,
                    moninfo,
                    key,
                    exception,
                    profile_name,
                    profile_key,
                    association,
                    mtime,
                    profile_association_changed,
                    profile_associations_changed,
                )
            )
            # Check video card gamma table and (re)load calibration if
            # necessary
            if not self.gdi32 or not (profile_name or self._reset_gamma_ramps):
                continue
            recheck = False
            apply_profiles, recheck, vcgt_ramp, vcgt_ramp_hack, vcgt_values = (
                self._generate_and_apply_profiles(
                    recheck,
                    results,
                    errors,
                    i,
                    display_desc,
                    key,
                    profile_name,
                    profile_association_changed,
                    desc,
                )
            )
            if (
                not apply_profiles
                and self.__other_component[1] != "madHcNetQueueWindow"
            ):
                # Important: Do not break here because we still want to
                # detect changed profile associations
                continue
            hwnds_pids_changed = self._track_process_changes(previous_hwnds_pids)
            idle = (
                (
                    not hwnds_pids_changed
                    and not self._manual_restore
                    and not profile_association_changed
                )
                if idle
                else idle
            )
            idle, do_continue = self._check_gamma_ramp_status(
                display,
                idle,
                i,
                moninfo,
                display_desc,
                key,
                profile_association_changed,
                apply_profiles,
                vcgt_values,
                hwnds_pids_changed,
            )
            if do_continue:
                continue
            is_buggy_video_driver = self._is_buggy_video_driver(moninfo)
            if recheck:
                # Try and prevent race condition with madVR
                # launching and resetting video card gamma table
                apply_profiles = self._should_apply_profiles()
            if not apply_profiles or idle:
                # Important: Do not break here because we still want to
                # detect changed profile associations
                continue
            self._log_profile_association_changes(
                profile_associations_changed,
                profile_association_changed,
                apply_profiles,
            )
            # Now actually reload or reset calibration
            self._reload_or_reset_calibration(
                display,
                display_desc,
                profile_association_changed,
                desc,
                hwnds_pids_changed,
            )
            try:
                hdc = win32gui.CreateDC(moninfo["Device"], None, None)
            except Exception as exception:
                if exception.args != self._last_exception_args or DEBUG:
                    self._last_exception_args = exception.args
                    print(
                        "Couldn't create DC for",
                        moninfo["Device"],
                        f"({display})",
                    )
                continue
            result = self._set_device_gamma_ramp(
                i, key, vcgt_ramp, vcgt_ramp_hack, is_buggy_video_driver, hdc
            )
            self._report_calibration_status(
                results,
                errors,
                display_desc,
                profile_association_changed,
                desc,
                hwnds_pids_changed,
                result,
            )
        else:
            # We only arrive here if the loop was completed
            self._next = False
        return profile_associations_changed, apply_profiles, idle, result

    def _generate_and_apply_profiles(
        self,
        recheck: bool,
        results: list[str],
        errors: list[str],
        i: int,
        display_desc: str,
        key: str,
        profile_name: str,
        profile_association_changed: bool,
        desc: str,
    ) -> tuple[
        bool,
        bool,
        ctypes.c_ushort_Array_256_Array_3,
        ctypes.c_ushort_Array_256_Array_3,
        list[tuple[int, int, int]],
    ]:
        """Generate and apply profiles.

        Args:
            recheck (bool): Whether to recheck the gamma ramp.
            results (list[str]): The list of results.
            errors (list[str]): The list of errors.
            i (int): The display index.
            display_desc (str): The description of the display.
            key (str): The profile association key.
            profile_name (str): The profile name.
            profile_association_changed (bool): Whether the profile association
                has changed.
            desc (str): The description of the profile.

        Returns:
            tuple[
                bool,
                bool,
                ctypes.c_ushort_Array_256_Array_3,
                ctypes.c_ushort_Array_256_Array_3,
                list[tuple[int, int, int]]
            ]: A tuple containing whether to apply profiles, whether to recheck,
                the VCGT ramp, the VCGT ramp hack, and the VCGT values.
        """
        apply_profiles = self._should_apply_profiles()
        recheck, vcgt_ramp, vcgt_ramp_hack, vcgt_values = self._generate_gamma_ramp(
            recheck,
            results,
            errors,
            display_desc,
            key,
            profile_name,
            profile_association_changed,
            desc,
        )
        if self._skip:
            self.setgammaramp_success[i] = True
        return apply_profiles, recheck, vcgt_ramp, vcgt_ramp_hack, vcgt_values

    def _log_profile_association_changes(
        self,
        profile_associations_changed: int,
        profile_association_changed: bool,
        apply_profiles: bool,
    ) -> None:
        """Log profile association changes for debugging.

        Args:
            profile_associations_changed (int): The number of profile
                associations changed.
            profile_association_changed (bool): Whether the profile association
                has changed.
            apply_profiles (bool): Whether to apply profiles.
        """
        if self._manual_restore:
            debug_print("Manual restore flag:", self._manual_restore)
        if profile_association_changed:
            debug_print(
                "Number of profile associations changed:",
                profile_associations_changed,
            )
        if apply_profiles:
            debug_print("Apply profiles:", apply_profiles)

    def _set_device_gamma_ramp(
        self,
        i: int,
        key: str,
        vcgt_ramp: ctypes.c_ushort_Array_256_Array_3,
        vcgt_ramp_hack: ctypes.c_ushort_Array_256_Array_3,
        is_buggy_video_driver: bool,
        hdc: int,
    ) -> None | bool | Exception:
        """Set the device gamma ramp.

        Args:
            i (int): The display index.
            key (str): The profile association key.
            vcgt_ramp (ctypes.c_ushort_Array_256_Array_3): The VCGT ramp.
            vcgt_ramp_hack (ctypes.c_ushort_Array_256_Array_3): The VCGT ramp
                hack for buggy video drivers.
            is_buggy_video_driver (bool): Whether the video driver is buggy.
            hdc (int): The device context handle.

        Returns:
            None | bool | Exception: The result of setting the device gamma
                ramp.
        """
        try:
            if is_buggy_video_driver:
                result = self.gdi32.SetDeviceGammaRamp(hdc, vcgt_ramp_hack)
            result = self.gdi32.SetDeviceGammaRamp(hdc, vcgt_ramp)
        except Exception as exception:
            result = exception
        finally:
            win32gui.DeleteDC(hdc)
        self.setgammaramp_success[i] = (
            result
            and not isinstance(result, Exception)
            and (self._reset_gamma_ramps or bool(self.profiles.get(key)))
        )

        return result

    def _report_calibration_status(
        self,
        results: list[str],
        errors: list[str],
        display_desc: str,
        profile_association_changed: bool,
        desc: str,
        hwnds_pids_changed: bool,
        result: None | bool | Exception,
    ) -> None:
        """Report the calibration status.

        Args:
            results (list[str]): The list of results.
            errors (list[str]): The list of errors.
            display_desc (str): The description of the display.
            profile_association_changed (bool): Whether the profile association
                has changed.
            desc (str): The description of the profile.
            hwnds_pids_changed (bool): Whether the HWNDs and PIDs have changed.
            result (None | bool | Exception): The result of setting the gamma
                ramp.
        """
        if (
            self._manual_restore
            or profile_association_changed
            or (not hwnds_pids_changed and getcfg("profile_loader.check_gamma_ramps"))
        ):
            if isinstance(result, Exception) or not result:
                if result:
                    print(result)
                print(lang.getstr("failure"))
            else:
                print(lang.getstr("success"))
        if self._manual_restore or (
            profile_association_changed
            and (isinstance(result, Exception) or not result)
        ):
            if isinstance(result, Exception) or not result:
                errstr = lang.getstr("calibration.load_error")
                errors.append(f"{display_desc}: {errstr}")
            else:
                text = display_desc + ": "
                if self._reset_gamma_ramps:
                    text += lang.getstr("linear").capitalize()
                else:
                    text += desc
                results.append(text)

    def _update_profile_associations(
        self,
        display: str,
        first_run: bool,
        i: int,
        moninfo: dict,
        key: str,
        exception: Exception,
        profile_name: str,
        profile_key: str,
        association: tuple[bool, float, str],
        mtime: float,
        profile_association_changed: bool,
        profile_associations_changed: int,
    ) -> tuple[int, bool, str]:
        """Update profile associations.

        Args:
            display (str): The display name.
            first_run (bool): Whether this is the first run.
            i (int): The display index.
            moninfo (dict): The monitor information.
            key (str): The profile association key.
            exception (Exception): The exception if any.
            profile_name (str): The profile name.
            profile_key (str): The profile key.
            association (tuple[bool, float, str]): The current association.
            mtime (float): The modification time of the profile.
            profile_association_changed (bool): Whether the profile association
                has changed.
            profile_associations_changed (int): The number of profile
                associations changed.

        Returns:
            tuple[int, bool, str]: The updated number of profile associations
                changed, whether the profile association changed, and the
                description.
        """
        if association[:2] == (profile_key, mtime):
            desc = association[2]
            return profile_associations_changed, profile_association_changed, desc

        if profile_name is None:
            desc = str(exception or "?")
        else:
            desc = get_profile_desc(profile_name)
        if not first_run:
            print("A profile change has been detected")
            print(display, "->", desc)
            device = get_active_display_device(moninfo["Device"])
            if device:
                display_edid = get_display_name_edid(device, moninfo, i)
                if self.monitoring:
                    self.devices2profiles[device.DeviceKey] = (
                        display_edid,
                        profile_name,
                        desc,
                    )
            if device:
                debug_print(
                    f"Monitor {moninfo['Device']} active display device name:",
                    device.DeviceName,
                )
                debug_print(
                    f"Monitor {moninfo['Device']} active display device string:",
                    device.DeviceString,
                )
                debug_print(
                    f"Monitor {moninfo['Device']} "
                    "active display device state flags: "
                    f"0x{device.StateFlags:x}"
                )
                debug_print(
                    f"Monitor {moninfo['Device']} active display device ID:",
                    device.DeviceID,
                )
                debug_print(
                    f"Monitor {moninfo['Device']} active display device key:",
                    device.DeviceKey,
                )
            else:
                debug_print(
                    f"WARNING: Monitor {moninfo['Device']} has no active display device"
                )
        self.profile_associations[key] = (profile_key, mtime, desc)
        self.profiles[key] = None
        self.ramps[key] = (None, None, None)
        profile_association_changed = True
        profile_associations_changed += 1
        if not first_run and self._is_displaycal_running():
            # Normally calibration loading is disabled while
            # DisplayCAL is running. Override this when the
            # display has changed
            self._manual_restore = getcfg("profile.load_on_login") and 2

        return profile_associations_changed, profile_association_changed, desc

    def _generate_gamma_ramp(
        self,
        recheck: bool,
        results: list[str],
        errors: list[str],
        display_desc: str,
        key: str,
        profile_name: str,
        profile_association_changed: bool,
        desc: str,
    ) -> tuple[
        bool,
        ctypes.c_ushort_Array_256_Array_3,
        ctypes.c_ushort_Array_256_Array_3,
        tuple[list[float], list[float], list[float]],
    ]:
        """Generate the gamma ramp for the given profile.

        Args:
            recheck (bool): Whether to recheck the gamma ramp.
            results (list[str]): The list of results.
            errors (list[str]): The list of errors.
            display_desc (str): The description of the display.
            key (str): The key for the profile.
            profile_name (str): The name of the profile.
            profile_association_changed (bool): Whether the profile association
                has changed.
            desc (str): The description of the profile.

        Returns:
            tuple[
                bool,
                ctypes.c_ushort_Array_256_Array_3,
                ctypes.c_ushort_Array_256_Array_3,
                tuple[list[float], list[float], list[float]]
            ]: The recheck flag, the gamma ramp, the hack gamma ramp, and the
                VCGT values.
        """
        (vcgt_ramp, vcgt_ramp_hack, vcgt_values) = self.ramps.get(
            self._reset_gamma_ramps or key, (None, None, None)
        )
        recheck = False
        if vcgt_ramp:
            return recheck, vcgt_ramp, vcgt_ramp_hack, vcgt_values

        vcgt_values, profile = self._retrieve_profile_vcgt(key, profile_name)

        if len(vcgt_values[0]) != 256:
            # Hmm. Do we need to deal with this?
            # I've never seen table-based vcgt with != 256 entries
            if (
                not self._reset_gamma_ramps
                and (self._manual_restore or profile_association_changed)
                and profile.tags.get("vcgt")
            ):
                print(lang.getstr("calibration.loading_from_display_profile"))
                print(display_desc)
                print(
                    lang.getstr(
                        "vcgt.unknown_format",
                        os.path.basename(profile.filename),
                    )
                )
                print(lang.getstr("failure"))
                results.append(display_desc)
                errors.append(
                    lang.getstr(
                        "vcgt.unknown_format",
                        os.path.basename(profile.filename),
                    )
                )
                # Fall back to linear calibration
            tag_data = b"vcgt"
            tag_data += b"\0" * 4  # Reserved
            tag_data += b"\0\0\0\x01"  # Formula type
            for _channel in range(3):
                tag_data += b"\0\x01\0\0"  # Gamma 1.0
                tag_data += b"\0" * 4  # Min 0.0
                tag_data += b"\0\x01\0\0"  # Max 1.0
            vcgt = VideoCardGammaFormulaType(tag_data, "vcgt")
            vcgt_values = vcgt.get_values()[:3]
            if self._reset_gamma_ramps:
                print("Caching linear gamma ramps")
            else:
                print("Caching implicit linear gamma ramps for profile", desc)
        else:
            print("Caching gamma ramps for profile", desc)
            # Convert vcgt to ushort_Array_256_Array_3
        vcgt_ramp = ((ctypes.c_ushort * 256) * 3)()
        vcgt_ramp_hack = ((ctypes.c_ushort * 256) * 3)()
        for j in range(len(vcgt_values[0])):
            for k in range(3):
                vcgt_value = vcgt_values[k][j][1]
                vcgt_ramp[k][j] = vcgt_value
                # Some video drivers won't reload gamma ramps if
                # the previously loaded calibration was the same.
                # Work-around by first loading a slightly changed
                # gamma ramp.
                if j == 0:
                    vcgt_value += 1
                vcgt_ramp_hack[k][j] = vcgt_value
        self.ramps[self._reset_gamma_ramps or key] = (
            vcgt_ramp,
            vcgt_ramp_hack,
            vcgt_values,
        )
        recheck = True
        return recheck, vcgt_ramp, vcgt_ramp_hack, vcgt_values

    def _retrieve_profile_vcgt(
        self,
        key: str,
        profile_name: str,
    ) -> tuple[tuple[list[float], list[float], list[float]], None | ICCProfile]:
        """Retrieve the VCGT values from the profile.

        Args:
            key (str): The key for the profile.
            profile_name (str): The name of the profile.
        """
        vcgt_values = ([], [], [])
        # in the pre-refactored code at this point the `profile` variable is
        # undefined, so we need to define it here to avoid UnboundLocalError.
        # and trust that it will be properly set later in the code.
        profile = None
        if self._reset_gamma_ramps:
            return vcgt_values, profile

        # Get display profile
        if not self.profiles.get(key):
            try:
                self.profiles[key] = ICCProfile(profile_name)
                if (
                    isinstance(
                        self.profiles[key].tags.get("MS00"),
                        WcsProfilesTagType,
                    )
                    and "vcgt" not in self.profiles[key].tags
                ):
                    self.profiles[key].tags["vcgt"] = (
                        self.profiles[key].tags["MS00"].get_vcgt()
                    )
                self.profiles[key].tags.get("vcgt")
            except Exception as exception:
                print(exception)
                self.profiles[key] = ICCProfile()
        profile = self.profiles[key]
        if isinstance(profile.tags.get("vcgt"), VideoCardGammaType):
            # Get display profile vcgt
            vcgt_values = profile.tags.vcgt.get_values()[:3]
            # Quantize to n bits
            # 8 bits can be encoded accurately in a 256-entry
            # 16 bit vcgt, but all other bitdepths need to be
            # quantized in such a way that the encoded 16-bit
            # values lie as close as possible to the ideal ones.
            # We assume the graphics subsystem quantizes using
            # integer truncating from the 16 bit encoded value
            if self._quantize < 65535.0:
                smooth = str(getcfg("profile_loader.quantize_bits")) in getcfg(
                    "profile_loader.smooth_bits"
                ).split(";")
                smooth_window = (0.5, 1, 1, 1, 0.5)
                for points in vcgt_values:
                    quantized = [
                        round(point[1] / 65535.0 * self._quantize) for point in points
                    ]
                    if smooth:
                        # Smooth and round to nearest again
                        quantized = [
                            round(v) for v in smooth_avg(quantized, 1, smooth_window)
                        ]
                    for k, point in enumerate(points):
                        point[1] = math.ceil(quantized[k] / self._quantize * 65535)

        return vcgt_values, profile

    def _track_process_changes(
        self,
        previous_hwnds_pids: set[tuple[int, int]],
    ) -> bool:
        """Track changes in running processes.

        Args:
            previous_hwnds_pids (set[tuple[int, int]]): Previous set of HWND
                and PID tuples.
        """
        if not getcfg("profile_loader.track_other_processes"):
            return getcfg("profile_loader.ignore_unchanged_gamma_ramps")

        hwnds_pids_changed = self._hwnds_pids != previous_hwnds_pids
        if hwnds_pids_changed and previous_hwnds_pids:
            debug_print("List of running processes changed")
            hwnds_pids_diff = previous_hwnds_pids.difference(self._hwnds_pids)
            if hwnds_pids_diff:
                debug_print("Gone processes:")
                for hwnd_pid in hwnds_pids_diff:
                    debug_print(*hwnd_pid)
            hwnds_pids_diff = self._hwnds_pids.difference(previous_hwnds_pids)
            if hwnds_pids_diff:
                debug_print("New processes:")
                for hwnd_pid in hwnds_pids_diff:
                    debug_print(*hwnd_pid)

        return hwnds_pids_changed

    def _check_gamma_ramp_status(
        self,
        display: str,
        idle: bool,
        i: int,
        moninfo: dict,
        display_desc: str,
        key: str,
        profile_association_changed: bool,
        apply_profiles: bool,
        vcgt_values: tuple[list[float], list[float], list[float]],
        hwnds_pids_changed: bool,
    ) -> tuple[bool, bool]:
        """Check the gamma ramp status.

        Args:
            display (str): Display identifier.
            idle (bool): Indicates if the system is idle.
            i (int): Monitor index.
            moninfo (dict): Monitor information.
            display_desc (str): Display description.
            key (str): Profile key.
            profile_association_changed (bool): Indicates if profile
                association changed.
            apply_profiles (bool): Indicates if profiles should be applied.
            vcgt_values (tuple[list[float], list[float], list[float]]): VCGT values.
            hwnds_pids_changed (bool): Indicates if HWNDs and PIDs have changed.

        Returns:
            tuple[bool, bool]: Updated idle status and a flag indicating
                whether to continue.
        """
        if (
            not self._manual_restore
            and not profile_association_changed
            and (idle or self.__other_component[1] == "madHcNetQueueWindow")
            and getcfg("profile_loader.check_gamma_ramps")
        ):
            # Get video card gamma ramp
            try:
                hdc = win32gui.CreateDC(moninfo["Device"], None, None)
            except Exception as exception:
                if exception.args != self._last_exception_args or DEBUG:
                    self._last_exception_args = exception.args
                    print(
                        "Couldn't create DC for",
                        moninfo["Device"],
                        f"({display})",
                    )
                # continue
                return idle, True

            ramp: ctypes.c_ushort_Array_256_Array_3 = ((ctypes.c_ushort * 256) * 3)()
            try:
                result = self.gdi32.GetDeviceGammaRamp(hdc, ramp)
            except Exception as e:
                print("Handled exception:", e)
                # continue
                return idle, True
            finally:
                win32gui.DeleteDC(hdc)

            if not result:
                # continue
                return idle, True

            ramp_values, do_return = self._get_ramp_values(
                ramp, key, display, i, vcgt_values
            )
            if do_return:
                return idle, True

            # Check if video card matches profile vcgt
            if (
                not hwnds_pids_changed
                and ramp_values == vcgt_values
                and i in self.setgammaramp_success
            ):
                idle = True
                # continue
                return idle, True

            idle = False
            if apply_profiles and not hwnds_pids_changed:
                print(lang.getstr("vcgt.mismatch", display_desc))
        return idle, False

    def _get_ramp_values(
        self,
        ramp: ctypes.c_ushort_Array_256_Array_3,
        key: str,
        display: str,
        i: int,
        vcgt_values: tuple[list[float], list[float], list[float]],
    ) -> tuple[list, bool]:
        # Get ramp values
        values = ([], [], [])
        for j, channel in enumerate(ramp):
            for k, v in enumerate(channel):
                values[j].append([float(k), v])
        if self.__other_component[1] == "madHcNetQueueWindow":
            madvr_reset_cal = self._madvr_reset_cal.get(key, True)
            if not madvr_reset_cal and values == self.linear_vcgt_values:
                # madVR has reset vcgt
                self._madvr_reset_cal[key] = True
                print(
                    f"madVR did reset gamma ramps for {display}, "
                    "do not preserve calibration state"
                )
            elif (
                madvr_reset_cal
                and values == vcgt_values
                and values != self.linear_vcgt_values
            ):
                # madVR did not reset vcgt
                self._madvr_reset_cal[key] = False
                print(
                    f"madVR did not reset gamma ramps for {display}, "
                    "preserve calibration state"
                )
                self.setgammaramp_success[i] = True
            if self._madvr_reset_cal.get(key, True) != madvr_reset_cal:
                if self._madvr_reset_cal.get(key, True):
                    msg = lang.getstr(
                        "app.detected.calibration_loading_disabled",
                        self._component_name,
                    )
                    self.notify([msg], [], True, False)
                    # continue
                    return None, True
                self.notify([], [], True, False)
        return values, False

    def _reload_or_reset_calibration(
        self,
        display: str,
        display_desc: str,
        profile_association_changed: bool,
        desc: str,
        hwnds_pids_changed: bool,
    ) -> None:
        """Reload or reset calibration.

        Args:
            display (str): Display identifier.
            display_desc (str): Display description.
            profile_association_changed (bool): Indicates if profile
                association changed.
            desc (str): Profile description.
            hwnds_pids_changed (bool): Indicates if the list of
                running processes changed.
        """
        if (
            self._manual_restore
            or profile_association_changed
            or (not hwnds_pids_changed and getcfg("profile_loader.check_gamma_ramps"))
        ):
            if self._reset_gamma_ramps:
                print(lang.getstr("calibration.resetting"))
                print(display_desc)
            else:
                print(lang.getstr("calibration.loading_from_display_profile"))
                print(f"{display_desc}:", desc)
        elif VERBOSE > 1 and getcfg("profile_loader.track_other_processes"):
            print("Preserving calibration state for display", display)

    def _handle_profile_updates(
        self,
        first_run: bool,
        apply_profiles: bool,
        displaycal_running: bool,
        results: list[str],
        errors: list[str],
        idle: bool,
        profile_associations_changed: bool,
    ) -> None:
        """Handle profile updates and notify user.

        Args:
            first_run (bool): Indicates if this is the first run.
            apply_profiles (bool): Indicates if profiles should be applied.
            displaycal_running (bool): Indicates if DisplayCAL is running.
            results (list): List to store result messages.
            errors (list): List to store error messages.
            idle (bool): Indicates if the system is idle.
            profile_associations_changed (bool): Indicates if profile
                associations changed.
        """
        if results or errors:
            if results:
                self.reload_count += 1
                lstr = (
                    "calibration.reset_success"
                    if self._reset_gamma_ramps
                    else "calibration.load_success"
                )
                results.insert(0, lang.getstr(lstr))
                if self._app_detection_msg:
                    results.insert(0, self._app_detection_msg)
                    self._app_detection_msg = None
            self.notify(
                results,
                errors,
                show_notification=bool(not first_run or errors)
                and self.__other_component[1] != "madHcNetQueueWindow",
            )
        else:
            # if (apply_profiles != self.__apply_profiles or
            # profile_associations_changed):
            if (
                not idle
                and apply_profiles
                and (not profile_associations_changed or not self._reset_gamma_ramps)
            ):
                self.reload_count += 1
            if displaycal_running != self._is_displaycal_running():
                msg = lang.getstr(
                    "app.detection_lost.calibration_loading_enabled"
                    if displaycal_running
                    else "app.detected.calibration_loading_disabled",
                    APPNAME,
                )
                displaycal_running = self._is_displaycal_running()
                print(msg)
                self.notify([msg], [], displaycal_running, show_notification=False)
            elif (
                apply_profiles != self.__apply_profiles or profile_associations_changed
            ):
                wx.CallAfter(lambda: self and self.taskbar_icon.set_visual_state())

    def shutdown(self) -> None:
        """Shut down the profile loader."""
        if self._shutdown:
            return
        self._shutdown = True
        print("Shutting down profile loader")
        if self.monitoring:
            print("Shutting down display configuration monitoring thread")
            self.monitoring = False
        if getcfg("profile_loader.fix_profile_associations"):
            self._reset_display_profile_associations()
        self.writecfg()
        if getattr(self, "taskbar_icon", None):
            if self.taskbar_icon.menu:
                self.taskbar_icon.menu.Destroy()
            self.taskbar_icon.RemoveIcon()
        if getattr(self, "frame", None):
            self.frame.listening = False

    def _enumerate_monitors(self) -> None:
        """Enumerate display adapters and devices."""
        print("-" * 80)
        print("Enumerating display adapters and devices:")
        print("")
        self.adapters = {
            device.DeviceName: device for device in get_display_devices(None)
        }
        self.monitors = []
        self.display_devices = {}
        self.child_devices_count = {}
        # Enumerate per-adapter devices
        for adapter in self.adapters:
            for i, device in enumerate(get_display_devices(adapter)):
                if not i:
                    device0 = device
                if device.DeviceKey in self.display_devices:
                    continue
                display, edid = get_display_name_edid(device)
                self.display_devices[device.DeviceKey] = [
                    display,
                    edid,
                    device,
                    device0,
                ]
        # Enumerate monitors
        try:
            monitors = get_real_display_devices_info()
        except Exception:
            import traceback

            print(traceback.format_exc())
            monitors = []
            self.setgammaramp_success[0] = False
        self._active_displays = []
        self._process_monitor_info(monitors)
        self._print_display_device_info()

    def _process_monitor_info(self, monitors: list[dict]) -> None:
        """Process monitor information.

        Args:
            monitors (list[dict]): List of monitor information dictionaries.
        """
        for i, moninfo in enumerate(monitors):
            if moninfo["Device"] == "WinDisc":
                # If e.g. we physically disconnect the display device, we will
                # get a 'WinDisc' temporary monitor we cannot do anything with
                # (MS, why is this not documented?)
                print(f"Skipping 'WinDisc' temporary monitor {i:d}")
                continue
            moninfo["_adapter"] = self.adapters.get(
                moninfo["Device"], ADict({"DeviceString": moninfo["Device"][4:]})
            )
            if self._is_buggy_video_driver(moninfo):
                print(
                    "Buggy video driver detected: {}.".format(
                        moninfo["_adapter"].DeviceString
                    ),
                    "Gamma ramp hack activated.",
                )
            device = get_active_display_device(moninfo["Device"])
            if device:
                self._active_displays.append(device.DeviceKey)
            debug_print(
                "Found monitor {:d} {} flags 0x{:x}".format(
                    i, moninfo["Device"], moninfo["Flags"]
                )
            )
            if device:
                debug_print(
                    f"Monitor {i:d} active display device name:", device.DeviceName
                )
                debug_print(
                    f"Monitor {i:d} active display device string:",
                    device.DeviceString,
                )
                debug_print(
                    f"Monitor {i:d} active display device state flags: "
                    f"0x{device.StateFlags:x}"
                )
                debug_print(f"Monitor {i:d} active display device ID:", device.DeviceID)
                debug_print(
                    f"Monitor {i:d} active display device key:", device.DeviceKey
                )
            else:
                debug_print(f"WARNING: Monitor {i:d} has no active display device")
            # Get monitor descriptive string
            display, edid = get_display_name_edid(device, moninfo, i)
            debug_print(f"Monitor {i:d} active display description:", display)
            debug_print(
                "Enumerating 1st display device for monitor {:d} {}".format(
                    i, moninfo["Device"]
                )
            )
            try:
                device0 = win32api.EnumDisplayDevices(moninfo["Device"], 0)
            except pywintypes.error as exception:
                print(
                    f"EnumDisplayDevices({moninfo['Device']!r}, 0) failed:",
                    exception,
                )
                device0 = None
            if device0:
                debug_print(
                    f"Monitor {i:d} 1st display device name:", device0.DeviceName
                )
                debug_print(
                    f"Monitor {i:d} 1st display device string:", device0.DeviceString
                )
                debug_print(
                    f"Monitor {i:d} 1st display device state flags: "
                    f"0x{device0.StateFlags:x}"
                )
                debug_print(f"Monitor {i:d} 1st display device ID:", device0.DeviceID)
                debug_print(
                    f"Monitor {i:d} 1st display device key:", device0.DeviceKey
                )
                display0, edid0 = get_display_name_edid(device0)
                debug_print(f"Monitor {i:d} 1st display description:", display0)
            if (
                device0
                and (not device or device0.DeviceKey != device.DeviceKey)
                and device0.DeviceKey not in self.display_devices
            ):
                # Key may not exist if device was added after enumerating
                # per-adapters devices
                self.display_devices[device0.DeviceKey] = [
                    display0,
                    edid0,
                    device0,
                    device0,
                ]
            if device:
                if device.DeviceKey in self.display_devices:
                    self.display_devices[device.DeviceKey][0] = display
                else:
                    # Key may not exist if device was added after enumerating
                    # per-adapters devices
                    self.display_devices[device.DeviceKey] = [
                        display,
                        edid,
                        device,
                        device0,
                    ]
            self.monitors.append((display, edid, moninfo, device))

    def _print_display_device_info(self) -> None:
        """Print information about display devices."""
        for display, _edid, device, device0 in self.display_devices.values():
            if device0 and device.DeviceKey == device0.DeviceKey:
                device_name = "\\".join(device.DeviceName.split("\\")[:-1])
                print(
                    self.adapters.get(
                        device_name, ADict({"DeviceString": device_name[4:]})
                    ).DeviceString
                )
            display_parts = display.split("@", 1)
            if len(display_parts) > 1:
                info = display_parts[1].split(" - ", 1)
                display_parts[1] = "@" + " ".join(info[:1])
            if not device.StateFlags & DISPLAY_DEVICE_ACTIVE:
                display_parts.append(" ({})".format(lang.getstr("deactivated")))
            print("  |-", "".join(display_parts))

    def _enumerate_windows_callback(self, hwnd: int, extra: object) -> None:
        """Callback for enumerating windows.

        Args:
            hwnd (int): Handle to the window.
            extra (object): Extra parameter (not used).
        """
        cls = win32gui.GetClassName(hwnd)
        if cls == "madHcNetQueueWindow" or self._is_known_window_class(cls):
            try:
                thread_id, pid = win32process.GetWindowThreadProcessId(hwnd)
                filename: str = get_process_filename(pid)
            except pywintypes.error:
                return
            self._hwnds_pids.add((filename, pid, thread_id, hwnd))
            basename = os.path.basename(filename)
            if basename.lower() != "madhcctrl.exe" and pid != self._pid:
                self.__other_component = filename, cls, 0

    def _is_known_window_class(self, cls: type) -> bool:
        """Check if the window class is known and should be monitored.

        Args:
            cls (type): The window class to check.

        Returns:
            bool: True if the window class is known, False otherwise.
        """
        return any(partial in cls for partial in self._known_window_classes)

    def _is_buggy_video_driver(self, moninfo: dict) -> bool:
        """Check if the video driver is buggy and requires a gamma ramp hack.

        Args:
            moninfo (dict): Monitor information dictionary containing adapter
                info.

        Returns:
            bool: True if the video driver is buggy, False otherwise.
        """
        # Intel video drivers won't reload gamma ramps if the
        # previously loaded calibration was the same.
        # Work-around by first loading a slightly changed
        # gamma ramp.
        adapter = moninfo["_adapter"].DeviceString.lower()
        for buggy_video_driver in self._buggy_video_drivers:
            if buggy_video_driver == "*" or buggy_video_driver in adapter:
                return True
        return False

    def _is_displaycal_running(self) -> bool:
        """Check if DisplayCAL is running by looking for its lock file.

        Returns:
            bool: True if DisplayCAL is running, False otherwise.
        """
        displaycal_lockfile = os.path.join(CONFIG_HOME, APPBASENAME + ".lock")
        return os.path.isfile(displaycal_lockfile)

    def _is_other_running(
        self,
        enumerate_windows_and_processes: bool = True,
    ) -> bool:
        """Determine if other software that may be using the videoLUT is in use.

        (e.g. madVR video playback, madTPG, other calibration software)

        Args:
            enumerate_windows_and_processes (bool): Whether to enumerate
                windows and processes to determine if other software is
                running. This should be True at launch, and False when called
                from the madVR callback.

        Returns:
            bool: True if other software is running, False otherwise.
        """
        if sys.platform != "win32":
            return None
        self._is_other_running_lock.acquire()
        if enumerate_windows_and_processes:
            # At launch, we won't be able to determine if madVR is running via
            # the callback API, and we can only determine if another
            # calibration solution is running by enumerating windows and
            # processes anyway.
            other_component = self.__other_component
            self.__other_component = None, None, 0
            # Look for known window classes
            # Performance on C2D 3.16 GHz (Win7 x64, ~ 90 processes): ~ 1ms
            self._hwnds_pids = set()
            try:
                win32gui.EnumWindows(self._enumerate_windows_callback, None)
            except pywintypes.error as exception:
                print("Enumerating windows failed:", exception)
            if (
                not self.__other_component[1]
                or self.__other_component[1] == "madHcNetQueueWindow"
            ):
                self._look_for_known_processes(other_component)
            if other_component != self.__other_component:
                self._handle_component_detection(other_component)
        result = (
            self.__other_component[0:2] != (None, None)
            and not self.__other_component[2]
        )
        self._is_other_running_lock.release()
        if self.__other_component[1] == "madHcNetQueueWindow":
            if enumerate_windows_and_processes:
                # Check if gamma ramps were reset for current display
                return self._madvr_reset_cal.get(self._current_display_key, True)
            # Check if gamma ramps were reset for any display
            return len(self._madvr_reset_cal) < len(self.monitors) or True in list(
                self._madvr_reset_cal.values()
            )
        return result

    def _look_for_known_processes(
        self, other_component: tuple[None | str, None | str, int]
    ) -> None:
        """Look for known processes that may be using the videoLUT.

        Args:
            other_component (tuple): A tuple containing the filename,
                window class, and reset flag of the other component.
        """
        # Look for known processes
        # Performance on C2D 3.16 GHz (Win7 x64, ~ 90 processes):
        # ~ 6-9ms (1ms to get PIDs)
        try:
            processes = win32ts.WTSEnumerateProcesses()
        except pywintypes.error as exception:
            print("Enumerating processes failed:", exception)
            return

        skip = False
        for _session_id, pid, basename, user_security_id in processes:
            name_lower = basename.lower()
            if name_lower != "madhcctrl.exe":
                # Add all processes except madVR Home Cinema Control
                self._hwnds_pids.add((basename, pid))
            if skip or not user_security_id:
                # Skip detection if other component already detected
                # or no user security ID (if no user SID, it's a
                # system process and we cannot get process filename
                # due to access restrictions)
                continue
            known_app = name_lower in self._known_apps
            has_exception = name_lower in self._exception_names
            if not (known_app or has_exception):
                continue
            try:
                filename = get_process_filename(pid)
            except (OSError, pywintypes.error) as exception:
                if exception.args[0] not in (
                    winerror.ERROR_ACCESS_DENIED,
                    winerror.ERROR_PARTIAL_COPY,
                    winerror.ERROR_INVALID_PARAMETER,
                    winerror.ERROR_GEN_FAILURE,
                ):
                    print(
                        f"Couldn't get filename of process {pid}:",
                        exception,
                    )
                continue
            enabled, reset, path = self._exceptions.get(filename.lower(), (0, 0, ""))
            if known_app or enabled:
                if known_app or not reset:
                    self.__other_component = filename, None, 0
                    skip = True
                elif other_component != (filename, None, reset):
                    self._reset_gamma_ramps = True
                self.__other_component = filename, None, reset

    def _handle_component_detection(
        self,
        other_component: tuple[None | str, None | str, int],
    ) -> None:
        """Handle detection of other software using the videoLUT.

        Args:
            other_component (tuple): A tuple containing the filename,
                window class, and reset flag of the other component.
        """
        if other_component[2] and not self.__other_component[2]:
            self._reset_gamma_ramps = bool(
                config.getcfg("profile_loader.reset_gamma_ramps")
            )
        check = (not other_component[2] and not self.__other_component[2]) or (
            other_component[2]
            and self.__other_component[0:2] != (None, None)
            and not self.__other_component[2]
        )
        if check:
            if self.__other_component[0:2] == (None, None):
                lstr = "app.detection_lost.calibration_loading_enabled"
                component = other_component
                sticky = False
            else:
                lstr = "app.detected.calibration_loading_disabled"
                component = self.__other_component
                sticky = True
        elif self.__other_component[0:2] != (None, None):
            lstr = "app.detected"
            component = self.__other_component
        else:
            lstr = "app.detection_lost"
            component = other_component
        if component[1] == "madHcNetQueueWindow":
            component_name = "madVR"
            self._madvr_reset_cal = {}
        elif component[0]:
            component_name = os.path.basename(component[0])
            try:
                info = list(get_file_info(component[0])["StringFileInfo"].values())
            except Exception:
                info = None
            if info:
                # Use FileDescription over ProductName (the former may
                # be more specific, e.g. Windows Display Color
                # Calibration, the latter more generic, e.g. Microsoft
                # Windows)
                component_name = info[0].get(
                    "FileDescription",
                    info[0].get("ProductName", component_name),
                )
        else:
            component_name = lang.getstr("unknown")
        if self.__other_component[0:2] != (None, None):
            self._component_name = component_name
        else:
            self._component_name = None
        msg = lang.getstr(lstr, component_name)
        print(msg)
        if check:
            self.notify([msg], [], sticky, show_notification=component_name != "madVR")
        else:
            self._app_detection_msg = msg
            self._manual_restore = getcfg("profile.load_on_login") and 2

    def _madvr_connection_callback(
        self,
        param: int,
        connection: object,
        ip: str,
        pid: int,
        module: str,
        component: str,
        instance: int,
        is_new_instance: bool,
    ) -> None:
        """Callback for madVR connection events.

        Args:
            param (int): Parameter passed to the callback.
            connection (object): Connection object.
            ip (str): IP address of the madVR instance.
            pid (int): Process ID of the madVR instance.
            module (str): Module name of the madVR instance.
            component (str): Component name of the madVR instance.
            instance (int): Instance number of the madVR instance.
            is_new_instance (bool): True if this is a new madVR instance, False
                if it is a disconnection.
        """
        if self.lock.locked():
            print("Waiting to acquire lock...")
        with self.lock:
            print("Acquired lock")
            if ip not in ("127.0.0.1", "localhost", "::1", "0:0:0:0:0:0:0:1"):
                print("Releasing lock")
                return
            args = (param, connection, ip, pid, module, component, instance)
            try:
                filename = get_process_filename(pid)
            except Exception:
                filename = lang.getstr("unknown")
            if is_new_instance:
                apply_profiles = self._should_apply_profiles(manual_override=None)
                self._madvr_instances.append(args)
                self.__other_component = filename, "madHcNetQueueWindow", 0
                print("madVR instance connected:", "PID", pid, filename)
                if apply_profiles:
                    msg = lang.getstr(
                        "app.detected.calibration_loading_disabled", component
                    )
                    print(msg)
                    self.notify([msg], [], True, show_notification=False)
                wx.CallAfter(wx.CallLater, 1500, self._check_madvr_reset_cal, args)
            elif args in self._madvr_instances:
                self._madvr_instances.remove(args)
                print("madVR instance disconnected:", "PID", pid, filename)
                if not self._madvr_instances and self._should_apply_profiles(
                    manual_override=None
                ):
                    msg = lang.getstr(
                        "app.detection_lost.calibration_loading_enabled", component
                    )
                    print(msg)
                    self.notify([msg], [], show_notification=False)
            print("Releasing lock")

    def _check_madvr_reset_cal(self, madvr_instance: tuple) -> None:
        """Check if madVR reset the video card gamma tables.

        Args:
            madvr_instance (tuple): A tuple containing madVR instance details
                (param, connection, ip, pid, module, component, instance).
        """
        if madvr_instance not in self._madvr_instances:
            return
        # Check if madVR did reset the video card gamma tables.
        # If it didn't, assume we can keep preserving calibration state
        with self.lock:
            self._next = True

    def _reset_display_profile_associations(self) -> None:
        """Reset display profile associations for all monitors."""
        if not self._can_fix_profile_associations():
            return
        for devicekey in self.devices2profiles:
            display_edid, profile, desc = self.devices2profiles[devicekey]
            if devicekey not in self._fixed_profile_associations or not profile:
                continue
            try:
                current_profile = get_display_profile(
                    path_only=True, devicekey=devicekey
                )
            except Exception as exception:
                print(
                    "Could not get display profile for display "
                    f"device {devicekey} ({display_edid[0]}):",
                    exception,
                )
                continue
            if not current_profile:
                continue
            current_profile = os.path.basename(current_profile)
            if not current_profile or current_profile == profile:
                continue
            print(
                f"Resetting profile association for {display_edid[0]}:",
                current_profile,
                "->",
                profile,
            )
            try:
                if not is_superuser() and not per_user_profiles_isenabled(
                    devicekey=devicekey
                ):
                    # Can only associate profiles to the display if
                    # per-user-profiles are enabled or if running as admin
                    enable_per_user_profiles(devicekey=devicekey)
                set_display_profile(profile, devicekey=devicekey)
                unset_display_profile(current_profile, devicekey=devicekey)
            except OSError as exception:
                print(exception)

    def _set_display_profiles(self, dry_run: bool = False) -> None:
        """Set display profiles for all monitors.

        Args:
            dry_run (bool): If True, do not actually set the profiles.
        """
        if not self._can_fix_profile_associations():
            return
        debug_print("-" * 80)
        debug_print("Checking profile associations")
        debug_print("")
        self.devices2profiles = {}
        for i, (display, _edid, moninfo, _device) in enumerate(self.monitors):
            debug_print(
                f"Enumerating display devices for monitor {i:d} {moninfo['Device']}"
            )
            devices = get_display_devices(moninfo["Device"])
            if not devices:
                debug_print(f"WARNING: Monitor {i:d} has no display devices")
                continue
            active_device = get_active_display_device(None, devices=devices)
            self._log_active_device_info(i, active_device)
            self._map_device_to_profile(moninfo, devices, active_device)
            self._set_active_profile(active_device, display, devices, dry_run)

    def _log_active_device_info(self, i: int, active_device: PyDISPLAY_DEVICE) -> None:
        """Log information about the active display device.

        Args:
            i (int): Monitor index.
            active_device (PyDISPLAY_DEVICE): The active display device.
        """
        if active_device:
            debug_print(
                f"Monitor {i:d} active display device name:",
                active_device.DeviceName,
            )
            debug_print(
                f"Monitor {i:d} active display device string:",
                active_device.DeviceString,
            )
            debug_print(
                f"Monitor {i:d} active display device state flags: "
                f"0x{active_device.StateFlags:x}"
            )
            debug_print(
                f"Monitor {i:d} active display device ID:",
                active_device.DeviceID,
            )
            debug_print(
                f"Monitor {i:d} active display device key:",
                active_device.DeviceKey,
            )
        else:
            debug_print(f"WARNING: Monitor {i:d} has no active display device")

    def _map_device_to_profile(
        self,
        moninfo: PyDISPLAY_DEVICE,
        devices: list[PyDISPLAY_DEVICE],
        active_device: PyDISPLAY_DEVICE,
    ) -> None:
        """Map display devices to their profiles.

        Args:
            moninfo (PyDISPLAY_DEVICE): Monitor information.
            devices: List of display devices.
            active_device: The active display device.
        """
        for device in devices:
            if active_device and device.DeviceID == active_device.DeviceID:
                active_moninfo = moninfo
            else:
                active_moninfo = None
            display_edid = get_display_name_edid(device, active_moninfo)
            try:
                profile = get_display_profile(
                    path_only=True, devicekey=device.DeviceKey
                )
            except Exception as exception:
                print(
                    "Could not get display profile for display "
                    f"device {device.DeviceKey} ({display_edid[0]}):",
                    exception,
                )
                profile = None
            if profile:
                profile = os.path.basename(profile)
            self.devices2profiles[device.DeviceKey] = (
                display_edid,
                profile,
                get_profile_desc(profile),
            )
            debug_print(f"{display_edid[0]} ({device.DeviceKey}): {profile}")

    def _set_active_profile(
        self,
        active_device: PyDISPLAY_DEVICE,
        display: str,
        devices: list[PyDISPLAY_DEVICE],
        dry_run: bool = False,
    ) -> None:
        """Set the active profile for a display device.

        Args:
            active_device (PyDISPLAY_DEVICE): The active display device.
            display (str): The display name.
            devices (list[PyDISPLAY_DEVICE]): List of display devices.
            dry_run (bool): If True, do not actually set the profile.
        """
        # Set the active profile
        device = active_device
        if not device:
            return
        try:
            correct_profile = get_display_profile(
                path_only=True, devicekey=device.DeviceKey
            )
        except Exception as exception:
            print(
                "Could not get display profile for active display "
                f"device {device.DeviceKey} ({display}):",
                exception,
            )
            return
        if correct_profile:
            correct_profile = os.path.basename(correct_profile)
        device = devices[0]
        current_profile = self.devices2profiles[device.DeviceKey][1]
        if correct_profile and current_profile != correct_profile and not dry_run:
            print(
                f"Fixing profile association for {display}:",
                current_profile,
                "->",
                correct_profile,
            )
            try:
                if not is_superuser() and not per_user_profiles_isenabled(
                    devicekey=device.DeviceKey
                ):
                    # Can only associate profiles to the display if
                    # per-user-profiles are enabled or if running as admin
                    enable_per_user_profiles(devicekey=device.DeviceKey)
                set_display_profile(
                    os.path.basename(correct_profile), devicekey=device.DeviceKey
                )
            except OSError as exception:
                print(exception)
            else:
                self._fixed_profile_associations.add(device.DeviceKey)

    def _set_manual_restore(self, event: wx.Event, manual_restore: bool = True) -> None:
        """Set the manual restore state for profile loading.

        Args:
            event (wx.Event): The event that triggered this method.
            manual_restore (bool): Whether to set the manual restore state.
        """
        if event:
            print("Menu command:", end=" ")
        print("Set calibration state to load profile vcgt")
        if self.lock.locked():
            print("Waiting to acquire lock...")
        with self.lock:
            print("Acquired lock")
            setcfg("profile_loader.reset_gamma_ramps", 0)
            self._manual_restore = manual_restore
            self._reset_gamma_ramps = False
            print("Releasing lock")
        self.taskbar_icon.set_visual_state()
        self.writecfg()

    def _set_reset_gamma_ramps(
        self, event: wx.Event, manual_restore: bool = True
    ) -> None:
        """Set the calibration state to reset video card gamma ramps.

        Args:
            event (wx.Event): The event that triggered this method.
            manual_restore (bool): Whether to set the manual restore state.
        """
        if event:
            print("Menu command:", end=" ")
        print("Set calibration state to reset vcgt")
        if self.lock.locked():
            print("Waiting to acquire lock...")
        with self.lock:
            print("Acquired lock")
            setcfg("profile_loader.reset_gamma_ramps", 1)
            self._manual_restore = manual_restore
            self._reset_gamma_ramps = True
            print("Releasing lock")
        self.taskbar_icon.set_visual_state()
        self.writecfg()

    def _should_apply_profiles(
        self,
        enumerate_windows_and_processes: bool = True,
        manual_override: int = 2,
    ) -> bool:
        """Determine if profiles should be applied.

        Args:
            enumerate_windows_and_processes (bool, optional): Whether to
                enumerate windows and processes to check if other software is
                running.
            manual_override (int, optional): Manual override for profile
                loading state:

                    0: Do not load profiles
                    1: Load profiles
                    2: Load profiles with manual restore.

                Defaults to 2.

        Returns:
            bool: True if profiles should be applied, False otherwise.
        """
        displaycal_running = self._is_displaycal_running()
        if displaycal_running:
            enumerate_windows_and_processes = False
        return (
            not self._is_other_running(enumerate_windows_and_processes)
            and (not displaycal_running or self._manual_restore == manual_override)
            and not self._skip
            and (
                "--force" in sys.argv[1:]
                or self._manual_restore
                or (
                    config.getcfg("profile.load_on_login")
                    and (
                        sys.platform != "win32"
                        or not calibration_management_isenabled()
                    )
                )
            )
        )

    def _toggle_fix_profile_associations(
        self, event: wx.Event, parent: wx.Window = None
    ) -> bool:
        """Toggle the fix profile associations setting.

        Args:
            event (wx.Event): The event that triggered this method.
            parent (wx.Window): The parent window for the dialog (optional).

        Returns:
            bool: True if the fix profile associations setting is enabled,
                  False otherwise.
        """
        if event:
            print("Menu command:", end=" ")
        print("Toggle fix profile associations", event.IsChecked())
        if event.IsChecked():
            dlg = FixProfileAssociationsDialog(self, parent)
            self.fix_profile_associations_dlg = dlg
            result = dlg.ShowModal()
            dlg.Destroy()
            if result == wx.ID_CANCEL:
                print("Cancelled toggling fix profile associations")
                return False
        if self.lock.locked():
            print("Waiting to acquire lock...")
        with self.lock:
            print("Acquired lock")
            setcfg("profile_loader.fix_profile_associations", int(event.IsChecked()))
            if event.IsChecked():
                self._set_display_profiles()
            else:
                self._reset_display_profile_associations()
            self._manual_restore = True
            self.writecfg()
            print("Releasing lock")
        return event.IsChecked()

    def _set_exceptions(self) -> None:
        """Set exceptions for profile loading."""
        self._exceptions = {}
        self._exception_names = set()
        exceptions = config.getcfg("profile_loader.exceptions").strip()
        if exceptions:
            print("Exceptions:")
        for exception in exceptions.split(";"):
            exception = exception.split(":", 2)
            if len(exception) < 3:
                # Malformed, ignore
                continue
            for i in range(2):
                try:
                    exception[i] = int(exception[i])
                except Exception:
                    exception[i] = 0
            enabled, reset, path = exception
            key = path.lower()
            self._exceptions[key] = (enabled, reset, path)
            self._exception_names.add(os.path.basename(key))
            print(
                f"Enabled={bool(enabled)}",
                "Action={}".format((reset and "Reset") or "Disable"),
                path,
            )

    def _set_profile_associations(self, event: wx.Event) -> None:
        """Open the profile associations dialog.

        Args:
            event (wx.Event): The event that triggered this method.
        """
        if event:
            print("Menu command:", end=" ")
        print("Set profile associations")
        dlg = ProfileAssociationsDialog(self)
        self.profile_associations_dlg = dlg
        dlg.Center()
        dlg.ShowModalThenDestroy()

    def writecfg(self) -> None:
        """Write configuration to the config file."""
        config.writecfg(
            module="apply-profiles",
            options=("argyll.dir", "profile.load_on_login", "profile_loader"),
        )


def get_display_name_edid(
    device: Device,
    moninfo: None | dict = None,
    index: None | int = None,
    include_adapter: bool = False,
) -> tuple[str, dict]:
    """Return display name and EDID information.

    Args:
        device (Device): The display device object.
        moninfo (None | dict, optional): Monitor information dictionary.
            Defaults to None
        index (None | int, optional): Index of the display. Defaults to None.
        include_adapter (bool, optional): Whether to include adapter
            information. Defaults to False.

    Returns:
        tuple[str, dict]: Display name and EDID information.
    """
    edid = {}
    if device:
        display = str(device.DeviceString)
        with contextlib.suppress(Exception):
            edid = get_edid(device=device)
    else:
        display = lang.getstr("unknown")
    display = edid.get("monitor_name", display)
    if moninfo:
        m_left, m_top, m_right, m_bottom = moninfo["Monitor"]
        m_width = m_right - m_left
        m_height = m_bottom - m_top
        display = f"{display} @ {m_left:d}, {m_top:d}, {m_width:d}x{m_height:d}"
        if moninfo["Flags"] & MONITORINFOF_PRIMARY:
            display += " [PRIMARY]"
        if moninfo.get("_adapter") and include_adapter:
            display += " - {}".format(moninfo["_adapter"].DeviceString)
    if index is not None:
        display = f"{index + 1:d}. {display}"
    return display, edid


def get_profile_desc(
    profile_path: str, include_basename_if_different: bool = True
) -> str:
    """Return profile description or path if not available.

    Args:
        profile_path (str): Path to the ICC profile.
        include_basename_if_different (bool, optional): Whether to include the
            basename if it is different from the profile description.

    Returns:
        str: Profile description or path.
    """
    if not profile_path:
        return ""
    try:
        profile = ICCProfile(profile_path)
        profile_desc = profile.getDescription()
    except Exception as exception:
        if not isinstance(exception, IOError):
            exception = traceback.format_exc()
        print(f"Could not get description of profile {profile_path}:", exception)
    else:
        basename = os.path.basename(profile_path)
        name = os.path.splitext(basename)[0]
        if (
            basename != profile_desc
            and include_basename_if_different
            and name not in (profile_desc, safe_asciize(profile_desc))
        ):
            return f"{profile_desc} ({basename})"
        return profile_desc
    return profile_path


def main() -> None:
    """Main function for the profile loader application."""
    unknown_option = filter_unknown_args()

    if "--help" in sys.argv[1:] or unknown_option:
        if unknown_option:
            print(
                f"{os.path.basename(sys.argv[0])}: "
                f"unrecognized option `{unknown_option}'"
            )
            if sys.platform == "win32":
                BaseApp._run_exitfuncs()
        display_help_message()
    elif "-V" in sys.argv[1:] or "--version" in sys.argv[1:]:
        print(f"{os.path.basename(sys.argv[0])} {VERSION_STRING}")
    else:
        if sys.platform == "win32":
            setup_profile_loader_task(EXE, EXEDIR, PYDIR)

        config.initcfg("apply-profiles")

        if (
            "--force" not in sys.argv[1:]
            and not config.getcfg("profile.load_on_login")
            and sys.platform != "win32"
        ):
            # Early exit incase profile loading has been disabled and isn't forced
            sys.exit()

        if "--error-dialog" in sys.argv[1:]:
            config.setcfg("profile_loader.error.show_msg", 1)
            config.writecfg(
                module="apply-profiles",
                options=("argyll.dir", "profile.load_on_login", "profile_loader"),
            )

        global lang
        from DisplayCAL import localization as lang

        lang.init()

        ProfileLoader()


def filter_unknown_args() -> None | str:
    """Filter unknown command-line arguments.

    Returns:
        None | str: The first unknown argument found, or None if all arguments
            are recognized.
    """
    unknown_option = None
    for arg in sys.argv[1:]:
        if (
            arg
            not in (
                "--debug",
                "-d",
                "--help",
                "--force",
                "-V",
                "--version",
                "--skip",
                "--test",
                "-t",
                "--verbose",
                "--verbose=1",
                "--verbose=2",
                "--verbose=3",
                "-v",
            )
            and (
                arg not in ("--oneshot", "--task", "--profile-associations")
                or sys.platform != "win32"
            )
            and (
                arg not in ("--verify", "--silent", "--error-dialog")
                or sys.platform == "win32"
            )
        ):
            unknown_option = arg
            break
    return unknown_option


def display_help_message() -> None:
    """Display help message for the profile loader application."""
    print(f"Usage: {os.path.basename(sys.argv[0])} [OPTION]...")
    print("Apply profiles to configured display devices and load calibration")
    print(f"Version {VERSION_STRING}")
    print("")
    print("Options:")
    print("  --help           Output this help text and exit")
    print("  --force          Force loading of calibration/profile (if it has been")
    print(f"                   disabled in {APPNAME}.ini)")
    print("  --skip           Skip initial loading of calibration")
    if sys.platform == "win32":
        print("  --oneshot        Exit after loading calibration")
        print("  -V, --version    Output version information and exit")
        print("")
        import textwrap

        print(
            "Configuration options ({}):".format(
                os.path.join(CONFIG_HOME, APPBASENAME + "-apply-profiles.ini")
            )
        )
        print("")
        for cfgname, cfgdefault in sorted(config.DEFAULTS.items()):
            if (
                not cfgname.startswith("profile_loader.")
                and cfgname != "profile.load_on_login"
            ):
                continue
            # Documentation
            key = cfgname.split(".", 1)[1]
            cfgdoc = {
                "load_on_login": "Apply calibration state on login and preserve",
                "buggy_video_drivers": (
                    "List of buggy video driver names (case "
                    "insensitive, delimiter: ';')"
                ),
                "check_gamma_ramps": (
                    "Check if video card gamma table has "
                    "changed and reapply calibration state if so"
                ),
                "exceptions": (
                    "List of exceptions (case "
                    "insensitive, delimiter: ';', format: "
                    "<enabled [0|1]>:<reset video card gamma table [0|1]:"
                    "<executable path>)"
                ),
                "fix_profile_associations": "Automatically fix profile asociations",
                "ignore_unchanged_gamma_ramps": (
                    "Ignore unchanged gamma table, i.e. reapply "
                    "calibration state even if no change (only "
                    "effective if profile_loader."
                    "track_other_processes = 0)"
                ),
                "quantize_bits": "Quantize video card gamma table to <n> bits",
                "reset_gamma_ramps": "Reset video card gamma table to linear",
                "track_other_processes": (
                    "Reapply calibration state when other processes launch or exit"
                ),
                "tray_icon_animation_quality": "Tray icon animation quality, 0 = off",
            }.get(key)

            if cfgdoc is None:
                continue
            # Name and valid values
            valid = config.VALID_VALUES.get(cfgname)
            if valid:
                valid = "[{}]".format("|".join(f"{v}" for v in valid))
            else:
                valid = config.VALID_RANGES.get(cfgname)
                if valid:
                    valid = "[{}..{}]".format(*tuple(valid))
                elif isinstance(cfgdefault, int):
                    # Boolean
                    valid = "[0|1]"
                elif isinstance(cfgdefault, str):
                    # String
                    valid = "<string>"
                    cfgdefault = f"'{cfgdefault}'"
                else:
                    valid = ""
            print(cfgname.ljust(45, " "), valid)
            cfgdoc += f" [Default: {cfgdefault}]."
            for line in textwrap.fill(cfgdoc, 75).splitlines():
                print(" " * 4 + line.rstrip())
    else:
        print("  --verify         Verify if calibration was loaded correctly")
        print("  --silent         Do not show dialog box on error")
        print("  --error-dialog   Force dialog box on error")
        print("  -V, --version    Output version information and exit")


if __name__ == "__main__":
    main()
