"""This is a collection of carefully selected utility functions.

Not everything should be an util: for example, if it's only used in one file,
then it should be defined in that file. Specifically, I believe everything in
this file should have most of these properties:

* The util is a lot of code or it does something that is hard to get right.
* The util is used in multiple files.
* The util is significantly easier to use than just doing what it does internally.
* The util and its usages as a whole should be less code than what would be needed without the util.

It's fine if one or two properties aren't quite true, but most of them should be.

tl;dr: Please think twice before adding new things to this file.
"""
from __future__ import annotations

import codecs
import collections
import contextlib
import dataclasses
import functools
import json
import logging
import re
import shlex
import shutil
import subprocess
import sys
import threading
import tkinter
import traceback
from collections.abc import Iterator
from pathlib import Path
from tkinter import ttk
from typing import TYPE_CHECKING, Any, Callable, Literal, TypeVar, cast

import dacite

import porcupine

log = logging.getLogger(__name__)
_T = TypeVar("_T")


# On Windows, sys.executable can point to pythonw.exe or Porcupine.exe.
# Neither of those behaves quite like you would expect Python to behave.
# In those cases, `python_executable` points at a plain old `python.exe`.
#
# Note that python_executable (or sys.executable for that matter) shouldn't be
# used to run the user's Python programs. The user could have multiple different
# Python installations or venvs.
if sys.platform == "win32" and sys.executable.endswith((r"\Porcupine.exe", r"\pythonw.exe")):
    python_executable = Path(sys.executable).parent / "python.exe"
else:
    python_executable = Path(sys.executable)


def quote(string: str) -> str:
    """Add quotes around an argument of a command.

    This function is equivalent to :func:`shlex.quote` on non-Windows systems,
    and on Windows it adds double quotes in a similar way. This is useful for
    running commands in the Windows command prompt or a POSIX-compatible shell.
    """
    if sys.platform == "win32":
        return subprocess.list2cmdline([string])
    else:
        return shlex.quote(string)


# TODO: document this?
def format_command(command: str, substitutions: dict[str, Any]) -> list[str]:
    parts = shlex.split(command, posix=(sys.platform != "win32"))
    return [part.format_map(substitutions) for part in parts]


# Using these with subprocess prevents opening unnecessary cmd windows
# TODO: document this
subprocess_kwargs: dict[str, Any] = {}
if sys.platform == "win32":
    # https://stackoverflow.com/a/1813893
    subprocess_kwargs["startupinfo"] = subprocess.STARTUPINFO(
        dwFlags=subprocess.STARTF_USESHOWWINDOW
    )


def _is_empty(iterator: Iterator[object]) -> bool:
    try:
        next(iterator)
        return False
    except StopIteration:
        return True


def find_project_root(project_file_path: Path) -> Path:
    """Given an absolute path to a file, figure out what project it belongs to.

    This is documented in user-doc/projects.md.
    """
    assert project_file_path.is_absolute()

    likely_root = None
    for path in project_file_path.parents:
        if (path / ".git").exists():
            return path  # trust this the most, if it exists
        elif likely_root is None and (
            (path / ".editorconfig").exists()
            or not _is_empty(path.glob("readme.*"))
            or not _is_empty(path.glob("Readme.*"))
            or not _is_empty(path.glob("readMe.*"))
            or not _is_empty(path.glob("ReadMe.*"))
            or not _is_empty(path.glob("README.*"))
        ):
            likely_root = path

    return likely_root or project_file_path.parent


# https://github.com/python/typing/issues/769
def copy_type(f: _T) -> Callable[[Any], _T]:
    return lambda x: x


