#!/usr/bin/env python3
"""Cookworm GUI

An application for editing the word list and popdefs in BookWorm Deluxe.

Copyright 2026 Wilbur Jaywright d.b.a. Marswide BGL.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

S.D.G."""

from os import path as op
from queue import Queue
import shutil
import sys
import threading
import tkinter as tk
from tkinter import filedialog
from tkinter import messagebox as mb
from tkinter import simpledialog as dialog
from tkinter import ttk
import time
import webbrowser
from . import utils
from . import config_io
from . import theme
from . import gui_heavy_ops
from . import info

# The path of the script file's containing folder
OP_PATH = op.dirname(__file__)

BACKUP_FILEEXT = ".bak"  # Suffix for backup files

# Miscellanious GUI settings
WINDOW_TITLE = info.PROGRAM_NAME
UNSAVED_WINDOW_TITLE = "*" + WINDOW_TITLE
STATUS_TICK_DELAY_MS = 100
THREAD_WAITING_TICK_DELAY_MS = 100

# Index with int(<is rare?>)
RARE_COLS = (theme.COLORS["paper"], theme.COLORS["sapphire"])
RARITY_DISP_PREFIX = "Rarity: "
RARITY_DISP_MAX = 8
NO_WORD = "(no word selected)"
DEFFIELD_SIZE = (15, 5)

# Widgets we type in, to set shortcuts for
TYPING_WIDGETS = ("Text", "TEntry")