class _TooltipManager:
    # This needs to be shared by all instances because there's only one
    # mouse pointer.
    tipwindow: tkinter.Toplevel | None = None

    def __init__(self, widget: tkinter.Widget, text: str) -> None:
        widget.bind("<Enter>", self.enter, add=True)
        widget.bind("<Leave>", self.leave, add=True)
        widget.bind("<Motion>", self.motion, add=True)
        self.widget = widget
        self.got_mouse = False
        self.text = text  # can be changed after creating tooltip manager

    @classmethod
    def destroy_tipwindow(cls, junk_event: tkinter.Event[tkinter.Misc] | None = None) -> None:
        if cls.tipwindow is not None:
            cls.tipwindow.destroy()
            cls.tipwindow = None

    def enter(self, event: tkinter.Event[tkinter.Misc]) -> None:
        # For some reason, toplevels get also notified of their
        # childrens' events.
        if event.widget is self.widget:
            self.destroy_tipwindow()
            self.got_mouse = True
            self.widget.after(1000, self.show)

    def leave(self, event: tkinter.Event[tkinter.Misc]) -> None:
        if event.widget is self.widget:
            self.destroy_tipwindow()
            self.got_mouse = False

    def motion(self, event: tkinter.Event[tkinter.Misc]) -> None:
        self.mousex = event.x_root
        self.mousey = event.y_root

    def show(self) -> None:
        if self.got_mouse:
            self.destroy_tipwindow()
            tipwindow = type(self).tipwindow = tkinter.Toplevel()
            tipwindow.bind("<Motion>", self.destroy_tipwindow, add=True)
            tipwindow.overrideredirect(True)

            # If you modify this, make sure to always define either no
            # colors at all or both foreground and background. Otherwise
            # the label will have light text on a light background or
            # dark text on a dark background on some systems.
            tkinter.Label(tipwindow, text=self.text, border=3, fg="black", bg="white").pack()
            tipwindow.update_idletasks()
            screen_width = tipwindow.winfo_screenwidth()
            to_end = screen_width - self.mousex
            tip_width = tipwindow.winfo_width()
            if to_end >= tip_width / 2:
                offset = int(tip_width / 2)
            else:
                offset = int(tip_width - to_end)
            tipwindow.geometry(f"+{self.mousex - offset}+{self.mousey - 30}")


def set_tooltip(widget: tkinter.Widget, text: str) -> None:
    """A simple tooltip implementation with tkinter.

    After calling ``set_tooltip(some_widget, "hello")``, "hello" will be
    displayed in a small window when the user moves the mouse over the
    widget and waits for 1 second.

    """
    try:
        manager: _TooltipManager = cast(Any, widget)._tooltip_manager
    except AttributeError:
        cast(Any, widget)._tooltip_manager = _TooltipManager(widget, text)
        return
    manager.text = text
    manager.show()


class PanedWindow(tkinter.PanedWindow):
    """Like :class:`tkinter.PanedWindow`, but uses Ttk colors.

    Do not waste your time with ``ttk.Panedwindow``. It lacks options to
    control the sizes of the panes.
    """

    @copy_type(tkinter.PanedWindow.__init__)
    def __init__(self, *args: Any, **kwargs: Any) -> None:
        super().__init__(*args, **kwargs)
        # even non-ttk widgets can handle <<ThemeChanged>>
        self.bind("<<ThemeChanged>>", self._update_colors, add=True)
        self._update_colors()

    def _update_colors(self, junk_event: object = None) -> None:
        ttk_bg = self.tk.eval("ttk::style lookup TLabel.label -background")
        assert ttk_bg
        self["bg"] = ttk_bg


# TODO: document this?
def is_bright(color: str) -> bool:
    widget = porcupine.get_main_window()  # any widget would do
    return sum(widget.winfo_rgb(color)) / 3 > 0x7FFF


# i know, i shouldn't do math with rgb colors, but this is good enough
def invert_color(color: str, *, black_or_white: bool = False) -> str:
    """Return a color with opposite red, green and blue values.

    Example: ``invert_color('white')`` is ``'#000000'`` (black).

    This function uses tkinter for converting the color to RGB. That's
    why a tkinter root window must have been created, but *color* can be
    any Tk-compatible color string, like a color name or a ``'#rrggbb'``
    string. The return value is always a ``'#rrggbb`` string (also compatible
    with Tk).

    If ``black_or_white=True`` is set, then the result is always ``"#000000"``
    (black) or ``"#ffffff"`` (white), depending on whether the color is bright
    or dark.
    """
    if black_or_white:
        return "#000000" if is_bright(color) else "#ffffff"

    widget = porcupine.get_main_window()  # any widget would do

    # tkinter uses 16-bit colors, convert them to 8-bit
    r, g, b = (value >> 8 for value in widget.winfo_rgb(color))
    return f"#{0xFF - r:02x}{0xFF - g:02x}{0xFF - b:02x}"


def mix_colors(color1: str, color2: str, color1_amount: float) -> str:
    """Create a new color based on two existing colors.

    The ``color1_amount`` should be a number between 0 and 1, specifying how
    much ``color1`` to use. If you set it to 0.8, for example, then the
    resulting color will be 80% ``color1`` and 20% ``color2``.

    Colors are specified and returned similarly to :func:`invert_color`.
    """
    color2_amount = 1 - color1_amount

    widget = porcupine.get_main_window()
    r, g, b = (
        round(color1_amount * value1 + color2_amount * value2)
        for value1, value2 in zip(widget.winfo_rgb(color1), widget.winfo_rgb(color2))
    )
    return f"#{r >> 8:02x}{g >> 8:02x}{b >> 8:02x}"  # convert back to 8-bit


# This doesn't handle all possible cases, see bind(3tk)
def _format_binding(binding: str, menu: bool) -> str:
    mac = porcupine.get_main_window().tk.eval("tk windowingsystem") == "aqua"
    parts = binding.lstrip("<").rstrip(">").split("-")

    # don't know how to show click in mac menus
    if mac and menu and any(parts[i : i + 2] == "Button-1".split("-") for i in range(len(parts))):
        return ""

    # Must recompute length on every iteration, because length changes
    i = 0
    while i < len(parts):
        if parts[i : i + 3] == ["Double", "Button", "1"]:
            parts[i : i + 3] = ["double-click"]
        elif parts[i : i + 2] == ["Button", "1"] or parts[i : i + 2] == ["ButtonRelease", "1"]:
            parts[i : i + 2] = ["click"]
        elif re.fullmatch(r"[a-z]", parts[i]):
            parts[i] = parts[i].upper()
        elif re.fullmatch(r"[A-Z]", parts[i]):
            parts.insert(i, "Shift")
            # Increment beyond the added "Shift" and letter
            i += 2
            continue

        i += 1

    if "Key" in parts:
        parts.remove("Key")

    if mac:
        # event_info() returns <Mod1-Key-x> for <Command-x>
        parts = [{"Mod1": "Command", "plus": "+", "minus": "-"}.get(part, part) for part in parts]

    if mac:
        # <ThePhilgrim> I think it's like from left to right... so it would be shift -> ctrl -> alt -> cmd
        sort_order = {"Shift": 1, "Control": 2, "Alt": 3, "Command": 4}
        symbol_mapping = {
            "Shift": "⇧",
            "Control": "⌃",  # NOT same as ascii "^"
            "Alt": "⌥",
            "Command": "⌘",
            "Return": "⏎",
        }
    else:
        sort_order = {"Control": 1, "Alt": 2, "Shift": 3}
        symbol_mapping = {
            "Control": "Ctrl",
            "0": "Zero",  # not needed on mac, its font distinguishes 0 and O well
            "plus": "Plus",
            "minus": "Minus",
            "Return": "Enter",
        }
    parts.sort(key=(lambda part: sort_order.get(part, 100)))

    if mac and menu:
        # Tk will use the proper symbols automagically, and it expects dash-separated
        # Even "Command--" for command and minus key works
        return "-".join(parts)

    parts = [symbol_mapping.get(part, part) for part in parts]

    if mac:
        # e.g. "⌘-double-click"
        # But not like this:  ["double-click"] --> ["-double-click"]
        parts[1:] = [
            {"click": "-click", "double-click": "-double-click"}.get(part, part)
            for part in parts[1:]
        ]

    return ("" if mac else "+").join(parts)


# TODO: document this
def get_binding(virtual_event: str, *, menu: bool = False, many: bool = False) -> str:
    bindings = porcupine.get_main_window().event_info(virtual_event)

    if not bindings and not menu:
        log.warning(f"no bindings configured for {virtual_event}")
    results = [_format_binding(b, menu) for b in bindings]
    if not many:
        del results[1:]
    return " or ".join(results)


# TODO: document this
def tkinter_safe_string(string: str, *, hide_unsupported_chars: bool = False) -> str:
    if hide_unsupported_chars:
        replace_with = ""
    else:
        replace_with = "\N{replacement character}"

    return "".join(replace_with if ord(char) > 0xFFFF else char for char in string)