class Editor(tk.Tk):
    """Main editor window"""

    def __init__(self):
        """Main editor window"""

        super().__init__()

        # Load our config
        self.__config = config_io.load_config()

        # The word list and definitions dictionary
        self.words = []
        self.defs = {}

        self.thread = None  # Any thread the GUI might spawn

        # Is the GUI currently busy (all widgets disabled)?
        self.__busy = False

        self.__status_text = ""  # Current operations message

        self.status_text_queue = Queue()  # Thread pipe for status text

        # Queues of (title, message) to use as dialog arguments
        self.op_infos = Queue()
        self.op_warnings = Queue()
        self.op_errors = Queue()

        # Ordered pairs of the queues with the callable to use on them
        self.op_dialog_queues_n_calls = list(zip([
            self.op_errors,
            self.op_warnings,
            self.op_infos,
            ],
        [
            mb.showerror,
            mb.showwarning,
            mb.showinfo],
        ))

        # Menu base name: display label pairs.
        # Menus must be disabled by display label.
        self.menu_labels = {}

        self.widgets_to_disable = []  # Widgets to disable when busy

        # Variables used by GUI widgets
        self.search_str = tk.StringVar(self)
        self.search_str.trace_add("write", lambda *args: self.update_query())
        self.query_list = tk.Variable(self, value=["foo", "bar", "bazz"])
        self.word_disp_str = tk.StringVar(self, NO_WORD)
        self.rarity_disp_double = tk.DoubleVar(self)
        self.rarity_disp_double.trace_add("write", lambda *args: self.update_rarity_disp_style())

        # Handle unsaved changes
        self.__unsaved_changes_displaystate = False
        self.unsaved_changes = False  # Thread-save
        self.protocol("WM_DELETE_WINDOW", self.on_closing)

        # Make the GUI
        self.title(WINDOW_TITLE)
        self.styler = ttk.Style(self)
        self.styler.theme_create("library", "default", theme.LIBRARY_THEME)
        self.styler.theme_use("library")

        self.iconphoto(True, tk.PhotoImage(file=info.ICON_PATH))
        self.build()

        # Lock built size as minimum
        self.update()
        self.minsize(self.winfo_width(), self.winfo_height())

        # Start the status ticker
        self.status_tick()

        # Load files
        self.load_files(select=False, do_or_die=True)

        # Start the GUI loop
        self.mainloop()

        # Save our config upon exit
        config_io.save_config(self.__config)

    @property
    def game_path(self):
        """The path to the game program folder"""
        return self.__config["gamePath"]

    @game_path.setter
    def game_path(self, new: str):
        """The path to the game program folder"""
        self.__config["gamePath"] = new

    def __build_menubar(self):
        """Construct the GUI's menubar"""

        # Base menubar
        self.menubar = tk.Menu(self, **theme.MENU_STYLING_KWARGS)
        self["menu"] = self.menubar

        # File menu
        self.file_menu = tk.Menu(self.menubar, tearoff=1, **theme.MENU_STYLING_KWARGS)
        self.menu_labels["file"] = "🗃 File"

        # Open
        self.bind("<Control-o>", lambda _: self.load_files(select=True))
        self.file_menu.add_command(
            label="📂 Open",
            underline=3,
            command=lambda: self.load_files(select=True),
            )

        # Reload
        self.bind("<Control-r>", lambda _: self.load_files(select=False))
        self.file_menu.add_command(
            label="🔃 Reload",
            underline=3,
            command=lambda: self.load_files(select=False),
            )

        # Save
        self.bind("<Control-s>", lambda _: self.save_files())
        self.file_menu.add_command(
            label="💾 Save",
            underline=3,
            command=self.save_files
            )

        self.file_menu.add_separator()

        # Backup existing
        self.bind("<Control-b>", self.make_backup)
        self.file_menu.add_command(
            label="🕞 Backup existing",
            underline=3,
            command=self.make_backup
            )

        self.menubar.add_cascade(
            label=self.menu_labels["file"],
            menu=self.file_menu
            )

        # Edit menu
        self.edit_menu = tk.Menu(self.menubar, tearoff=1, **theme.MENU_STYLING_KWARGS)
        self.menu_labels["edit"] = "🖊 Edit"

        self.edit_menu.add_command(
            label="➕ Add several words", command=self.mass_add_words
            )
        self.edit_menu.add_command(
            label="📚 Auto-define undefined rare words",
            command=self.mass_auto_define
            )

        self.edit_menu.add_separator()
        self.edit_menu.add_command(
            label="🗑 Delete several words", command=self.mass_delete_words
            )
        self.edit_menu.add_command(
            label="📏 Delete words of invalid length",
            command=self.del_invalid_len_words
            )
        self.edit_menu.add_command(
            label="👬 Delete duplicate word listings",
            command=self.del_dupe_words
            )

        self.edit_menu.add_separator()
        self.edit_menu.add_command(
            label="⛓️‍💥 Delete orphaned definitions",
            command=self.del_orphaned_defs
            )
        self.edit_menu.add_command(
            label="🔣 Delete unencodable definitions",
            command=self.del_badenc_defs
            )

        self.menubar.add_cascade(
            label=self.menu_labels["edit"], menu=self.edit_menu
            )

        # Help menu
        self.help_menu = tk.Menu(self.menubar, tearoff=1, **theme.MENU_STYLING_KWARGS)
        self.menu_labels["help"] = "❔ Help"

        self.help_menu.add_command(
            label="ⓘ About", command=lambda: info.AboutDialogue(self)
        )

        self.help_menu.add_separator()
        self.help_menu.add_command(
            label="📖 How to use",
            foreground=theme.COLORS["link_blue"],
            command=lambda: webbrowser.open(info.URL.how_to_use),
        )
        self.help_menu.add_command(
            label="⁉️ Report an issue",
            foreground=theme.COLORS["link_blue"],
            command=lambda: webbrowser.open(info.URL.report_issue),
        )

        self.menubar.add_cascade(
            label=self.menu_labels["help"], menu=self.help_menu
            )

    def __build_list_pane(self):
        """Construct the word list pane"""

        # Subframe for search system
        self.search_frame = ttk.Frame(self.list_frame)
        self.search_frame.grid(row=0, columnspan=2, sticky=tk.NSEW)

        # Search system
        self.search_label = ttk.Label(
            self.search_frame, text="Search 🔎:", anchor=tk.W
            )
        self.search_label.grid(row=0, column=0, sticky=tk.NSEW)
        self.search_entry = ttk.Entry(self.search_frame, textvariable=self.search_str)
        self.search_entry.grid(row=0, column=1, sticky=tk.NSEW)
        self.search_frame.columnconfigure(1, weight=1)

        self.search_clear_bttn = ttk.Button(
            self.search_frame,
            text="X",
            width=3,
            command=lambda: self.search_str.set("")
        )
        self.search_clear_bttn.grid(row=0, column=2, sticky=tk.NSEW)
        self.widgets_to_disable += [
            self.search_label,
            self.search_entry,
            self.search_clear_bttn,
        ]

        # Word list display
        self.query_box = tk.Listbox(
            self.list_frame,
            listvariable=self.query_list,
            height=10,
            selectmode=tk.SINGLE,
            exportselection=False,
            bg=theme.COLORS["paper"],
            selectbackground=theme.COLORS["selected_paper"],
            selectforeground="black"
        )

        self.query_box.bind(
            "<<ListboxSelect>>",
            lambda _: self.selection_updated(),
            )
        self.query_box.bind(
            "<Up>",
            lambda _: self.move_in_query(-1))
        self.query_box.bind(
            "<Down>",
            lambda _: self.move_in_query(1))
        self.query_box.grid(row=1, column=0, sticky=tk.NSEW)
        self.widgets_to_disable.append(self.query_box)

        self.query_box_scrollbar = ttk.Scrollbar(
            self.list_frame, orient=tk.VERTICAL, command=self.query_box.yview
        )
        self.query_box["yscrollcommand"] = self.query_box_scrollbar.set
        self.query_box_scrollbar.grid(row=1, column=1, sticky=tk.N + tk.S + tk.E)
        # Scrollbar cannot be state disabled
        # self.widgets_to_disable.append(self.query_box_scrollbar)
        self.list_frame.rowconfigure(1, weight=1)

        # Button to add a word
        self.add_word_bttn = ttk.Button(
            self.list_frame, text="➕ Add word", command=self.add_word
        )
        self.add_word_bttn.grid(row=2, columnspan=2, sticky=tk.NSEW)
        self.widgets_to_disable.append(self.add_word_bttn)
        self.list_frame.columnconfigure(0, weight=1)

    def __build_word_edit_pane(self):
        """Construct the selected word editing pane"""

        # Subframe for word and usage display
        self.word_disp_frame = ttk.Frame(self.word_edit_frame)
        self.word_disp_frame.grid(row=0, columnspan=2, sticky=tk.NSEW)
        self.word_edit_frame.columnconfigure(0, weight=1)
        self.word_edit_frame.columnconfigure(1, weight=1)

        # Display the currently selected word
        self.word_disp_label = ttk.Label(
            self.word_disp_frame,
            textvariable=self.word_disp_str,
            anchor=tk.CENTER,
            style="WordDisplay.TLabel",
            )
        self.word_disp_label.grid(row=0, column=0, sticky=tk.NSEW)
        self.widgets_to_disable.append(self.word_disp_label)
        self.word_disp_frame.columnconfigure(0, weight=1)

        # Display how often that word is used
        # IDK exactly how this style works, but it does
        self.styler.layout(
            'text.Horizontal.TProgressbar',
            [
                (
                    'Horizontal.Progressbar.trough',
                    {
                        'children': [(
                            'Horizontal.Progressbar.pbar',
                            {'side': tk.LEFT, 'sticky': tk.NS}
                            )],
                        'sticky': tk.NSEW
                    }
                ),
                ('Horizontal.Progressbar.label', {'sticky': ''})
            ]
        )

        self.rarity_display = ttk.Progressbar(
            self.word_disp_frame,
            variable=self.rarity_disp_double,
            style='text.Horizontal.TProgressbar',
            maximum=RARITY_DISP_MAX,
            )
        self.update_rarity_disp_style()
        self.rarity_display.grid(row=0, column=1, sticky=tk.NSEW)
        # Progress bar cannot be disabled
        # self.widgets_to_disable.append(self.rarity_display)

        # Allow editing of the popdef for the word
        self.def_field = tk.Text(
            self.word_edit_frame,
            width=DEFFIELD_SIZE[0],
            height=DEFFIELD_SIZE[1],
            wrap=tk.WORD,
            # bg=theme.COLORS["selected_paper"],
        )
        self.def_field.grid(row=1, columnspan=2, sticky=tk.NSEW)
        self.widgets_to_disable.append(self.def_field)
        self.def_field.bind(
            "<KeyRelease>",
            lambda _: self.regulate_def_buttons(),
            )
        self.word_edit_frame.rowconfigure(1, weight=1)
        self.word_edit_frame.columnconfigure(0, weight=1)
        self.word_edit_frame.columnconfigure(1, weight=1)

        self.reset_def_bttn = ttk.Button(
            self.word_edit_frame,
            text="🔃 Reset definition",
            command=self.selection_updated,
        )
        self.reset_def_bttn.grid(row=2, column=0, sticky=tk.NSEW)
        self.widgets_to_disable.append(self.reset_def_bttn)

        self.save_def_bttn = ttk.Button(
            self.word_edit_frame,
            text="💾 Save definition",
            command=self.update_definition
        )
        self.save_def_bttn.grid(row=2, column=1, sticky=tk.NSEW)
        self.widgets_to_disable.append(self.save_def_bttn)

        self.autodef_bttn = ttk.Button(
            self.word_edit_frame,
            text="📚 Auto-define",
            command=self.auto_define,
        )
        self.autodef_bttn.grid(row=3, columnspan=2, sticky=tk.NSEW)
        self.widgets_to_disable.append(self.autodef_bttn)

        self.del_bttn = ttk.Button(
            self.word_edit_frame,
            text="🗑 Delete word",
            command=self.delete_selected_word,
        )
        self.del_bttn.grid(row=4, columnspan=2, sticky=tk.NSEW)
        self.widgets_to_disable.append(self.del_bttn)

    def select_all(self, event: tk.Event):
        """Select all text in the widget generating the given event

        Args:
            event (tk.Event): The Tkinter event that calls this."""

        # This is a Text widget
        if hasattr(event.widget, "tag_add"):
            event.widget.tag_add(tk.SEL, "1.0", "end-1c")
            return

        # This is an Entry widget
        if hasattr(event.widget, "selection_range"):
            event.widget.selection_range(0, tk.END)
            return

        print(
            "ERROR: Attempted to select all in unhandled widget type:",
            type(event.widget),
            )

    def build(self):
        """Construct the GUI"""

        # Make Ctrl + A the select all shortcut
        for typing_widget in TYPING_WIDGETS:
            self.bind_class(typing_widget, "<Control-a>", self.select_all)

        self.__build_menubar()

        # Left-hand pane, for list
        self.list_frame = ttk.Frame(self)
        self.list_frame.grid(row=0, column=0, sticky=tk.NSEW)
        self.columnconfigure(0, weight=1)
        self.__build_list_pane()

        # Right-hand pane, for single word edit functions
        self.word_edit_frame = ttk.Frame(self)
        self.word_edit_frame.grid(row=0, column=1, sticky=tk.NSEW)
        self.columnconfigure(1, weight=1)
        self.__build_word_edit_pane()

        # Expand those two panes vertically
        self.rowconfigure(0, weight=1)

        # Status display footer
        self.status_label_str = tk.StringVar(self)
        self.status_label = ttk.Label(
            self,
            textvariable=self.status_label_str,
            anchor=tk.W,
            relief="sunken",
            style="Score.TLabel",
        )
        self.status_label.grid(row=1, column=0, columnspan=2, sticky=tk.EW)

    def on_closing(self):
        """What to do if the user clicks to close this window"""

        if self.unsaved_changes:
            answer = mb.askyesnocancel(
                "Unsaved changes",
                "There are currently unsaved changes to the word list " +
                "and / or popdefs. Save before exiting?",
            )

            # The user cancelled the exit
            if answer is None:
                return

            # The user clicked yes
            if answer:
                self.save_and_close()
                return

        # Close the window
        self.destroy()

    def thread_process(self, function: callable, message: str = "Working..."):
        """Run a method in a thread, and grey out the GUI until it's finished.

        Args:
            function (callable): The function or method to run in a thread.
            message (str): The message to show over the greyed out GUI."""

        self.status_text = message
        self.busy = True
        self.thread = threading.Thread(
            target=lambda: self.__busy_run(function), daemon=True
        )
        self.thread.start()

        self.thread_waiting_tick()

    def thread_waiting_tick(self):
        """Wait for the current thread to finish with after timers, then un-grey the GUI"""
        # TODO could this whole method be combined with status_tick()?

        if self.thread.is_alive():
            self.after(THREAD_WAITING_TICK_DELAY_MS, self.thread_waiting_tick)
            return

        # The thread has finished
        self.busy = False

        # The currently selected query and word may have had changes made
        self.update_query()
        self.selection_updated()

    def save_and_close(self):
        """Start a ticker to wait until the GUI is done saving before destroying it"""
        self.save_files()
        self.__save_and_close_waiting_tick()

    def __save_and_close_waiting_tick(self):
        """Continue waiting for saving to finish with after timers, or finish up afterward"""

        # Keep waiting
        if self.busy:
            self.after(THREAD_WAITING_TICK_DELAY_MS, self.__save_and_close_waiting_tick)
            return

        # We are no longer busy, check if we successfully saved
        if self.unsaved_changes:
            mb.showerror(
                "Could not save",
                "Failed to save the files. Not exiting."
                )
            return

        # We successfully saved, exit
        self.destroy()

    def __busy_run(self, method: callable):
        """Run a method, and grey out the GUI until it's finished.

        Args:
            method (callable): The method to run."""

        try:
            method()
        except Exception as e:
            self.op_errors.put((
                "Unhandled exception",
                "The threaded operation crashed. The exact error was, " +
                f'"{type(e).__name__}: {e}"',
                ))

    @property
    def idle_status(self) -> str:
        """The status text to display when no operations are running"""
        return f"Ready. {len(self.words):,} words. " +\
            f"{len(self.defs):,} popdefs." +\
            f"{" (unsaved)" if self.unsaved_changes else ""}"

    @property
    def busy(self) -> bool:
        """Wether or not the program is busy"""
        return self.__busy

    @busy.setter
    def busy(self, new: bool):
        """Wether or not the program is busy"""
        # For other widget disablers to reference
        self.__busy = new

        # Enable or disable all the widgets
        new_state = (tk.NORMAL, tk.DISABLED)[int(new)]
        for entry in self.menu_labels.values():
            self.menubar.entryconfig(entry, state=new_state)
        for widget in self.widgets_to_disable:
            widget.config(state=new_state)

        # Reset the status message to idle as needed
        if not new:
            self.status_text = self.idle_status

        # Rerun any unique widget disablers
        self.unique_disable_handlers()

    @property
    def status_text(self) -> str:
        """Current operations message (thread-safe)"""
        return self.__status_text

    @status_text.setter
    def status_text(self, new: str):
        """Current operations message (thread-safe)"""
        self.__status_text = new
        self.status_text_queue.put(new)

    def status_tick(self):
        """Run one iteration of updating status displays"""
        # Allow for no text updates
        message = None

        # Skip to the end of the queue
        while not self.status_text_queue.empty():
            message = self.status_text_queue.get()

        if message:
            self.status_label_str.set(message)

        # We have a new signal value for changes not being saved
        if self.unsaved_changes != self.__unsaved_changes_displaystate:
            # Set the window title based on wether changes are saved or not
            self.title(
                (
                    WINDOW_TITLE,
                    UNSAVED_WINDOW_TITLE,
                    )[int(self.unsaved_changes)]
                )

            self.__unsaved_changes_displaystate = self.unsaved_changes

        # Show all process dialogs
        for queue, showdialog in self.op_dialog_queues_n_calls:
            if not queue.empty():
                showdialog(*queue.get())
                break  # Show all of one type of dialog
            # This loop will run again on the next tick

        self.after(STATUS_TICK_DELAY_MS, self.status_tick)

    def update_rarity_disp_style(self):
        """Set the update meter text to the current value

        Args:
            force_text (bool): Make sure to display text even if no word is selected.
                Defaults to False."""

        if self.selected_word == NO_WORD:
            self.styler.configure(
                "TProgressbar",
                text="",
                background=RARE_COLS[0]
                )
            return

        self.styler.configure(
            "TProgressbar",
            text=RARITY_DISP_PREFIX +
            f"{self.rarity_disp_double.get():.02f} / {RARITY_DISP_MAX}",
            background=RARE_COLS[RARITY_DISP_MAX - self.rarity_disp_double.get() < utils.RARE_THRESH],
            )

    def unique_disable_handlers(self):
        """Run all unique widget disabling handlers"""

        self.regulate_word_buttons()
        self.regulate_def_buttons()

    def regulate_word_buttons(self):
        """Enable or disable the word handling buttons based on if a word is
            selected"""

        # Do not enable or disable these widgets if the GUI is busy
        if self.busy:
            return

        # buttons should be disabled if no word is selected
        new_state = (
            tk.NORMAL, tk.DISABLED
            )[int(self.selected_word == NO_WORD)]

        for button in (self.autodef_bttn, self.del_bttn):
            button.config(state=new_state)

    def regulate_def_buttons(self):
        """Check if the definition has changed, and regulate the reset / save
            def buttons accordingly."""

        # Do not enable or disable these widgets if the GUI is busy
        if self.busy:
            return

        # Get the current entry
        def_entry = self.def_field.get("0.0", tk.END).strip()

        # There is no selected word
        if self.selected_word == NO_WORD:
            new_state = tk.DISABLED

        # We have an old definition for this word
        elif self.selected_word in self.defs:
            # The user deleted the old definition
            if not def_entry:
                new_state = tk.NORMAL

            # The old definition is the same as the new one
            elif self.defs[self.selected_word] == def_entry:
                new_state = tk.DISABLED

            # There is a new definition
            else:
                new_state = tk.NORMAL

        # We do not have an old definition, and there is a new one
        elif def_entry:
            new_state = tk.NORMAL

        # There was no old or new definition
        else:
            new_state = tk.DISABLED

        # Enable or disable the buttons appropriately
        for button in (self.reset_def_bttn, self.save_def_bttn):
            button.config(state=new_state)

    @property
    def wordlist_abs_path(self) -> str:
        """The absolute path of the wordlist file"""
        return op.join(self.game_path, utils.WORDLIST_FILE)

    @property
    def popdefs_abs_path(self) -> str:
        """The absolute path of the popdefs file"""
        return op.join(self.game_path, utils.POPDEFS_FILE)

    def load_files(self, select: bool = True, do_or_die: bool = False):
        """Load the wordlist and the popdefs (threaded)

        Args:
            select (bool): Wether or not we need to prompt the user to select
                a new path.
                Defaults to True.
            do_or_die(bool): Wether or not cancelling is not an option.
                Defaults to False, cancelling is an option."""

        # Disable the GUI even while not threading, while the file dialog is open
        self.busy = True

        # Set the status text appropriately
        self.status_text = "Selecting game files..."

        # Ask the user for a directory if the current one is invalid, even if
        # the select argument is false.
        select = select or not utils.is_game_path_valid(self.game_path)

        # While we need to select something
        while select:
            while True:  # Keep asking for an input
                response = filedialog.askdirectory(
                    title="Game directory", initialdir=utils.deepest_valid_path(self.game_path)
                )

                # We got a response, so break the loop.
                if response:
                    break

                # The user cancelled, but we aren't supposed to force. Abort.
                if not do_or_die:
                    # The GUI should no longer be disabled or show loading status
                    self.busy = False
                    self.status_text = self.idle_status
                    return

                # The only remaining possibility is that the user cancelled,
                # but we are do or die. They must choose a directory, or quit.
                if mb.askyesno(
                    "Cannot cancel",
                    "The program needs a valid directory to continue. Exit the program?",
                ):
                    self.destroy()
                    sys.exit()

            # If the game path is valid, we are no longer selecting
            select = not utils.is_game_path_valid(response)
            if select:
                mb.showerror(
                    "Invalid directory",
                    "Could not find the word list and pop definitions here.",
                )
            else:
                self.game_path = response  # We got a new valid directory

        # Load the files from the game path
        self.thread_process(
            lambda: gui_heavy_ops.load_files(self),
            message="Loading...",
        )

    def save_files(self, backup: bool = False):
        """Attempt to save the worldist and popdefs (threaded).
            Reference self.unsaved_changes to know the result.

        Args:
            backup (bool): Wether or not to copy the original files to a
                backup name.
                Defaults to False."""

        # Backup system
        # Recommend a backup if the files are older than this program
        # No thread means we cannot change the status text here
        self.status_text = "Checking need for backup..."
        backup = backup or (
            # Make sure we have files to back up before timestamp reading
            utils.is_game_path_valid(self.game_path) and

            # Files are older than this program
            min((
                op.getmtime(self.wordlist_abs_path),
                op.getmtime(self.popdefs_abs_path),
                )) < info.INITIAL_COMMIT_TIMESTAMP and

            # User confirmed a backup based on this
            mb.askyesno(
                "Backup recommended",
                "The existing game files are older than this program. Save a backup?"
                ))

        self.thread_process(lambda: gui_heavy_ops.save_files(self, backup))

    def make_backup(self) -> bool:
        """Save a backup of the files, with a timestamp.

        Returns:
            success (bool): Wether or not we were able to backup."""

        # Catch for the files having been deleted while editing them
        if not utils.is_game_path_valid(self.game_path):
            mb.showerror(
                "Backup failed",
                "Could not back up the original files " +
                "because they have disappeared.",
            )
            return False

        backup_suffix = f"_{int(time.time())}{BACKUP_FILEEXT}"
        shutil.copy(
            self.wordlist_abs_path,
            self.wordlist_abs_path + backup_suffix,
        )
        shutil.copy(
            self.popdefs_abs_path,
            self.popdefs_abs_path + backup_suffix,
        )

        # Notify the user that the backup was successful
        mb.showinfo(
            "Backup completed",
            f"Copied files to current game path with suffix '{backup_suffix}'",
            )
        return True

    def select_alpha_file(self):
        """
        Let the user select a text file

        Returns:
            f (TextIO): The opened file for reading
        """
        return filedialog.askopenfile(
            title="Select human-readable list of words",
            filetypes=utils.TEXT_FILETYPE,
        )

    def selection_updated(self):
        """A new word has been selected (or the equivalent of such), update
            everything."""

        # Load and display the current definition
        self.load_definition()

        # If no word is selected, clear the usage display
        # and just show NO_WORD in the word display
        if self.selected_word == NO_WORD:
            self.rarity_disp_double.set(0)
            self.word_disp_str.set(self.selected_word)

        # Otherwise, load and display usage statistics
        # and add quotes around the main display
        else:
            usage = utils.get_word_usage(self.selected_word)
            self.rarity_disp_double.set(RARITY_DISP_MAX - usage)

            self.word_disp_str.set(f'"{self.selected_word}"')

        # Enable or disable the word handling buttons based on the selection
        self.regulate_word_buttons()

    def update_query(self):
        """Update the list of search results"""

        # Do not allow any capitalization or non-letters in the search
        search = "".join([
                char for char in self.search_str.get().lower()
                if char.isalpha()
                ])

        # Apply that filtering to whatever is typed
        self.search_str.set(search)

        # Comprehensively filter the wordlist to only matching words
        if search:
            query = [word for word in self.words if search in word]

            # Sort search results primarily by how close
            # the search query is to the beginning.
            query.sort(key=lambda x: x.index(search))

        # The search was cleared
        else:
            query = self.words

        # Update the query list
        self.query_list.set(query)

        # There was a search entered, and it returned values.
        # Highlight the top result.
        if search and query:
            self.selected_word = query[0]

        # The search was cleared or returned no search results
        else:
            self.selected_word = NO_WORD

    @property
    def selected_word(self) -> str:
        """The currently selected word.

        Returns:
            word (str): Either the curently selected word or NO_WORD."""

        if self.query_box.curselection():  # Something is selected
            # Return the word at the starting index of the selection
            # (only one word can be selected so the end doesn't matter)
            return self.query_box.get(self.query_box.curselection()[0])
        return NO_WORD

    @selected_word.setter
    def selected_word(self, word: str):
        """The currently selected word.
            If set to a word that is not in the current query,
            quietly reverts to NO_WORD.

        Args:
            word (str): The word to try and select."""

        # For our logic purposes, NO_WORD should be treated as null.
        if word == NO_WORD:
            word = ""

        # The word is in our current query, so select and view it
        if word and utils.binary_search(self.words, word) is not None:
            word_query_index = self.query_list.get().index(word)
            self.query_box.selection_clear(0, tk.END)
            self.query_box.selection_set(word_query_index)
            self.query_box.see(word_query_index)

        # Something not in the query list was given, or NO_WORD was given.
        # Clear the selection.
        else:
            self.query_box.selection_clear(0, tk.END)

        self.selection_updated()

    def move_in_query(self, amount: int):
        """Move through the existing query by a delta

        Args:
            amount (int): The amount to move by. Quietly clamped to query size.
        """

        # Do not try to move if the query is empty
        if not self.query_box.size():
            return

        # Get our current position, augment it, then clamp it to query size
        og = self.query_box.curselection()[0]
        desired = og + amount
        target = max((0, min((desired, self.query_box.size() - 1))))

        # After clamping, can we move in that direction at all?
        if target == og:
            return

        # Actually make the selection change
        self.query_box.selection_clear(0, tk.END)
        self.query_box.selection_set(target)
        self.query_box.see(target)
        self.selection_updated()

    def load_definition(self):
        """Load the definition of the selected word if there is one"""

        # Clear any old displayed definition, regardless
        self.def_field.delete(0.0, tk.END)

        # If we have a definition for this word, display it
        if self.selected_word != NO_WORD and self.selected_word in self.defs:
            self.def_field.insert(0.0, self.defs[self.selected_word])

        # The definition has no unsaved changes.
        # Disable definition reset and save buttons.
        self.regulate_def_buttons()

    def update_definition(self):
        """Update the stored definition for a word"""

        # Filter definition to only spaces, and remove surrounding whitespace.
        def_entry = utils.WHITESPACE_PATTERN.sub(
            " ",
            self.def_field.get("0.0", tk.END).strip(),
            )

        # We have a definition to save
        if def_entry:
            # Ensure the new definition will encode properly
            try:
                def_entry.encode(utils.FILE_ENC)
            except UnicodeEncodeError:
                mb.showerror(
                    "Unsaveable definition",
                    "The definition contains one or more characters that " +
                    f"cannot be encoded to {utils.FILE_ENC}."
                    )
                return

            # Encoding is all clear, save the definition to memory
            self.defs[self.selected_word] = def_entry

        # We had a definition, and it has been deleted
        elif self.defs.get(self.selected_word) is not None:
            del self.defs[self.selected_word]

        # There are now unsaved changes
        self.unsaved_changes = True

        # In case the invalid character filtering changed what was entered
        self.load_definition()

        # The definition has no unsaved changes.
        # Disable definition reset and save buttons.
        self.regulate_def_buttons()

    def is_len_valid(self, word: str, notify: bool = False) -> bool:
        """Check if a word's length is valid.

        Args:
            word (str): The word to check.
            notify (bool): Wether or not to graphically notify the user
                if the word was of invalid length.
                Defaults to False.

        Returns:
            result (bool): Is the word of valud length?"""

        valid = utils.WORD_LENGTH_MIN <= len(word) <= utils.WORD_LENGTH_MAX

        if not valid and notify:
            # Dialog auto-selects the word "short" or "long" based on wether
            # the invalid length was a too long case or not.
            mb.showerror(
                "Word is too " +
                ("short", "long")[int(len(word) > utils.WORD_LENGTH_MAX)],

                f"Word must be between {utils.WORD_LENGTH_MIN:,} " +
                f"and {utils.WORD_LENGTH_MAX:,} letters long.",
            )

        return valid

    def add_word(self):
        """Create a new word entry"""

        new = dialog.askstring("New word", "Enter the word to add:")

        # Allow the user to cancel, and also enforce length delimiters.
        if not new or not self.is_len_valid(new, notify=True):
            return

        new = new.lower()

        # Ensure that the word is only letters
        if not new.isalpha():
            mb.showerror(
                "Invalid characters found",
                "Word must be only letters (no numbers or symbols).",
            )
            return

        # If the word really is new, add it.
        if not utils.binary_search(self.words, new):
            # Add the new word
            self.words.append(new)
            self.words.sort()

            # Update the query
            self.update_query()

            # There are now unsaved changes
            self.unsaved_changes = True

        # If it is not new, notify the user.
        else:
            mb.showinfo(
                "Already have word",
                f"The word '{new}' is already in the word list.",
            )

        # Highlight and scroll to the new word even if it wasn't actually new
        # If the word isn't in our search query, clear it so we can still
        # select the word.
        if self.search_str.get() not in new:
            self.search_str.set("")
        self.selected_word = new

    def mass_unsaved_changes(self, title: str, changes: str):
        """DEPRECATED: Call when mass changes have been made to the files.
            Offers to save them to disk.

        Args:
            title (str): The title of the dialog.
            changes (str): A human-readable description of the changes made."""

        self.unsaved_changes = True

        # Offer to save changes
        if mb.askyesno(
            title,
            changes + "\nSave changes to disk now?",
        ):
            self.save_files()

    def mass_add_words(self):
        """Add a whole file's worth of words (threaded)"""

        self.thread_process(
            lambda: gui_heavy_ops.mass_add_words(
                self,
                self.select_alpha_file(),
                ),
            )

    def mass_delete_words(self):
        """Delete a whole file's worth of words (threaded)"""

        self.thread_process(
            lambda: gui_heavy_ops.mass_delete_words(
                self,
                self.select_alpha_file(),
                ),
            )

    def delete_selected_word(self):
        """Delete the currently selected word"""

        # No word is selected, so nothing to delete
        if self.selected_word == NO_WORD:
            return

        # Actually do the deleting
        self._delete_word(self.selected_word)

        # Refresh the query list
        self.update_query()

        # There are now unsaved changes
        self.unsaved_changes = True

    def _delete_word(self, word: str, quiet=True):
        """Delete a word from our wordlist and popdefs

        Args:
            word (str): The word to delete.
            quiet (bool): If the word doesn't exist, this silences an error.
                Defaults to True, silence the error."""

        # Delete the word
        if utils.binary_search(self.words, word) is not None:
            self.words.remove(word)
        elif not quiet:
            mb.showerror(
                "Bad delete attempt",
                f"Attempted to delete '{word}' but it was not in the wordlist."
                )

        # Delete any popdef
        if self.defs.get(word) is not None:
            del self.defs[word]

    def del_invalid_len_words(self):
        """Remove all words of invalid length from the wordlist (threaded)"""

        self.thread_process(lambda: gui_heavy_ops.del_invalid_len_words(self))

    def del_orphaned_defs(self):
        """Find and delete any orphaned definitions (threaded)"""

        self.thread_process(lambda: gui_heavy_ops.del_orphaned_defs(self))

    def del_badenc_defs(self):
        """Find and delete any unencodable definitions"""

        self.thread_process(lambda: gui_heavy_ops.del_badenc_defs(self))

    def del_dupe_words(self):
        """Delete any duplicate word listings (threaded)"""

        self.thread_process(lambda: gui_heavy_ops.del_dupe_words(self))

    def auto_define(self):
        """Attempt to automatically define the currently selected word"""

        if self.selected_word == NO_WORD:
            return

        result, success = utils.build_auto_def(self.selected_word)
        if not success:
            mb.showerror("Could not autodefine", result)
            return

        # Write out the auto-definition, but do not save
        self.def_field.delete(0.0, tk.END)
        self.def_field.insert(0.0, result)

        # Enable or disable the definition handler buttons accordingly
        self.regulate_def_buttons()

    def mass_auto_define(self):
        """Find all words below the usage threshold,
            and try to define them (threaded)"""

        self.thread_process(
            lambda: gui_heavy_ops.mass_auto_define(self),
            message="Working...",
            )


def main():
    """Launch the GUI application"""
    Editor()
    return 0


# The module is being run directly
if __name__ == "__main__":
    sys.exit(main())