class EventDataclass:
    """
    Inherit from this class when creating a dataclass for
    :func:`bind_with_data`.

    All values should be JSON safe or data classes containing JSON safe values.
    Nested dataclasses don't need to inherit from EventDataclass. Example::

        import dataclasses
        from typing import List
        from porcupine import utils

        @dataclasses.dataclass
        class Foo:
            message: str
            num: int

        @dataclasses.dataclass
        class Bar(utils.EventDataclass):
            foos: List[Foo]

        def handle_event(event: utils.EventWithData) -> None:
            print(event.data_class(Bar).foos[0].message)

        utils.bind_with_data(some_widget, '<<Thingy>>', handle_event, add=True)
        ...
        foos = [Foo('ab', 123), Foo('cd', 456)]
        some_widget.event_generate('<<Thingy>>', data=Bar(foos))

    Note that before Python 3.10, you need ``List[str]`` instead of
    ``list[str]``, even if you use ``from __future__ import annotations``. This
    is because Porcupine uses a library that needs to evaluate the type
    annotations even if ``from __future__ import annotations``
    was used.
    """

    def __str__(self) -> str:
        # str(Foo(a=1, b=2)) --> 'Foo{"a": 1, "b": 2}'
        # Content after Foo is JSON parsed in Event.data_class()
        return type(self).__name__ + json.dumps(dataclasses.asdict(self))  # type: ignore


if TYPE_CHECKING:
    _Event = tkinter.Event[tkinter.Misc]
else:
    _Event = tkinter.Event


class EventWithData(_Event):
    """A subclass of :class:`tkinter.Event[tkinter.Misc]` for use with :func:`bind_with_data`."""

    #: If a string was passed to the ``data`` argument of ``event_generate()``,
    #: then this is that string.
    data_string: str

    def data_class(self, T: type[_T]) -> _T:
        """
        If a dataclass instance of type ``T`` was passed as ``data`` to
        ``event_generate()``, then this returns a copy of it. Otherwise this
        raises an error.

        ``T`` must be a dataclass that inherits from :class:`EventDataclass`.
        """
        assert self.data_string.startswith(T.__name__ + "{")
        result = dacite.from_dict(T, json.loads(self.data_string[len(T.__name__) :]))
        assert isinstance(result, T)
        return result

    def __repr__(self) -> str:
        match = re.fullmatch(r"<(.*)>", super().__repr__())
        assert match is not None
        return f"<{match.group(1)} data_string={self.data_string!r}>"


def bind_with_data(
    widget: tkinter.Misc,
    sequence: str,
    callback: Callable[[EventWithData], str | None],
    add: bool = False,
) -> str:
    """
    Like ``widget.bind(sequence, callback)``, but supports the ``data``
    argument of ``event_generate()``. Note that the callback takes an argument
    of type :class:`EventWithData` rather than a usual ``tkinter.Event[tkinter.Misc]``.

    Here's an example::

        from porcupine import utils

        def handle_event(event: utils.EventWithData):
            print(event.data_string)

        utils.bind_with_data(some_widget, '<<Thingy>>', handle_event, add=True)

        # this prints 'wut wut'
        some_widget.event_generate('<<Thingy>>', data='wut wut')

    Note that everything is a string in Tcl, so tkinter ``str()``'s the data.
    """
    # tkinter creates event objects normally and appends them to the
    # deque, then run_callback() adds data_blablabla attributes to the
    # event objects and runs callback(event)
    #
    # TODO: is it possible to do this without a deque?
    event_objects: collections.deque[tkinter.Event[tkinter.Misc]] = collections.deque()
    widget.bind(sequence, event_objects.append, add=add)

    def run_the_callback(data_string: str) -> str | None:
        event: tkinter.Event[tkinter.Misc] | EventWithData = event_objects.popleft()
        event.__class__ = EventWithData  # evil haxor muhaha
        assert isinstance(event, EventWithData)
        event.data_string = data_string
        return callback(event)  # may return 'break'

    # tkinter's bind() ignores the add argument when the callback is a string :(
    funcname = widget.register(run_the_callback)
    widget.tk.call("bind", widget, sequence, '+ if {"[%s %%d]" == "break"} break' % funcname)
    return funcname


def add_scroll_command(
    widget: tkinter.Text,
    option: Literal["xscrollcommand", "yscrollcommand"],
    callback: Callable[[], None],
) -> None:
    """Schedule ``callback`` to run with no arguments when ``widget`` is scrolled.

    The option should be ``'xscrollcommand'`` for horizontal scrolling or
    ``'yscrollcommand'`` for vertical scrolling.

    Unlike when setting the option directly, this function can be called
    multiple times with the same widget and the same option to set multiple
    callbacks.
    """
    if not widget[option]:
        widget[option] = lambda *args: None
    tcl_code = widget[option]
    assert isinstance(tcl_code, str)
    assert tcl_code

    # from options(3tk): "... the widget will generate a Tcl command by
    # concatenating the scroll command and two numbers."
    #
    # So if tcl_code is like this:  bla bla bla
    #
    # it would be called like this:  bla bla bla 0.123 0.456
    #
    # and by putting something in front on separate line we can make it get called like this
    #
    #   something
    #   bla bla bla 0.123 0.456
    widget[option] = widget.register(callback) + "\n" + tcl_code


# this is not bind_tab to avoid confusing with tabs.py, as in browser tabs
def bind_tab_key(
    widget: tkinter.Widget, on_tab: Callable[[tkinter.Event[Any], bool], Any], **bind_kwargs: Any
) -> None:
    """A convenience function for binding Tab and Shift+Tab.

    Use this function like this::

        def on_tab(event, shifted):
            # shifted is True if the user held down shift while pressing
            # tab, and False otherwise
            ...

        utils.bind_tab_key(some_widget, on_tab, add=True)

    The ``event`` argument and ``on_tab()`` return values are treated
    just like with regular bindings.

    Binding ``'<Tab>'`` works just fine everywhere, but binding
    ``'<Shift-Tab>'`` only works on Windows and Mac OSX. This function
    also works on X11.
    """

    # there's something for this in more_functools, but it's a big
    # dependency for something this simple imo
    def callback(shifted: bool, event: tkinter.Event[tkinter.Misc]) -> Any:
        return on_tab(event, shifted)

    if widget.tk.call("tk", "windowingsystem") == "x11":
        # even though the event keysym says Left, holding down the right
        # shift and pressing tab also works :D
        shift_tab = "<ISO_Left_Tab>"
    else:
        shift_tab = "<Shift-Tab>"

    widget.bind("<Tab>", functools.partial(callback, False), **bind_kwargs)  # noqa: TK141
    widget.bind(shift_tab, functools.partial(callback, True), **bind_kwargs)  # noqa: TK141


# Must be in a function because lambdas and local variables are ... inconvenient
def _associate_another_widget_with_a_radiobutton(
    other: ttk.Label | ttk.Entry, radio: ttk.Radiobutton
) -> None:
    other.bind("<Enter>", lambda e: radio.event_generate("<Enter>"), add=True)
    other.bind("<Leave>", lambda e: radio.event_generate("<Leave>"), add=True)
    other.bind("<Button-1>", lambda e: radio.invoke(), add=True)


# TODO: document this?
def ask_line_ending(
    old_line_ending: porcupine.settings.LineEnding,
) -> porcupine.settings.LineEnding:
    top = tkinter.Toplevel(name="choose_line_ending")
    top.resizable(False, False)
    top.transient(porcupine.get_main_window())
    top.title("Choose a line ending")

    big_frame = ttk.Frame(top)
    big_frame.pack(fill="both", expand=True)
    ttk.Label(big_frame, text="Choose how line endings should be saved:").pack(
        fill="x", padx=5, pady=5
    )

    options: list[tuple[str, str, str]] = [
        (
            "LF",
            "LF line endings (Unix)",
            "Newline characters will be saved to the file as the LF byte (\\n)."
            " This is the line ending used by most Unix-like operating systems,"
            " such as Linux and MacOS,"
            " and usually the preferred line ending in projects that use Git.",
        ),
        (
            "CRLF",
            "CRLF line endings (Windows)",
            "Each newline will be saved to the file as two bytes,"
            " CR (\\r) followed by LF (\\n)."
            " CRLF is Porcupine's default line ending on Windows,"
            " and the only line ending supported by many Windows programs."
            "\n\nCommitting files to Git with CRLF is usually considered bad style."
            " If you use CRLF in projects that use Git,"
            " make sure to configure Git to convert the line endings"
            " so that your CRLF line endings appear as LF line endings"
            " for other people working on the project.",
        ),
        (
            "CR",
            "CR line endings (???)",
            "I don't know when this option could be useful,"
            " but it is provided in case you have some use case that I didn't think of.",
        ),
    ]

    var = tkinter.StringVar(value=old_line_ending.name)
    for line_ending_name, short_text, long_text in options:
        radio = ttk.Radiobutton(big_frame, variable=var, value=line_ending_name, text=short_text)
        radio.pack(fill="x", padx=(10, 0), pady=(10, 0))
        label = ttk.Label(big_frame, wraplength=450, text=long_text)
        label.pack(fill="x", padx=(50, 10), pady=(0, 10))
        _associate_another_widget_with_a_radiobutton(label, radio)

        if line_ending_name == old_line_ending.name:
            radio.focus()

    ttk.Label(
        big_frame,
        text=(
            "Consider setting the line ending in a project-specific .editorconfig file"
            " if your project uses an unusual choice of line endings."
        ),
    )

    ttk.Button(big_frame, text="OK", command=top.destroy, width=15).pack(
        side="right", padx=10, pady=10
    )
    top.bind("<Escape>", (lambda e: top.destroy()), add=True)

    top.wait_window()
    return porcupine.settings.LineEnding[var.get()]


# TODO: document this?
def ask_encoding(text: str, old_encoding: str) -> str | None:
    if porcupine.get_main_window().tk.call("winfo", "exists", ".choose_encoding"):
        porcupine.get_main_window().nametowidget(".choose_encoding").destroy()

    dialog = tkinter.Toplevel(name="choose_encoding")
    if porcupine.get_main_window().winfo_viewable():
        dialog.transient(porcupine.get_main_window())
    dialog.resizable(False, False)
    dialog.title("Choose an encoding")

    label_width = 400

    big_frame = ttk.Frame(dialog)
    big_frame.pack(fill="both", expand=True)
    ttk.Label(big_frame, text=text, wraplength=label_width).pack(fill="x", padx=10, pady=10)

    options: list[tuple[str, str]] = [
        ("UTF-8", "By far the most commonly used encoding. Supports all Unicode characters."),
        (
            "Latin-1",
            "Supports only 256 different characters, but never fails to open a file. Also known as ISO 8859-1.",
        ),
    ]

    radio_var = tkinter.StringVar(value="other")  # which item selected: "UTF-8", "Latin-1", "other"
    entry_var = tkinter.StringVar(value=old_encoding)  # text of entry

    for name, description in options:
        radio = ttk.Radiobutton(big_frame, variable=radio_var, value=name, text=name)
        radio.pack(fill="x", padx=(10, 0), pady=(10, 0))
        label = ttk.Label(big_frame, wraplength=label_width - (50 + 10), text=description)
        label.pack(fill="x", padx=(50, 10), pady=(0, 10))
        _associate_another_widget_with_a_radiobutton(label, radio)

    other_frame = ttk.Frame(big_frame)
    other_frame.pack(side="top", fill="x", padx=10, pady=10)
    other_radio = ttk.Radiobutton(
        other_frame, variable=radio_var, value="other", text="Other encoding:"
    )
    other_radio.pack(side="left")
    entry = ttk.Entry(other_frame, textvariable=entry_var)
    entry.pack(side="left", padx=5)
    _associate_another_widget_with_a_radiobutton(entry, other_radio)

    # Set UI to old encoding. Treat e.g. "utf8" as meaning "UTF-8".
    for name, description in options:
        if codecs.lookup(name) == codecs.lookup(old_encoding):
            radio_var.set(name)
            break
    else:
        radio_var.set("other")

    ttk.Label(
        big_frame,
        text=(
            "You can create a project-specific .editorconfig file to change the encoding permanently."
        ),
        wraplength=label_width,
    ).pack(fill="x", padx=10, pady=(30, 10))

    button_frame = ttk.Frame(big_frame)
    button_frame.pack(fill="x", pady=10)

    selected_encoding = None

    def select_encoding() -> None:
        nonlocal selected_encoding
        if radio_var.get() == "other":
            selected_encoding = entry_var.get()
        else:
            selected_encoding = radio_var.get()
        dialog.destroy()

    cancel_button = ttk.Button(button_frame, text="Cancel", command=dialog.destroy, width=1)
    cancel_button.pack(side="left", expand=True, fill="x", padx=10)
    ok_button = ttk.Button(button_frame, text="OK", command=select_encoding, width=1)
    ok_button.pack(side="right", expand=True, fill="x", padx=10)

    def update_ui(*junk: object) -> None:
        if radio_var.get() == "other":
            entry.config(state="normal")
        else:
            entry.config(state="disabled")

        valid = True
        if radio_var.get() == "other":
            try:
                codecs.lookup(entry_var.get())
            except LookupError:
                valid = False

        ok_button.config(state=("normal" if valid else "disabled"))

    radio_var.trace_add("write", update_ui)
    entry_var.trace_add("write", update_ui)
    update_ui()

    entry.bind("<Return>", (lambda event: ok_button.invoke()), add=True)
    entry.bind("<Escape>", (lambda event: cancel_button.invoke()), add=True)
    entry.select_range(0, "end")
    entry.focus()

    dialog.wait_window()
    return selected_encoding


def run_in_thread(
    blocking_function: Callable[[], _T],
    done_callback: Callable[[bool, str | _T], None],
    *,
    check_interval_ms: int = 100,
    daemon: bool = True,
) -> None:
    """Run ``blocking_function()`` in another thread.

    If the *blocking_function* raises an error,
    ``done_callback(False, traceback)`` will be called where *traceback*
    is the error message as a string. If no errors are raised,
    ``done_callback(True, result)`` will be called where *result* is the
    return value from *blocking_function*. The *done_callback* is always
    called from Tk's main loop, so it can do things with Tkinter widgets
    unlike *blocking_function*.

    Internally, this function checks whether the thread has completed every
    100 milliseconds by default (so 10 times per second). Specify
    *check_interval_ms* to customize this.

    Unlike :class:`threading.Thread`, this function uses a daemon thread by
    default. This means that the thread will end forcefully when Porcupine
    exits, and it might not get a chance to finish whatever it is doing. Pass
    ``daemon=False`` to change this.
    """
    root = porcupine.get_main_window()  # any widget would do

    value: _T
    error_traceback: str | None = None

    def thread_target() -> None:
        nonlocal value
        nonlocal error_traceback

        try:
            value = blocking_function()
        except Exception:
            error_traceback = traceback.format_exc()

    def check() -> None:
        if thread.is_alive():
            # let's come back and check again later
            root.after(check_interval_ms, check)
        else:
            if error_traceback is None:
                done_callback(True, value)
            else:
                done_callback(False, error_traceback)

    thread = threading.Thread(target=thread_target, daemon=daemon)
    thread.start()
    root.after_idle(check)


@copy_type(open)
@contextlib.contextmanager
def backup_open(file: Any, *args: Any, **kwargs: Any) -> Any:
    """Like :func:`open`, but uses a backup file if needed.

    This is useless with modes like ``'r'`` because they don't modify
    the file, but this is useful when overwriting the user's files.

    This needs to be used as a context manager. For example::

        try:
            with utils.backup_open(cool_file, 'w') as file:
                ...
        except (UnicodeError, OSError):
            # log the error and report it to the user

    This automatically restores from the backup on failure.
    """
    path = Path(file)
    if path.exists():
        # there's something to back up
        #
        # for backing up foo.py:
        # if foo-backup.py, then use foo-backup-backup.py etc
        backuppath = path
        while backuppath.exists():
            backuppath = backuppath.with_name(backuppath.stem + "-backup" + backuppath.suffix)

        log.info(f"backing up '{path}' to '{backuppath}'")
        shutil.copy(path, backuppath)

        try:
            yield path.open(*args, **kwargs)
        except Exception as e:
            log.info(f"restoring '{path}' from the backup")
            shutil.move(str(backuppath), str(path))
            raise e
        else:
            log.info(f"deleting '{backuppath}'")
            backuppath.unlink()

    else:
        yield path.open(*args, **kwargs)
