"""The main window of the ComicTagger app"""

#
# Copyright 2012-2014 ComicTagger Authors
#
# 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.
from __future__ import annotations

import functools
import logging
import operator
import os
import pickle
import platform
import re
import sys
import webbrowser
from collections.abc import Sequence
from typing import Any, Callable, cast

import natsort
import settngs
from PyQt5 import QtCore, QtGui, QtNetwork, QtWidgets, uic

import comicapi.merge
import comictaggerlib.graphics.resources
import comictaggerlib.ui
from comicapi import utils
from comicapi.comicarchive import ComicArchive, tags
from comicapi.filenameparser import FileNameParser
from comicapi.genericmetadata import Credit, GenericMetadata
from comicapi.issuestring import IssueString
from comictaggerlib import ctsettings, ctversion
from comictaggerlib.applicationlogwindow import ApplicationLogWindow, QTextEditLogger
from comictaggerlib.autotagmatchwindow import AutoTagMatchWindow
from comictaggerlib.autotagprogresswindow import AutoTagProgressWindow
from comictaggerlib.autotagstartwindow import AutoTagStartWindow
from comictaggerlib.cbltransformer import CBLTransformer
from comictaggerlib.coverimagewidget import CoverImageWidget
from comictaggerlib.crediteditorwindow import CreditEditorWindow
from comictaggerlib.ctsettings import ct_ns
from comictaggerlib.exportwindow import ExportConflictOpts, ExportWindow
from comictaggerlib.fileselectionlist import FileSelectionList
from comictaggerlib.graphics import graphics_path
from comictaggerlib.issueidentifier import IssueIdentifier
from comictaggerlib.logwindow import LogWindow
from comictaggerlib.md import prepare_metadata
from comictaggerlib.optionalmsgdialog import OptionalMessageDialog
from comictaggerlib.pagebrowser import PageBrowserWindow
from comictaggerlib.pagelisteditor import PageListEditor
from comictaggerlib.renamewindow import RenameWindow
from comictaggerlib.resulttypes import Action, MatchStatus, OnlineMatchResults, Result, Status
from comictaggerlib.seriesselectionwindow import SeriesSelectionWindow
from comictaggerlib.settingswindow import SettingsWindow
from comictaggerlib.ui import ui_path
from comictaggerlib.ui.qtutils import center_window_on_parent, enable_widget
from comictaggerlib.versionchecker import VersionChecker
from comictalker.comictalker import ComicTalker, TalkerError

logger = logging.getLogger(__name__)


def execute(f: Callable[[], Any]) -> None:
    f()


class TaggerWindow(QtWidgets.QMainWindow):
    appName = "ComicTagger"
    version = ctversion.version

    def __init__(
        self,
        file_list: list[str],
        config: settngs.Config[ct_ns],
        talkers: dict[str, ComicTalker],
        parent: QtWidgets.QWidget | None = None,
    ) -> None:
        super().__init__(parent)

        with (ui_path / "taggerwindow.ui").open(encoding="utf-8") as uifile:
            uic.loadUi(uifile, self)

        self.md_attributes = {
            "data_origin": None,
            "issue_id": None,
            "series": self.leSeries,
            "issue": self.leIssueNum,
            "title": self.leTitle,
            "publisher": self.lePublisher,
            "month": self.lePubMonth,
            "year": self.lePubYear,
            "day": self.lePubDay,
            "issue_count": self.leIssueCount,
            "volume": self.leVolumeNum,
            "genres": self.leGenre,
            "language": self.cbLanguage,
            "description": self.teComments,
            "volume_count": self.leVolumeCount,
            "critical_rating": self.dsbCriticalRating,
            "country": self.cbCountry,
            "alternate_series": self.leAltSeries,
            "alternate_number": self.leAltIssueNum,
            "alternate_count": self.leAltIssueCount,
            "imprint": self.leImprint,
            "notes": self.teNotes,
            "web_links": (self.leWebLink, self.btnOpenWebLink, self.btnAddWebLink, self.btnRemoveWebLink),
            "format": self.cbFormat,
            "manga": self.cbManga,
            "black_and_white": self.cbBW,
            "page_count": None,
            "maturity_rating": self.cbMaturityRating,
            "story_arcs": self.leStoryArc,
            "series_groups": self.leSeriesGroup,
            "scan_info": self.leScanInfo,
            "characters": self.teCharacters,
            "teams": self.teTeams,
            "locations": self.teLocations,
            "credits": (self.twCredits, self.btnAddCredit, self.btnEditCredit, self.btnRemoveCredit),
            "credits.person": 3,
            "credits.language": 2,
            "credits.role": 1,
            "credits.primary": 0,
            "tags": self.teTags,
            "pages": None,
            "page.type": None,
            "page.bookmark": None,
            "page.double_page": None,
            "page.image_index": None,
            "page.size": None,
            "page.height": None,
            "page.width": None,
            "price": None,
            "is_version_of": None,
            "rights": None,
            "identifier": None,
            "last_mark": None,
        }
        comictaggerlib.ui.qtutils.active_palette = self.leSeries.palette()
        self.config = config
        self.talkers = talkers
        self.log_window = self.setup_logger()

        # prevent multiple instances
        socket = QtNetwork.QLocalSocket(self)
        socket.connectToServer(config[0].internal__install_id)
        alive = socket.waitForConnected(3000)
        if alive:
            logger.setLevel(logging.INFO)
            logger.info("Another application with key [%s] is already running", config[0].internal__install_id)
            # send file list to other instance
            if file_list:
                socket.write(pickle.dumps(file_list))
                if not socket.waitForBytesWritten(3000):
                    logger.error(socket.errorString())
            socket.disconnectFromServer()
            sys.exit()
        else:
            # listen on a socket to prevent multiple instances
            self.socketServer = QtNetwork.QLocalServer(self)
            self.socketServer.newConnection.connect(self.on_incoming_socket_connection)
            ok = self.socketServer.listen(config[0].internal__install_id)
            if not ok:
                if self.socketServer.serverError() == QtNetwork.QAbstractSocket.SocketError.AddressInUseError:
                    self.socketServer.removeServer(config[0].internal__install_id)
                    ok = self.socketServer.listen(config[0].internal__install_id)
                if not ok:
                    logger.error(
                        "Cannot start local socket with key [%s]. Reason: %s",
                        config[0].internal__install_id,
                        self.socketServer.errorString(),
                    )
                    sys.exit()

        self.archiveCoverWidget = CoverImageWidget(self.coverImageContainer, CoverImageWidget.ArchiveMode, None)
        grid_layout = QtWidgets.QGridLayout(self.coverImageContainer)
        grid_layout.addWidget(self.archiveCoverWidget)
        grid_layout.setContentsMargins(0, 0, 0, 0)

        self.page_list_editor = PageListEditor(self.tabPages)
        grid_layout = QtWidgets.QGridLayout(self.tabPages)
        grid_layout.addWidget(self.page_list_editor)

        self.fileSelectionList = FileSelectionList(self.widgetListHolder, self.config[0], self.dirty_flag_verification)
        grid_layout = QtWidgets.QGridLayout(self.widgetListHolder)
        grid_layout.addWidget(self.fileSelectionList)

        self.fileSelectionList.selectionChanged.connect(self.load_archive)
        self.fileSelectionList.listCleared.connect(self.file_list_cleared)
        self.fileSelectionList.set_sorting(
            self.config[0].internal__sort_column, QtCore.Qt.SortOrder(self.config[0].internal__sort_direction)
        )

        # we can't specify relative font sizes in the UI designer, so
        # walk through all the labels in the main form, and make them
        # a smidge smaller TODO: there has to be a better way to do this
        for child in self.scrollAreaWidgetContents.children():
            if isinstance(child, QtWidgets.QLabel):
                f = child.font()
                if f.pointSize() > 10:
                    f.setPointSize(f.pointSize() - 2)
                f.setItalic(True)
                child.setFont(f)

        self.scrollAreaWidgetContents.adjustSize()

        self.setWindowIcon(QtGui.QIcon(str(graphics_path / "app.png")))

        # respect the command line selected tags
        if config[0].Runtime_Options__tags_write:
            config[0].internal__write_tags = config[0].Runtime_Options__tags_write

        if config[0].Runtime_Options__tags_read:
            config[0].internal__read_tags = config[0].Runtime_Options__tags_read

        for tag_id in config[0].internal__write_tags.copy():
            if tag_id not in self.enabled_tags():
                config[0].internal__write_tags.remove(tag_id)

        for tag_id in config[0].internal__read_tags.copy():
            if tag_id not in self.enabled_tags():
                config[0].internal__read_tags.remove(tag_id)

        self.selected_write_tags: list[str] = config[0].internal__write_tags or [self.enabled_tags()[0]]
        self.selected_read_tags: list[str] = config[0].internal__read_tags or [self.enabled_tags()[0]]

        self.setAcceptDrops(True)
        self.view_tag_actions, self.remove_tag_actions = self.tag_actions()
        self.config_menus()
        self.statusBar()
        self.populate_combo_boxes()

        self.page_browser: PageBrowserWindow | None = None
        self.comic_archive: ComicArchive | None = None
        self.dirty_flag = False
        self.droppedFile = None
        self.page_loader = None
        self.droppedFiles: list[str] = []
        self.metadata = GenericMetadata()
        self.atprogdialog: AutoTagProgressWindow | None = None
        self.reset_app()

        # set up some basic field validators
        validator = QtGui.QIntValidator(1900, 2099, self)
        self.lePubYear.setValidator(validator)

        validator = QtGui.QIntValidator(1, 12, self)
        self.lePubMonth.setValidator(validator)

        # TODO: for now keep it simple, ideally we should check the full date
        validator = QtGui.QIntValidator(1, 31, self)
        self.lePubDay.setValidator(validator)

        validator = QtGui.QIntValidator(1, 99999, self)
        self.leIssueCount.setValidator(validator)
        self.leVolumeNum.setValidator(validator)
        self.leVolumeCount.setValidator(validator)
        self.leAltIssueNum.setValidator(validator)
        self.leAltIssueCount.setValidator(validator)

        # make sure some editable comboboxes don't take drop actions
        self.cbFormat.lineEdit().setAcceptDrops(False)
        self.cbMaturityRating.lineEdit().setAcceptDrops(False)

        # hook up the callbacks
        self.cbSelectedReadTags.dropdownClosed.connect(self.select_read_tags)
        self.cbSelectedWriteTags.itemChecked.connect(self.select_write_tags)
        self.cbx_sources.currentIndexChanged.connect(self.select_source)
        self.btnEditCredit.clicked.connect(self.edit_credit)
        self.btnAddCredit.clicked.connect(self.add_credit)
        self.btnRemoveCredit.clicked.connect(self.remove_credit)
        self.twCredits.cellDoubleClicked.connect(self.edit_credit)
        self.btnOpenWebLink.clicked.connect(self.open_web_link)
        self.connect_dirty_flag_signals()
        self.page_list_editor.modified.connect(self.set_dirty_flag)
        self.page_list_editor.firstFrontCoverChanged.connect(self.front_cover_changed)
        self.page_list_editor.listOrderChanged.connect(self.page_list_order_changed)
        self.tabWidget.currentChanged.connect(self.tab_changed)

        self.page_list_editor.set_blur(self.config[0].General__blur)

        def _sync_blur(*args: Any) -> None:
            self.config[0].General__blur = self.page_list_editor.blur

        self.page_list_editor.cbxBlur.clicked.connect(_sync_blur)

        self.update_tag_tweaks()

        self.show()
        self.set_app_position()
        if self.config[0].internal__form_width != -1:
            self.splitter.setSizes([self.config[0].internal__form_width, self.config[0].internal__list_width])
        self.raise_()
        QtCore.QCoreApplication.processEvents()
        self.resizeEvent(None)

        self.splitter.splitterMoved.connect(self.splitter_moved_event)

        self.fileSelectionList.add_app_action(self.actionAutoIdentify)
        self.fileSelectionList.add_app_action(self.actionAutoTag)
        self.fileSelectionList.add_app_action(self.actionCopyTags)
        self.fileSelectionList.add_app_action(self.actionRename)
        self.fileSelectionList.add_app_action(self.actionRemoveAuto)
        self.fileSelectionList.add_app_action(self.actionRepackage)

        if file_list:
            self.fileSelectionList.add_path_list(file_list)

        if self.config[0].Dialog_Flags__show_disclaimer:
            checked = OptionalMessageDialog.msg(
                self,
                "Welcome!",
                """
                Thanks for trying ComicTagger!<br><br>
                Be aware that this is beta-level software, and consider it experimental.
                You should use it very carefully when modifying your data files.  As the
                license says, it's "AS IS!"<br><br>
                Also, be aware that writing tags to comic archives will change their file hashes,
                which has implications with respect to other software packages.  It's best to
                use ComicTagger on local copies of your comics.<br><br>
                COMIC VINE NOTE: Using the default API key will serverly limit search and tagging
                times. A personal API key will allow for a <b>5 times increase</b> in online search speed. See the
                <a href='https://github.com/comictagger/comictagger/wiki/UserGuide#comic-vine'>Wiki page</a>
                for more information.<br><br>
                Have fun!
                """,
            )
            self.config[0].Dialog_Flags__show_disclaimer = not checked
        if self.config[0].Dialog_Flags__notify_plugin_changes and getattr(sys, "frozen", False):
            checked = OptionalMessageDialog.msg(
                self,
                "Plugins Have moved!",
                f"""
                Due to techinical issues the Metron and GCD plugins are no longer bundled in ComicTagger!<br/><br/>
                You will need to download the .zip or .whl from the GitHub release page to:<br/><pre>{str(self.config[0].Runtime_Options__config.user_plugin_dir)}</pre><br/>
                GCD: <a href="https://github.com/comictagger/gcd_talker/releases">https://github.com/comictagger/gcd_talker/releases</a><br/>
                Metron: <a href="https://github.com/comictagger/metron_talker/releases">https://github.com/comictagger/metron_talker/releases</a><br/><br/>
                For more information on installing plugins see the wiki page:<br/><a href="https://github.com/comictagger/comictagger/wiki/Installing-plugins">https://github.com/comictagger/comictagger/wiki/Installing-plugins</a>
                """,
            )
            self.config[0].Dialog_Flags__notify_plugin_changes = not checked

        if self.config[0].General__check_for_new_version:
            self.check_latest_version_online()

    def enabled_tags(self) -> Sequence[str]:
        return [tag.id for tag in tags.values() if tag.enabled]

    def tag_actions(self) -> tuple[dict[str, QtWidgets.QAction], dict[str, QtWidgets.QAction]]:
        view_raw_tags: dict[str, QtWidgets.QAction] = {}
        remove_raw_tags: dict[str, QtWidgets.QAction] = {}
        for tag in tags.values():
            view_raw_tags[tag.id] = self.menuViewRawTags.addAction(f"View Raw {tag.name()} Tags")
            view_raw_tags[tag.id].setEnabled(tag.enabled)
            view_raw_tags[tag.id].setStatusTip(f"View raw {tag.name()} tag block from file")
            view_raw_tags[tag.id].triggered.connect(functools.partial(self.view_raw_tags, tag.id))

            remove_raw_tags[tag.id] = self.menuRemove.addAction(f"Remove Raw {tag.name()} Tags")
            remove_raw_tags[tag.id].setEnabled(tag.enabled)
            remove_raw_tags[tag.id].setStatusTip(f"Remove {tag.name()} tags from comic archive")
            remove_raw_tags[tag.id].triggered.connect(functools.partial(self.remove_tags, [tag.id]))

        return view_raw_tags, remove_raw_tags

    def current_talker(self) -> ComicTalker:
        if self.config[0].Sources__source in self.talkers:
            return self.talkers[self.config[0].Sources__source]
        logger.error("Could not find the '%s' talker", self.config[0].Sources__source)
        raise SystemExit(2)

    def open_file_event(self, url: QtCore.QUrl) -> None:
        logger.info(url.toLocalFile())
        self.fileSelectionList.add_path_list([url.toLocalFile()])

    def sigint_handler(self, *args: Any) -> None:
        # defer the actual close in the app loop thread
        QtCore.QTimer.singleShot(200, lambda: execute(self.close))

    def setup_logger(self) -> ApplicationLogWindow:
        try:
            current_logs = (self.config[0].Runtime_Options__config.user_log_dir / "ComicTagger.log").read_text("utf-8")
        except Exception:
            current_logs = ""
        root_logger = logging.getLogger()
        qapplogwindow = ApplicationLogWindow(
            self.config[0].Runtime_Options__config.user_log_dir,
            QTextEditLogger(logging.Formatter("%(asctime)s | %(name)s | %(levelname)s | %(message)s"), logging.DEBUG),
            parent=self,
        )
        qapplogwindow.textEdit.append(current_logs.strip())
        root_logger.addHandler(qapplogwindow.log_handler)
        return qapplogwindow

    def reset_app(self) -> None:
        self.archiveCoverWidget.clear()
        self.comic_archive = None
        self.dirty_flag = False
        self.clear_form()
        self.page_list_editor.reset_page()
        if self.page_browser is not None:
            self.page_browser.reset()
        self.update_app_title()
        self.update_menus()
        self.update_info_box()

        self.droppedFile = None
        self.page_loader = None

    def update_app_title(self) -> None:
        self.setWindowIcon(QtGui.QIcon(str(graphics_path / "app.png")))

        if self.comic_archive is None:
            self.setWindowTitle(self.appName)
        else:
            mod_str = ""
            ro_str = ""

            if self.dirty_flag:
                mod_str = " [modified]"

            if not self.comic_archive.is_writable():
                ro_str = " [read only]"

            self.setWindowTitle(f"{self.appName} - {self.comic_archive.path}{mod_str}{ro_str}")

    def config_menus(self) -> None:
        # File Menu
        self.actionAutoTag.triggered.connect(self.auto_tag)
        self.actionCopyTags.triggered.connect(self.copy_tags)
        self.actionExit.triggered.connect(self.close)
        self.actionLoad.triggered.connect(self.select_file)
        self.actionLoadFolder.triggered.connect(self.select_folder)
        self.actionOpenFolderAsComic.triggered.connect(self.select_folder_archive)
        self.actionRemoveAuto.triggered.connect(self.remove_auto)
        self.actionRename.triggered.connect(self.rename_archive)
        self.actionRepackage.triggered.connect(self.repackage_archive)
        self.actionSettings.triggered.connect(self.show_settings)
        self.actionWrite_Tags.triggered.connect(self.write_tags)
        # Tag Menu
        self.actionApplyCBLTransform.triggered.connect(self.apply_cbl_transform)
        self.actionAutoIdentify.triggered.connect(self.auto_identify_search)
        self.actionAutoImprint.triggered.connect(self.auto_imprint)
        self.actionClearEntryForm.triggered.connect(self.clear_form)
        self.actionLiteralSearch.triggered.connect(self.literal_search)
        self.actionParse_Filename.triggered.connect(self.use_filename)
        self.actionParse_Filename_split_words.triggered.connect(self.use_filename_split)
        self.actionReCalcPageDims.triggered.connect(self.recalc_page_dimensions)
        self.actionSearchOnline.triggered.connect(self.query_online)
        # Window Menu
        self.actionLogWindow.triggered.connect(self.log_window.show)
        self.actionPageBrowser.triggered.connect(self.show_page_browser)
        # Help Menu
        self.actionAbout.triggered.connect(self.about_app)
        self.actionComicTaggerForum.triggered.connect(self.show_forum)
        self.actionReportBug.triggered.connect(self.report_bug)
        self.actionWiki.triggered.connect(self.show_wiki)

        self.actionAddWebLink.triggered.connect(self.add_weblink_item)
        self.actionRemoveWebLink.triggered.connect(self.remove_weblink_item)

        self.leWebLink.addAction(self.actionAddWebLink)
        self.leWebLink.addAction(self.actionRemoveWebLink)

    def add_weblink_item(self, url: str = "") -> None:
        item = ""
        if isinstance(url, str):
            item = url
        self.leWebLink.addItem(item)
        self.leWebLink.item(self.leWebLink.count() - 1).setFlags(
            QtCore.Qt.ItemFlag.ItemIsEditable
            | QtCore.Qt.ItemFlag.ItemIsEnabled
            | QtCore.Qt.ItemFlag.ItemIsDragEnabled
            | QtCore.Qt.ItemFlag.ItemIsSelectable
        )
        self.leWebLink.item(self.leWebLink.count() - 1).setSelected(True)
        if not url:
            self.leWebLink.editItem(self.leWebLink.item(self.leWebLink.count() - 1))

    def remove_weblink_item(self) -> None:
        item = self.leWebLink.takeItem(self.leWebLink.currentRow())
        del item

    def repackage_archive(self) -> None:
        ca_list = self.fileSelectionList.get_selected_archive_list()
        non_zip_count = 0
        to_zip = []
        largest_page_size = 0
        for ca in ca_list:
            largest_page_size = max(largest_page_size, len(ca.get_page_name_list()))
            if not ca.is_zip():
                to_zip.append(ca)

        if not to_zip:
            QtWidgets.QMessageBox.information(
                self, self.tr("Export as Zip Archive"), self.tr("Only ZIP archives are selected!")
            )
            logger.warning("Export as Zip Archive. Only ZIP archives are selected")
            return

        if not self.dirty_flag_verification(
            "Export as Zip Archive",
            "If you export archives as Zip now, unsaved data in the form may be lost.  Are you sure?",
        ):
            return

        if to_zip:
            EW = ExportWindow(
                self,
                (
                    f"You have selected {len(to_zip)} archive(s) to export  to Zip format. "
                    """ New archives will be created in the same folder as the original.

   Please choose config below, and select OK.
   """
                ),
            )
            EW.adjustSize()
            EW.setModal(True)
            if not EW.exec():
                return

            prog_dialog = None
            if len(to_zip) > 3 or largest_page_size > 24:
                prog_dialog = QtWidgets.QProgressDialog("", "Cancel", 0, non_zip_count, self)
                prog_dialog.setWindowTitle("Exporting as ZIP")
                prog_dialog.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
                prog_dialog.setMinimumDuration(300)
                center_window_on_parent(prog_dialog)
            QtCore.QCoreApplication.processEvents()

            new_archives_to_add = []
            archives_to_remove = []
            skipped_list = []
            failed_list = []
            success_count = 0
            logger.debug("Exporting %d comics to zip", len(to_zip))

            for prog_idx, ca in enumerate(to_zip, 1):
                logger.debug("Exporting comic %d: %s", prog_idx, ca.path)
                QtCore.QCoreApplication.processEvents()
                if prog_dialog is not None:
                    if prog_dialog.wasCanceled():
                        break
                    prog_dialog.setValue(prog_idx)
                    prog_dialog.setLabelText(str(ca.path))
                QtCore.QCoreApplication.processEvents()

                export_name = ca.path.with_suffix(".cbz")
                export = True

                if export_name.exists():
                    if EW.fileConflictBehavior == ExportConflictOpts.dontCreate:
                        export = False
                        skipped_list.append(ca.path)
                    elif EW.fileConflictBehavior == ExportConflictOpts.createUnique:
                        export_name = utils.unique_file(export_name)

                if export:
                    logger.debug("Exporting %s to %s", ca.path, export_name)
                    if ca.export_as_zip(export_name):
                        success_count += 1
                        if EW.addToList:
                            new_archives_to_add.append(str(export_name))
                        if EW.deleteOriginal:
                            archives_to_remove.append(ca)
                            ca.path.unlink(missing_ok=True)

                    else:
                        # last export failed, so remove the zip, if it exists
                        failed_list.append(ca.path)
                        if export_name.exists():
                            export_name.unlink(missing_ok=True)

            if prog_dialog is not None:
                prog_dialog.hide()
            QtCore.QCoreApplication.processEvents()
            self.fileSelectionList.remove_archive_list(archives_to_remove)

            summary = f"Successfully created {success_count} Zip archive(s)."
            if len(skipped_list) > 0:
                summary += (
                    f"\n\nThe following {len(skipped_list)} archive(s) were skipped due to file name conflicts:\n"
                )
                for f in skipped_list:
                    summary += f"\t{f}\n"
            if len(failed_list) > 0:
                summary += (
                    f"\n\nThe following {len(failed_list)} archive(s) failed to export due to read/write errors:\n"
                )
                for f in failed_list:
                    summary += f"\t{f}\n"

            logger.info(summary)
            dlg = LogWindow(self)
            dlg.set_text(summary)
            dlg.setWindowTitle("Archive Export to Zip Summary")
            dlg.exec()
            self.fileSelectionList.add_path_list(new_archives_to_add)

    def about_app(self) -> None:
        website = "https://github.com/comictagger/comictagger"
        email = "comictagger@gmail.com"
        license_link = "http://www.apache.org/licenses/LICENSE-2.0"
        license_name = "Apache License 2.0"

        msg_box = QtWidgets.QMessageBox()
        msg_box.setWindowTitle("About " + self.appName)
        msg_box.setTextFormat(QtCore.Qt.TextFormat.RichText)
        msg_box.setIconPixmap(QtGui.QPixmap(":/graphics/about.png"))
        msg_box.setText(
            "<br><br><br>"
            + self.appName
            + f" v{self.version}"
            + "<br>"
            + "&copy;2014-2022 ComicTagger Devs<br><br>"
            + f"<a href='{website}'>{website}</a><br><br>"
            + f"<a href='mailto:{email}'>{email}</a><br><br>"
            + f"License: <a href='{license_link}'>{license_name}</a>"
        )

        msg_box.setStandardButtons(QtWidgets.QMessageBox.StandardButton.Ok)
        msg_box.exec()

    def dragEnterEvent(self, event: QtGui.QDragEnterEvent) -> None:
        self.droppedFiles = []
        if event.mimeData().hasUrls():
            # walk through the URL list and build a file list
            for url in event.mimeData().urls():
                if url.isValid() and url.scheme() == "file":
                    self.droppedFiles.append(url.toLocalFile())

            if self.droppedFiles is not None:
                event.accept()

    def dropEvent(self, event: QtGui.QDropEvent) -> None:
        self.fileSelectionList.add_path_list(self.droppedFiles)
        event.accept()

    def update_ui_for_archive(self, parse_filename: bool = True) -> None:
        if self.comic_archive is not None:
            if self.metadata.is_empty and parse_filename:
                self.use_filename()

            self.metadata.apply_default_page_list(self.comic_archive.get_page_name_list())

        self.update_cover_image()

        if self.page_browser is not None and self.comic_archive is not None:
            self.page_browser.set_comic_archive(self.comic_archive)
            self.page_browser.metadata = self.metadata

        if self.comic_archive is not None:
            self.page_list_editor.set_data(self.comic_archive, self.metadata.pages)
        self.metadata_to_form()
        self.clear_dirty_flag()  # also updates the app title
        self.update_info_box()
        self.update_menus()
        self.update_app_title()

    def update_cover_image(self) -> None:
        cover_idx = self.metadata.get_cover_page_index_list()[0]
        if self.comic_archive is not None:
            self.archiveCoverWidget.set_archive(self.comic_archive, cover_idx)

    def update_menus(self) -> None:
        enabled = self.comic_archive is not None
        writeable = self.comic_archive is not None and self.comic_archive.is_writable()
        self.actionApplyCBLTransform.setEnabled(enabled)
        self.actionAutoIdentify.setEnabled(enabled)
        self.actionAutoTag.setEnabled(enabled)
        self.actionCopyTags.setEnabled(enabled)
        self.actionParse_Filename.setEnabled(enabled)
        self.actionParse_Filename_split_words.setEnabled(enabled)
        self.actionReCalcPageDims.setEnabled(enabled)
        self.actionRemoveAuto.setEnabled(enabled)
        self.actionRename.setEnabled(enabled)
        self.actionRepackage.setEnabled(enabled)

        self.menuRemove.setEnabled(enabled)
        self.menuViewRawTags.setEnabled(enabled)
        if self.comic_archive is not None:
            for tag_id in tags:
                self.view_tag_actions[tag_id].setEnabled(tags[tag_id].enabled and self.comic_archive.has_tags(tag_id))
                self.remove_tag_actions[tag_id].setEnabled(tags[tag_id].enabled and self.comic_archive.has_tags(tag_id))

        self.actionWrite_Tags.setEnabled(writeable)

    def update_info_box(self) -> None:
        ca = self.comic_archive

        if ca is None:
            self.lblFilename.setText("")
            self.lblArchiveType.setText("")
            self.lblTagList.setText("")
            self.lblPageCount.setText("")
            return

        filename = os.path.basename(ca.path)
        filename = os.path.splitext(filename)[0]
        filename = FileNameParser().fix_spaces(filename, False)

        self.lblFilename.setText(filename)

        self.lblArchiveType.setText(ca.archiver.name() + " archive")

        page_count = f" ({ca.get_number_of_pages()} pages)"
        self.lblPageCount.setText(page_count)

        supported_md = ca.get_supported_tags()
        tag_info = ""
        for md in supported_md:
            if ca.has_tags(md):
                tag_info += "• " + tags[md].name() + "\n"

        self.lblTagList.setText(tag_info)

    def set_dirty_flag(self) -> None:
        if not self.dirty_flag:
            self.dirty_flag = True
            self.fileSelectionList.set_modified_flag(True)
            self.update_app_title()

    def clear_dirty_flag(self) -> None:
        if self.dirty_flag:
            self.dirty_flag = False
            self.fileSelectionList.set_modified_flag(False)
            self.update_app_title()

    def connect_dirty_flag_signals(self) -> None:
        # recursively connect the tab form child slots
        self.connect_child_dirty_flag_signals(self.tabWidget)

    def connect_child_dirty_flag_signals(self, widget: QtCore.QObject) -> None:
        if isinstance(widget, QtWidgets.QLineEdit):
            widget.textEdited.connect(self.set_dirty_flag)
        if isinstance(widget, QtWidgets.QTextEdit):
            widget.textChanged.connect(self.set_dirty_flag)
        if isinstance(widget, QtWidgets.QComboBox):
            widget.currentIndexChanged.connect(self.set_dirty_flag)
        if isinstance(widget, QtWidgets.QCheckBox):
            widget.stateChanged.connect(self.set_dirty_flag)
        if isinstance(widget, QtWidgets.QListWidget):
            widget.itemChanged.connect(self.set_dirty_flag)

        # recursive call on children
        for child in widget.children():
            if child != self.page_list_editor:
                self.connect_child_dirty_flag_signals(child)

    def clear_form(self) -> None:
        # get a minty fresh metadata object
        self.metadata = GenericMetadata()

        # recursively clear the tab form
        self.clear_children(self.tabWidget)

        # clear the dirty flag, since there is nothing in there now to lose
        self.clear_dirty_flag()
        self.update_ui_for_archive(parse_filename=False)

    def clear_children(self, widget: QtCore.QObject) -> None:
        if isinstance(widget, (QtWidgets.QLineEdit, QtWidgets.QTextEdit)):
            widget.setText("")
        if isinstance(widget, QtWidgets.QComboBox):
            widget.setCurrentIndex(0)
        if isinstance(widget, QtWidgets.QCheckBox):
            widget.setChecked(False)
        if isinstance(widget, QtWidgets.QTableWidget):
            widget.setRowCount(0)
        if isinstance(widget, QtWidgets.QListWidget):
            widget.clear()

        # recursive call on children
        for child in widget.children():
            self.clear_children(child)

    # Copy all of the metadata object into the form.
    # Merging of metadata should be done via the overlay function
    def metadata_to_form(self) -> None:
        def assign_text(field: QtWidgets.QLineEdit | QtWidgets.QTextEdit, value: Any) -> None:
            if value is not None:
                if isinstance(field, QtWidgets.QTextEdit) and False:
                    field.setPlainText(str(value))
                else:
                    field.setText(str(value))

        md = self.metadata

        assign_text(self.leSeries, md.series)
        assign_text(self.leIssueNum, md.issue)
        assign_text(self.leIssueCount, md.issue_count)
        assign_text(self.leVolumeNum, md.volume)
        assign_text(self.leVolumeCount, md.volume_count)
        assign_text(self.leTitle, md.title)
        assign_text(self.lePublisher, md.publisher)
        assign_text(self.lePubMonth, md.month)
        assign_text(self.lePubYear, md.year)
        assign_text(self.lePubDay, md.day)
        assign_text(self.leGenre, ",".join(md.genres))
        assign_text(self.leImprint, md.imprint)
        assign_text(self.teComments, md.description)
        assign_text(self.teNotes, md.notes)
        assign_text(self.leStoryArc, ",".join(md.story_arcs))
        assign_text(self.leScanInfo, md.scan_info)
        assign_text(self.leSeriesGroup, ",".join(md.series_groups))
        assign_text(self.leAltSeries, md.alternate_series)
        assign_text(self.leAltIssueNum, md.alternate_number)
        assign_text(self.leAltIssueCount, md.alternate_count)
        self.leWebLink.clear()
        for u in md.web_links:
            self.add_weblink_item(u.url)
        assign_text(self.teCharacters, "\n".join(md.characters))
        assign_text(self.teTeams, "\n".join(md.teams))
        assign_text(self.teLocations, "\n".join(md.locations))

        self.dsbCriticalRating.setValue(md.critical_rating or 0.0)

        if md.format is not None and md.format != "":
            i = self.cbFormat.findText(md.format)
            if i == -1:
                self.cbFormat.setEditText(md.format)
            else:
                self.cbFormat.setCurrentIndex(i)

        if md.maturity_rating is not None and md.maturity_rating != "":
            i = self.cbMaturityRating.findText(md.maturity_rating)
            if i == -1:
                self.cbMaturityRating.setEditText(md.maturity_rating)
            else:
                self.cbMaturityRating.setCurrentIndex(i)
        else:
            self.cbMaturityRating.setCurrentIndex(0)

        if md.language is not None:
            i = self.cbLanguage.findData(md.language)
            self.cbLanguage.setCurrentIndex(i)
        else:
            self.cbLanguage.setCurrentIndex(0)

        if md.country is not None:
            i = self.cbCountry.findText(md.country)
            self.cbCountry.setCurrentIndex(i)
        else:
            self.cbCountry.setCurrentIndex(0)

        if md.manga is not None:
            i = self.cbManga.findData(md.manga)
            self.cbManga.setCurrentIndex(i)
        else:
            self.cbManga.setCurrentIndex(0)

        if md.black_and_white:
            self.cbBW.setChecked(True)
        else:
            self.cbBW.setChecked(False)

        self.teTags.setText(", ".join(md.tags))

        self.twCredits.setRowCount(0)

        if md.credits is not None and len(md.credits) != 0:
            self.twCredits.setSortingEnabled(False)

            for row, credit in enumerate(md.credits):
                # if the role-person pair already exists, just skip adding it to the list
                if self.is_dupe_credit(None, credit.role.title(), credit.person):
                    continue

                self.add_new_credit_entry(row, credit)

        self.twCredits.setSortingEnabled(True)
        self.update_credit_colors()

    def add_new_credit_entry(self, row: int, credit: Credit) -> None:
        self.twCredits.insertRow(row)

        item = QtWidgets.QTableWidgetItem(credit.role)
        item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
        item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, credit.role)
        self.twCredits.setItem(row, self.md_attributes["credits.role"], item)

        language = utils.get_language_from_iso(credit.language) or credit.language
        item = QtWidgets.QTableWidgetItem(language)
        item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, credit.language)
        item.setData(QtCore.Qt.ItemDataRole.UserRole, credit.language)
        item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
        self.twCredits.setItem(row, self.md_attributes["credits.language"], item)

        item = QtWidgets.QTableWidgetItem(credit.person)
        item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, credit.person)
        item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
        self.twCredits.setItem(row, self.md_attributes["credits.person"], item)

        item = QtWidgets.QTableWidgetItem("")
        item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
        self.twCredits.setItem(row, self.md_attributes["credits.primary"], item)
        self.update_credit_primary_flag(row, credit.primary)

    def is_dupe_credit(self, row: int | None, role: str, name: str) -> bool:
        for r in range(self.twCredits.rowCount()):
            if r == row:
                continue

            if (
                self.twCredits.item(r, self.md_attributes["credits.role"]).text() == role
                and self.twCredits.item(r, self.md_attributes["credits.person"]).text() == name
            ):
                return True

        return False

    def form_to_metadata(self) -> None:
        # copy the data from the form into the metadata
        md = GenericMetadata()
        md.is_empty = False
        md.alternate_number = utils.xlate(IssueString(self.leAltIssueNum.text()).as_string())
        md.issue = utils.xlate(IssueString(self.leIssueNum.text()).as_string())
        md.issue_count = utils.xlate_int(self.leIssueCount.text())
        md.volume = utils.xlate_int(self.leVolumeNum.text())
        md.volume_count = utils.xlate_int(self.leVolumeCount.text())
        md.month = utils.xlate_int(self.lePubMonth.text())
        md.year = utils.xlate_int(self.lePubYear.text())
        md.day = utils.xlate_int(self.lePubDay.text())
        md.alternate_count = utils.xlate_int(self.leAltIssueCount.text())

        md.series = utils.xlate(self.leSeries.text())
        md.title = utils.xlate(self.leTitle.text())
        md.publisher = utils.xlate(self.lePublisher.text())
        md.genres = set(utils.split(self.leGenre.text(), ","))
        md.imprint = utils.xlate(self.leImprint.text())
        md.description = utils.xlate(self.teComments.toPlainText())
        md.notes = utils.xlate(self.teNotes.toPlainText())
        md.maturity_rating = self.cbMaturityRating.currentText()

        md.critical_rating = utils.xlate_float(self.dsbCriticalRating.cleanText())
        if md.critical_rating == 0.0:
            md.critical_rating = None

        md.story_arcs = utils.split(self.leStoryArc.text(), ",")
        md.scan_info = utils.xlate(self.leScanInfo.text())
        md.series_groups = utils.split(self.leSeriesGroup.text(), ",")
        md.alternate_series = self.leAltSeries.text()
        md.web_links = [utils.parse_url(self.leWebLink.item(i).text()) for i in range(self.leWebLink.count())]
        md.characters = set(utils.split(self.teCharacters.toPlainText(), "\n"))
        md.teams = set(utils.split(self.teTeams.toPlainText(), "\n"))
        md.locations = set(utils.split(self.teLocations.toPlainText(), "\n"))

        md.format = utils.xlate(self.cbFormat.currentText())
        md.country = utils.xlate(self.cbCountry.currentText())

        md.language = utils.xlate(self.cbLanguage.itemData(self.cbLanguage.currentIndex()))

        md.manga = utils.xlate(self.cbManga.itemData(self.cbManga.currentIndex()))

        # Make a list from the comma delimited tags string
        md.tags = set(utils.split(self.teTags.toPlainText(), ","))

        md.black_and_white = self.cbBW.isChecked()

        # get the credits from the table
        md.credits = []

        for row in range(self.twCredits.rowCount()):
            role = self.twCredits.item(row, self.md_attributes["credits.role"]).text()
            lang = (
                self.twCredits.item(row, self.md_attributes["credits.language"]).data(QtCore.Qt.ItemDataRole.UserRole)
                or self.twCredits.item(row, self.md_attributes["credits.language"]).text()
            )
            name = self.twCredits.item(row, self.md_attributes["credits.person"]).text()
            primary_flag = self.twCredits.item(row, self.md_attributes["credits.primary"]).text() != ""

            md.add_credit(name, role, bool(primary_flag), lang)

        md.pages = self.page_list_editor.get_page_list()

        # Preserve hidden md values
        md.data_origin = self.metadata.data_origin
        md.issue_id = self.metadata.issue_id
        md.series_id = self.metadata.series_id

        md.price = self.metadata.price
        md.identifier = self.metadata.identifier
        md.rights = self.metadata.rights

        self.metadata = md

    def use_filename(self) -> None:
        self._use_filename()

    def _use_filename(self, split_words: bool = False) -> None:
        if self.comic_archive is not None:
            # copy the form onto metadata object
            self.form_to_metadata()
            new_metadata = self.comic_archive.metadata_from_filename(
                self.config[0].Filename_Parsing__filename_parser,
                self.config[0].Filename_Parsing__remove_c2c,
                self.config[0].Filename_Parsing__remove_fcbd,
                self.config[0].Filename_Parsing__remove_publisher,
                split_words,
                self.config[0].Filename_Parsing__allow_issue_start_with_letter,
                self.config[0].Filename_Parsing__protofolius_issue_number_scheme,
            )
            self.metadata.overlay(new_metadata, mode=comicapi.merge.Mode.OVERLAY, merge_lists=False)
            self.metadata_to_form()

    def use_filename_split(self) -> None:
        self._use_filename(True)

    def select_folder(self) -> None:
        self.select_file(folder_mode=True)

    def select_folder_archive(self) -> None:
        dialog = self.file_dialog(folder_mode=True)
        if dialog.exec():
            file_list = dialog.selectedFiles()
            if file_list:
                self.fileSelectionList.twList.selectRow(self.fileSelectionList.add_path_item(file_list[0]))

    def select_file(self, folder_mode: bool = False) -> None:
        dialog = self.file_dialog(folder_mode=folder_mode)
        if dialog.exec():
            file_list = dialog.selectedFiles()
            self.fileSelectionList.add_path_list(file_list)

    def file_dialog(self, folder_mode: bool = False) -> QtWidgets.QFileDialog:
        dialog = QtWidgets.QFileDialog(self)
        if folder_mode:
            dialog.setFileMode(QtWidgets.QFileDialog.FileMode.Directory)
        else:
            archive_filter = "Comic archive files (*.cbz *.zip *.cbr *.rar *.cb7 *.7z)"
            filters = [archive_filter, "Any files (*)"]
            dialog.setNameFilters(filters)
            dialog.setFileMode(QtWidgets.QFileDialog.FileMode.ExistingFiles)

        if os.environ.get("XDG_SESSION_DESKTOP", "") == "KDE":
            dialog.setOption(QtWidgets.QFileDialog.Option.DontUseNativeDialog)

        if self.config[0].internal__last_opened_folder is not None:
            dialog.setDirectory(self.config[0].internal__last_opened_folder)
        return dialog

    def auto_identify_search(self) -> None:
        if self.comic_archive is None:
            QtWidgets.QMessageBox.warning(self, "Automatic Identify Search", "You need to load a comic first!")
            return

        self.query_online(autoselect=True)

    def literal_search(self) -> None:
        self.query_online(autoselect=False, literal=True)

    def query_online(self, autoselect: bool = False, literal: bool = False) -> None:
        issue_number = str(self.leIssueNum.text()).strip()

        # Only need this check is the source has issue level data.
        if autoselect and issue_number == "":
            QtWidgets.QMessageBox.information(
                self,
                "Automatic Identify Search",
                "Can't auto-identify without an issue number. The auto-tag function has the 'If no issue number, assume \"1\"' option if desired.",
            )
            return

        if str(self.leSeries.text()).strip() != "":
            series_name = str(self.leSeries.text()).strip()
        else:
            QtWidgets.QMessageBox.information(self, "Online Search", "Need to enter a series name to search.")
            return

        year = utils.xlate_int(self.lePubYear.text())

        issue_count = utils.xlate_int(self.leIssueCount.text())

        selector = SeriesSelectionWindow(
            self,
            series_name,
            issue_number,
            year,
            issue_count,
            self.comic_archive,
            self.config[0],
            self.current_talker(),
            autoselect,
            literal,
        )

        selector.setWindowTitle(f"Search: '{series_name}' - Select Series")

        selector.setModal(True)
        selector.exec()

        if selector.result():
            # we should now have a series ID
            QtWidgets.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.CursorShape.WaitCursor))

            # copy the form onto metadata object
            self.form_to_metadata()

            try:
                new_metadata = self.current_talker().fetch_comic_data(
                    issue_id=selector.issue_id, series_id=selector.series_id, issue_number=selector.issue_number
                )
            except TalkerError as e:
                QtWidgets.QApplication.restoreOverrideCursor()
                QtWidgets.QMessageBox.critical(self, f"{e.source} {e.code_name} Error", f"{e}")
                return
            QtWidgets.QApplication.restoreOverrideCursor()

            if new_metadata is None or new_metadata.is_empty:
                QtWidgets.QMessageBox.critical(
                    self, "Search", f"Could not find an issue {selector.issue_number} for that series"
                )
                return

            self.metadata = prepare_metadata(self.metadata, new_metadata, self.config[0])
            # Now push the new combined data into the edit controls
            self.metadata_to_form()

    def write_tags(self) -> None:
        if self.metadata is not None and self.comic_archive is not None:
            if self.config[0].General__prompt_on_save:
                reply = QtWidgets.QMessageBox.question(
                    self,
                    "Save Tags",
                    f"Are you sure you wish to save {', '.join([tags[tag_id].name() for tag_id in self.selected_write_tags])} tags to this archive?",
                    QtWidgets.QMessageBox.StandardButton.Yes,
                    QtWidgets.QMessageBox.StandardButton.No,
                )
            else:
                reply = QtWidgets.QMessageBox.StandardButton.Yes

            if reply != QtWidgets.QMessageBox.StandardButton.Yes:
                return
            QtWidgets.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.CursorShape.WaitCursor))
            self.form_to_metadata()

            failed_tag: str = ""
            # Save each tag
            for tag_id in self.selected_write_tags:
                success = self.comic_archive.write_tags(self.metadata, tag_id)
                if not success:
                    failed_tag = tags[tag_id].name()
                    break

            self.comic_archive.load_cache(set(tags))
            QtWidgets.QApplication.restoreOverrideCursor()

            if failed_tag:
                QtWidgets.QMessageBox.warning(
                    self,
                    "Save failed",
                    f"The tag save operation seemed to fail for: {failed_tag}",
                )
            else:
                self.clear_dirty_flag()
                self.update_info_box()
                self.update_menus()

                # Only try to read if write was successful
                self.metadata, error = self.read_selected_tags(self.selected_read_tags, self.comic_archive)
                if error is not None:
                    QtWidgets.QMessageBox.warning(
                        self,
                        "Read Failed!",
                        f"One or more of the selected read tags failed to load for {self.comic_archive.path}, check log for details",
                    )
                    logger.error("Failed to load metadata for %s: %s", self.ca.path, error)

            self.fileSelectionList.update_current_row()
            self.update_ui_for_archive()
        else:
            QtWidgets.QMessageBox.information(self, "Whoops!", "No data to write!")

    def select_read_tags(self, tag_ids: list[str]) -> None:
        """Should only be called from the combobox signal"""
        if self.dirty_flag_verification(
            "Change Read Tags",
            "If you change read tag(s) now, data in the form will be lost.  Are you sure?",
        ):
            self.selected_read_tags = list(reversed(tag_ids))
            self.config[0].internal__read_tags = self.selected_read_tags
            self.update_menus()
            if self.comic_archive is not None:
                self.load_archive(self.comic_archive)
        else:
            self.cbSelectedReadTags.dropdownClosed.disconnect()
            self.adjust_tags_combo()
            self.cbSelectedReadTags.dropdownClosed.connect(self.select_read_tags)

    def select_write_tags(self) -> None:
        self.selected_write_tags = self.cbSelectedWriteTags.currentData()
        self.config[0].internal__write_tags = self.selected_write_tags
        self.update_tag_tweaks()
        self.update_menus()

    def select_source(self, s: int) -> None:
        self.config[0].Sources__source = self.cbx_sources.itemData(s)

    def update_credit_colors(self) -> None:
        selected_tags = [tags[tag_id] for tag_id in self.selected_write_tags]
        enabled = set()
        for tag in selected_tags:
            enabled.update(tag.supported_attributes)

        credit_attributes = [x for x in self.md_attributes.items() if "credits." in x[0]]

        for r in range(self.twCredits.rowCount()):
            w = self.twCredits.item(r, 1)
            supports_role = any(tag.supports_credit_role(str(w.text())) for tag in selected_tags)
            for credit in credit_attributes:
                widget_enabled = credit[0] in enabled
                widget = self.twCredits.item(r, credit[1])
                if credit[0] == "credits.role":
                    widget_enabled = widget_enabled and supports_role
                enable_widget(widget, widget_enabled)

    def update_tag_tweaks(self) -> None:
        # depending on the current data tag, certain fields are disabled
        enabled_widgets = set()
        for tag_id in self.selected_write_tags:
            if not tags[tag_id].enabled:
                continue
            enabled_widgets.update(tags[tag_id].supported_attributes)

        for md_field, widget in self.md_attributes.items():
            if widget is not None and not isinstance(widget, (int)):
                enable_widget(widget, md_field in enabled_widgets)

        self.update_credit_colors()
        self.page_list_editor.select_read_tags(self.selected_write_tags)

    def cell_double_clicked(self, r: int, c: int) -> None:
        self.edit_credit()

    def add_credit(self) -> None:
        self.modify_credits(False)

    def edit_credit(self) -> None:
        if self.twCredits.currentRow() > -1:
            self.modify_credits(True)

    def update_credit_primary_flag(self, row: int, primary: bool) -> None:
        # if we're clearing a flag do it and quit
        if not primary:
            self.twCredits.item(row, self.md_attributes["credits.primary"]).setText("")
            return

        # otherwise, we need to check for, and clear, other primaries with same role
        role = str(self.twCredits.item(row, self.md_attributes["credits.role"]).text())
        r = 0
        for r in range(self.twCredits.rowCount()):
            if (
                self.twCredits.item(r, self.md_attributes["credits.primary"]).text() != ""
                and str(self.twCredits.item(r, self.md_attributes["credits.role"]).text()).casefold() == role.casefold()
            ):
                self.twCredits.item(r, self.md_attributes["credits.primary"]).setText("")

        # Now set our new primary
        self.twCredits.item(row, self.md_attributes["credits.primary"]).setText("Yes")

    def modify_credits(self, edit: bool) -> None:
        row = self.twCredits.rowCount()
        old = Credit()
        if edit:
            row = self.twCredits.currentRow()
            lang = str(
                self.twCredits.item(row, self.md_attributes["credits.language"]).data(QtCore.Qt.ItemDataRole.UserRole)
                or utils.get_language_iso(self.twCredits.item(row, self.md_attributes["credits.language"]).text())
            )
            old = Credit(
                self.twCredits.item(row, self.md_attributes["credits.person"]).text(),
                self.twCredits.item(row, self.md_attributes["credits.role"]).text(),
                self.twCredits.item(row, self.md_attributes["credits.primary"]).text() != "",
                lang,
            )

        editor = CreditEditorWindow(self, CreditEditorWindow.ModeEdit, old)
        editor.setModal(True)
        editor.exec()
        if editor.result():
            new = editor.get_credit()

            if new == old:
                # nothing has changed, just quit
                return

            # check for dupes
            ok_to_mod = True
            if self.is_dupe_credit(row, new.role, new.person):
                # delete the dupe credit from list
                qmsg = QtWidgets.QMessageBox()
                qmsg.setText("Duplicate Credit!")
                qmsg.setInformativeText(
                    "This will create a duplicate credit entry. Would you like to merge the entries, or create a duplicate?"
                )
                qmsg.addButton("Merge", QtWidgets.QMessageBox.ButtonRole.AcceptRole)
                qmsg.addButton("Duplicate", QtWidgets.QMessageBox.ButtonRole.NoRole)

                if qmsg.exec() == 0:
                    # merge
                    if edit:
                        # just remove the row that would be same
                        self.twCredits.removeRow(row)
                        # TODO -- need to find the row of the dupe, and possible change the primary flag

                    ok_to_mod = False

            if ok_to_mod:
                # modify it
                if edit:
                    lang = utils.get_language_from_iso(new.language) or new.language
                    self.twCredits.item(row, self.md_attributes["credits.role"]).setText(new.role)
                    self.twCredits.item(row, self.md_attributes["credits.person"]).setText(new.person)
                    self.twCredits.item(row, self.md_attributes["credits.language"]).setText(lang)
                    self.twCredits.item(row, self.md_attributes["credits.language"]).setData(
                        QtCore.Qt.ItemDataRole.UserRole, new.language
                    )
                    self.update_credit_primary_flag(row, new.primary)
                else:
                    # add new entry
                    row = self.twCredits.rowCount()
                    self.add_new_credit_entry(row, new)

            self.update_credit_colors()
            self.set_dirty_flag()

    def remove_credit(self) -> None:
        row = self.twCredits.currentRow()
        if row != -1:
            self.twCredits.removeRow(row)
        self.set_dirty_flag()

    def open_web_link(self) -> None:
        row = self.leWebLink.currentRow()
        if row < 0:
            if self.leWebLink.count() < 1:
                return
            row = 0
        web_link = self.leWebLink.item(row).text()
        try:
            utils.parse_url(web_link)
            webbrowser.open_new_tab(web_link)
        except utils.LocationParseError:
            QtWidgets.QMessageBox.warning(self, "Web Link", "Web Link is invalid.")

    def show_settings(self) -> None:
        settingswin = SettingsWindow(self, self.config, self.talkers)
        settingswin.setModal(True)
        settingswin.exec()
        settingswin.result()
        self.adjust_source_combo()

    def set_app_position(self) -> None:
        if self.config[0].internal__window_width != 0:
            self.move(self.config[0].internal__window_x, self.config[0].internal__window_y)
            self.resize(self.config[0].internal__window_width, self.config[0].internal__window_height)
        else:
            screen = QtGui.QGuiApplication.primaryScreen().geometry()
            size = self.frameGeometry()
            self.move(int((screen.width() - size.width()) / 2), int((screen.height() - size.height()) / 2))

    def adjust_source_combo(self) -> None:
        self.cbx_sources.setCurrentIndex(self.cbx_sources.findData(self.config[0].Sources__source))

    def adjust_tags_combo(self) -> None:
        """Select the enabled tags. Since tags are merged in an overlay fashion the last item in the list takes priority. We reverse the order for display to the user"""
        unchecked = set(self.enabled_tags()) - set(self.selected_read_tags)
        for i, tag_id in enumerate(reversed(self.selected_read_tags)):
            if not tags[tag_id].enabled:
                continue
            item_idx = self.cbSelectedReadTags.findData(tag_id)
            self.cbSelectedReadTags.setItemChecked(item_idx, True)
            # Order matters, move items to list order
            if item_idx != i:
                self.cbSelectedReadTags.moveItem(item_idx, row=i)
        for tag_id in unchecked:
            self.cbSelectedReadTags.setItemChecked(self.cbSelectedReadTags.findData(tag_id), False)

        # select the current tag_id
        unchecked = set(self.enabled_tags()) - set(self.selected_write_tags)
        for tag_id in self.selected_write_tags:
            if not tags[tag_id].enabled:
                continue
            self.cbSelectedWriteTags.setItemChecked(self.cbSelectedWriteTags.findData(tag_id), True)
        for tag_id in unchecked:
            self.cbSelectedWriteTags.setItemChecked(self.cbSelectedWriteTags.findData(tag_id), False)
        self.update_tag_tweaks()

    def populate_tag_names(self) -> None:
        # First clear all entries (called from settingswindow.py)
        self.cbSelectedWriteTags.clear()
        self.cbSelectedReadTags.clear()
        # Add the entries to the tag comboboxes
        for tag in tags.values():
            if not tag.enabled:
                continue
            if self.config[0].Metadata_Options__use_short_tag_names:
                self.cbSelectedWriteTags.addItem(tag.id.upper(), tag.id)
                self.cbSelectedReadTags.addItem(tag.id.upper(), tag.id)
            else:
                self.cbSelectedWriteTags.addItem(tag.name(), tag.id)
                self.cbSelectedReadTags.addItem(tag.name(), tag.id)

    def populate_combo_boxes(self) -> None:
        self.populate_tag_names()

        self.adjust_tags_combo()

        # Add talker entries
        for t_id, talker in self.talkers.items():
            self.cbx_sources.addItem(talker.name, t_id)
        self.adjust_source_combo()

        # Add the entries to the country combobox
        self.cbCountry.addItem("", "")
        for f in natsort.humansorted(utils.countries().items(), operator.itemgetter(1)):
            self.cbCountry.addItem(f[1], f[0])

        # Add the entries to the language combobox
        self.cbLanguage.addItem("", "")

        for f in natsort.humansorted(utils.languages().items(), operator.itemgetter(1)):
            self.cbLanguage.addItem(f[1], f[0])

        # Add the entries to the manga combobox
        self.cbManga.addItem("", "")
        self.cbManga.addItem("Yes", "Yes")
        self.cbManga.addItem("Yes (Right to Left)", "YesAndRightToLeft")
        self.cbManga.addItem("No", "No")

        # Add the entries to the maturity combobox
        self.cbMaturityRating.addItem("", "")
        self.cbMaturityRating.addItem("Everyone", "")
        self.cbMaturityRating.addItem("G", "")
        self.cbMaturityRating.addItem("Early Childhood", "")
        self.cbMaturityRating.addItem("Everyone 10+", "")
        self.cbMaturityRating.addItem("PG", "")
        self.cbMaturityRating.addItem("Kids to Adults", "")
        self.cbMaturityRating.addItem("Teen", "")
        self.cbMaturityRating.addItem("M", "")
        self.cbMaturityRating.addItem("MA15+", "")
        self.cbMaturityRating.addItem("Mature 17+", "")
        self.cbMaturityRating.addItem("R18+", "")
        self.cbMaturityRating.addItem("X18+", "")
        self.cbMaturityRating.addItem("Adults Only 18+", "")
        self.cbMaturityRating.addItem("Rating Pending", "")

        # Add entries to the format combobox
        self.cbFormat.addItem("")
        self.cbFormat.addItem(".1")
        self.cbFormat.addItem("-1")
        self.cbFormat.addItem("1 Shot")
        self.cbFormat.addItem("1/2")
        self.cbFormat.addItem("1-Shot")
        self.cbFormat.addItem("Annotation")
        self.cbFormat.addItem("Annotations")
        self.cbFormat.addItem("Annual")
        self.cbFormat.addItem("Anthology")
        self.cbFormat.addItem("B&W")
        self.cbFormat.addItem("B/W")
        self.cbFormat.addItem("B&&W")
        self.cbFormat.addItem("Black & White")
        self.cbFormat.addItem("Box Set")
        self.cbFormat.addItem("Box-Set")
        self.cbFormat.addItem("Crossover")
        self.cbFormat.addItem("Director's Cut")
        self.cbFormat.addItem("Epilogue")
        self.cbFormat.addItem("Event")
        self.cbFormat.addItem("FCBD")
        self.cbFormat.addItem("Flyer")
        self.cbFormat.addItem("Giant")
        self.cbFormat.addItem("Giant Size")
        self.cbFormat.addItem("Giant-Size")
        self.cbFormat.addItem("Graphic Novel")
        self.cbFormat.addItem("Hardcover")
        self.cbFormat.addItem("Hard-Cover")
        self.cbFormat.addItem("King")
        self.cbFormat.addItem("King Size")
        self.cbFormat.addItem("King-Size")
        self.cbFormat.addItem("Limited Series")
        self.cbFormat.addItem("Magazine")
        self.cbFormat.addItem("-1")
        self.cbFormat.addItem("NSFW")
        self.cbFormat.addItem("One Shot")
        self.cbFormat.addItem("One-Shot")
        self.cbFormat.addItem("Point 1")
        self.cbFormat.addItem("Preview")
        self.cbFormat.addItem("Prologue")
        self.cbFormat.addItem("Reference")
        self.cbFormat.addItem("Review")
        self.cbFormat.addItem("Reviewed")
        self.cbFormat.addItem("Scanlation")
        self.cbFormat.addItem("Script")
        self.cbFormat.addItem("Series")
        self.cbFormat.addItem("Sketch")
        self.cbFormat.addItem("Special")
        self.cbFormat.addItem("TPB")
        self.cbFormat.addItem("Trade Paper Back")
        self.cbFormat.addItem("WebComic")
        self.cbFormat.addItem("Web Comic")
        self.cbFormat.addItem("Year 1")
        self.cbFormat.addItem("Year One")

    def remove_auto(self) -> None:
        self.remove_tags(self.selected_write_tags)

    def remove_tags(self, tag_ids: list[str]) -> None:
        # remove the indicated tag_ids from the archive
        ca_list = self.fileSelectionList.get_selected_archive_list()
        has_md_count = 0
        file_md_count = {}
        for tag_id in tag_ids:
            file_md_count[tag_id] = 0
        for ca in ca_list:
            for tag_id in tag_ids:
                if ca.has_tags(tag_id):
                    has_md_count += 1
                    file_md_count[tag_id] += 1

        if has_md_count == 0:
            QtWidgets.QMessageBox.information(
                self,
                "Remove Tags",
                f"No archives with {', '.join([tags[tag_id].name() for tag_id in tag_ids])} tags selected!",
            )
            return

        if has_md_count != 0 and not self.dirty_flag_verification(
            "Remove Tags", "If you remove tags now, unsaved data in the form will be lost.  Are you sure?"
        ):
            return

        if has_md_count != 0:
            reply = QtWidgets.QMessageBox.question(
                self,
                "Remove Tags",
                f"Are you sure you wish to remove {', '.join([f'{tags[tag_id].name()} tags from {count} files' for tag_id, count in file_md_count.items()])} removing a total of {has_md_count} tag(s)?",
                QtWidgets.QMessageBox.StandardButton.Yes,
                QtWidgets.QMessageBox.StandardButton.No,
            )

            if reply == QtWidgets.QMessageBox.StandardButton.Yes:
                progdialog = QtWidgets.QProgressDialog("", "Cancel", 0, has_md_count, self)
                progdialog.setWindowTitle("Removing Tags")
                progdialog.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
                progdialog.setMinimumDuration(300)
                center_window_on_parent(progdialog)
                QtCore.QCoreApplication.processEvents()

                failed_list = []
                success_count = 0
                for prog_idx, ca in enumerate(ca_list, 1):
                    QtCore.QCoreApplication.processEvents()
                    if progdialog.wasCanceled():
                        break
                    progdialog.setValue(prog_idx)
                    progdialog.setLabelText(str(ca.path))
                    QtCore.QCoreApplication.processEvents()
                    for tag_id in tag_ids:
                        if ca.has_tags(tag_id) and ca.is_writable():
                            if ca.remove_tags(tag_id):
                                success_count += 1
                            else:
                                failed_list.append(ca.path)
                                # Abandon any further tag removals to prevent any greater damage to archive
                                break
                    ca.reset_cache()
                    ca.load_cache(self.enabled_tags())

                progdialog.hide()
                QtCore.QCoreApplication.processEvents()
                self.fileSelectionList.update_selected_rows()
                self.update_info_box()
                self.update_menus()

                summary = f"Successfully removed {success_count} tags in archive(s)."
                if len(failed_list) > 0:
                    summary += f"\n\nThe remove operation failed in the following {len(failed_list)} archive(s):\n"
                    for f in failed_list:
                        summary += f"\t{f}\n"

                dlg = LogWindow(self)
                dlg.set_text(summary)
                dlg.setWindowTitle("Tag Remove Summary")
                dlg.exec()

    def copy_tags(self) -> None:
        # copy the indicated tags in the archive
        ca_list = self.fileSelectionList.get_selected_archive_list()
        has_src_count = 0

        src_tag_ids: list[str] = self.selected_read_tags
        dest_tag_ids: list[str] = self.selected_write_tags

        if len(src_tag_ids) == 1 and src_tag_ids[0] in dest_tag_ids:
            # Remove the read tag from the write tag
            dest_tag_ids.remove(src_tag_ids[0])

        if not dest_tag_ids:
            QtWidgets.QMessageBox.information(
                self, "Copy Tags", "Can't copy tag tag onto itself.  Read tag and modify tag must be different."
            )
            return

        for ca in ca_list:
            for tag_id in src_tag_ids:
                if ca.has_tags(tag_id):
                    has_src_count += 1
                    continue

        if has_src_count == 0:
            QtWidgets.QMessageBox.information(
                self,
                "Copy Tags",
                f"No archives with {', '.join([tags[tag_id].name() for tag_id in src_tag_ids])} tags selected!",
            )
            return

        if has_src_count != 0 and not self.dirty_flag_verification(
            "Copy Tags", "If you copy tags now, unsaved data in the form may be lost.  Are you sure?"
        ):
            return

        if has_src_count != 0:
            reply = QtWidgets.QMessageBox.question(
                self,
                "Copy Tags",
                f"Are you sure you wish to copy the combined (with overlay order) tags of "
                f"{', '.join([tags[tag_id].name() for tag_id in src_tag_ids])} "
                f"to {', '.join([tags[tag_id].name() for tag_id in dest_tag_ids])} tags in "
                f"{has_src_count} archive(s)?",
                QtWidgets.QMessageBox.StandardButton.Yes,
                QtWidgets.QMessageBox.StandardButton.No,
            )

            if reply == QtWidgets.QMessageBox.StandardButton.Yes:
                prog_dialog = QtWidgets.QProgressDialog("", "Cancel", 0, has_src_count, self)
                prog_dialog.setWindowTitle("Copying Tags")
                prog_dialog.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
                prog_dialog.setMinimumDuration(300)
                center_window_on_parent(prog_dialog)
                QtCore.QCoreApplication.processEvents()

                failed_list = []
                success_count = 0
                for prog_idx, ca in enumerate(ca_list, 1):
                    ca_saved = False
                    md, error = self.read_selected_tags(src_tag_ids, ca)
                    if error is not None:
                        failed_list.append(ca.path)
                        continue
                    if md.is_empty:
                        continue

                    for tag_id in dest_tag_ids:
                        if ca.has_tags(tag_id):
                            QtCore.QCoreApplication.processEvents()
                            if prog_dialog.wasCanceled():
                                break

                            prog_dialog.setValue(prog_idx)
                            prog_dialog.setLabelText(str(ca.path))
                            center_window_on_parent(prog_dialog)
                            QtCore.QCoreApplication.processEvents()

                        if tag_id == "cbi" and self.config[0].Metadata_Options__apply_transform_on_bulk_operation:
                            md = CBLTransformer(md, self.config[0]).apply()

                        if ca.write_tags(md, tag_id):
                            if not ca_saved:
                                success_count += 1
                                ca_saved = True
                        else:
                            failed_list.append(ca.path)

                    ca.reset_cache()
                    ca.load_cache({*self.selected_read_tags, *self.selected_write_tags})

                prog_dialog.hide()
                QtCore.QCoreApplication.processEvents()
                self.fileSelectionList.update_selected_rows()
                self.update_info_box()
                self.update_menus()

                summary = f"Successfully copied tags in {success_count} archive(s)."
                if len(failed_list) > 0:
                    summary += f"\n\nThe copy operation failed in the following {len(failed_list)} archive(s):\n"
                    for f in failed_list:
                        summary += f"\t{f}\n"

                dlg = LogWindow(self)
                dlg.set_text(summary)
                dlg.setWindowTitle("Tag Copy Summary")
                dlg.exec()

    def auto_tag_log(self, text: str) -> None:
        if self.atprogdialog is not None:
            self.atprogdialog.textEdit.append(text.rstrip())
            self.atprogdialog.textEdit.ensureCursorVisible()
            QtCore.QCoreApplication.processEvents()
            QtCore.QCoreApplication.processEvents()
            QtCore.QCoreApplication.processEvents()

    def identify_and_tag_single_archive(
        self, ca: ComicArchive, match_results: OnlineMatchResults, dlg: AutoTagStartWindow
    ) -> tuple[bool, OnlineMatchResults]:

        success = False
        ii = IssueIdentifier(ca, self.config[0], self.current_talker())

        # read in tags, and parse file name if not there
        md, error = self.read_selected_tags(self.selected_read_tags, ca)
        if error is not None:
            QtWidgets.QMessageBox.warning(
                self,
                "Aborting...",
                f"One or more of the read tags failed to load for {ca.path}. Aborting to prevent any possible further damage. Check log for details.",
            )
            logger.error("Failed to load tags from %s: %s", self.ca.path, error)
            return False, match_results

        if md.is_empty:
            md = ca.metadata_from_filename(
                self.config[0].Filename_Parsing__filename_parser,
                self.config[0].Filename_Parsing__remove_c2c,
                self.config[0].Filename_Parsing__remove_fcbd,
                self.config[0].Filename_Parsing__remove_publisher,
                dlg.split_words,
                self.config[0].Filename_Parsing__allow_issue_start_with_letter,
                self.config[0].Filename_Parsing__protofolius_issue_number_scheme,
            )
            if dlg.ignore_leading_digits_in_filename and md.series is not None:
                # remove all leading numbers
                md.series = re.sub(r"(^[\d.]*)(.*)", r"\2", md.series)

        # use the dialog specified search string
        if dlg.search_string:
            md.series = dlg.search_string

        if md is None or md.is_empty:
            logger.error("No metadata given to search online with!")
            return False, match_results

        if dlg.dont_use_year:
            md.year = None
        if md.issue is None or md.issue == "":
            if dlg.assume_issue_one:
                md.issue = "1"
            else:
                md.issue = utils.xlate(md.volume)

        ii.set_output_function(self.auto_tag_log)
        if self.atprogdialog is not None:
            ii.set_cover_url_callback(self.atprogdialog.set_test_image)
        ii.series_match_thresh = dlg.name_length_match_tolerance

        result, matches = ii.identify(ca, md)

        found_match = False
        choices = False
        low_confidence = False

        if result == ii.result_no_matches:
            pass
        elif result == ii.result_found_match_but_bad_cover_score:
            low_confidence = True
            found_match = True
        elif result == ii.result_found_match_but_not_first_page:
            found_match = True
        elif result == ii.result_multiple_matches_with_bad_image_scores:
            low_confidence = True
            choices = True
        elif result == ii.result_one_good_match:
            found_match = True
        elif result == ii.result_multiple_good_matches:
            choices = True

        if choices:
            if low_confidence:
                self.auto_tag_log("Online search: Multiple low-confidence matches.  Save aborted\n")
                match_results.low_confidence_matches.append(
                    Result(
                        Action.save,
                        Status.match_failure,
                        ca.path,
                        online_results=matches,
                        match_status=MatchStatus.low_confidence_match,
                    )
                )
            else:
                self.auto_tag_log("Online search: Multiple matches.  Save aborted\n")
                match_results.multiple_matches.append(
                    Result(
                        Action.save,
                        Status.match_failure,
                        ca.path,
                        online_results=matches,
                        match_status=MatchStatus.multiple_match,
                    )
                )
        elif low_confidence and not dlg.auto_save_on_low:
            self.auto_tag_log("Online search: Low confidence match.  Save aborted\n")
            match_results.low_confidence_matches.append(
                Result(
                    Action.save,
                    Status.match_failure,
                    ca.path,
                    online_results=matches,
                    match_status=MatchStatus.low_confidence_match,
                )
            )
        elif not found_match:
            self.auto_tag_log("Online search: No match found.  Save aborted\n")
            match_results.no_matches.append(
                Result(
                    Action.save,
                    Status.match_failure,
                    ca.path,
                    online_results=matches,
                    match_status=MatchStatus.no_match,
                )
            )
        else:
            # a single match!
            if low_confidence:
                self.auto_tag_log("Online search: Low confidence match, but saving anyways, as indicated...\n")

            # now get the particular issue data
            QtWidgets.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.CursorShape.WaitCursor))

            try:

                ct_md = self.current_talker().fetch_comic_data(matches[0].issue_id)
            except TalkerError:
                logger.exception("Save aborted.")
                return False, match_results

            QtWidgets.QApplication.restoreOverrideCursor()

            if ct_md is None or ct_md.is_empty:
                match_results.fetch_data_failures.append(
                    Result(
                        Action.save,
                        Status.fetch_data_failure,
                        ca.path,
                        online_results=matches,
                        match_status=MatchStatus.good_match,
                    )
                )

            if ct_md is not None:
                temp_opts = cast(ct_ns, settngs.get_namespace(self.config, True, True, True, False)[0])
                temp_opts.Auto_Tag__clear_tags = dlg.cbxClearMetadata.isChecked()

                md = prepare_metadata(md, ct_md, temp_opts)

                res = Result(
                    Action.save,
                    status=Status.success,
                    original_path=ca.path,
                    online_results=matches,
                    match_status=MatchStatus.good_match,
                    md=md,
                    tags_written=self.selected_write_tags,
                )

                def write_Tags() -> bool:
                    for tag_id in self.selected_write_tags:
                        # write out the new data
                        if not ca.write_tags(md, tag_id):
                            self.auto_tag_log(
                                f"{tags[tag_id].name()} save failed! Aborting any additional tag saves.\n"
                            )
                            return False
                    return True

                # Save tags
                if write_Tags():
                    match_results.good_matches.append(res)
                    success = True
                    self.auto_tag_log("Save complete!\n")
                else:
                    res.status = Status.write_failure
                    match_results.write_failures.append(res)

                ca.reset_cache()
                ca.load_cache({*self.selected_read_tags, *self.selected_write_tags})

        return success, match_results

    def auto_tag(self) -> None:
        ca_list = self.fileSelectionList.get_selected_archive_list()
        tag_names = ", ".join([tags[tag_id].name() for tag_id in self.selected_write_tags])

        if len(ca_list) == 0:
            QtWidgets.QMessageBox.information(self, "Auto-Tag", "No archives selected!")
            return

        if not self.dirty_flag_verification(
            "Auto-Tag", "If you auto-tag now, unsaved data in the form will be lost.  Are you sure?"
        ):
            return

        atstartdlg = AutoTagStartWindow(
            self,
            self.config[0],
            (
                f"You have selected {len(ca_list)} archive(s) to automatically identify and write "
                + f"{tag_names} tags to.\n\nPlease choose config below, and select OK to Auto-Tag."
            ),
        )

        atstartdlg.adjustSize()
        atstartdlg.setModal(True)
        if not atstartdlg.exec():
            return

        self.atprogdialog = AutoTagProgressWindow(self, self.current_talker())
        self.atprogdialog.setModal(True)
        self.atprogdialog.show()
        self.atprogdialog.progressBar.setMaximum(len(ca_list))
        self.atprogdialog.setWindowTitle("Auto-Tagging")
        center_window_on_parent(self.atprogdialog)

        self.auto_tag_log("==========================================================================\n")
        self.auto_tag_log(f"Auto-Tagging Started for {len(ca_list)} items\n")

        match_results = OnlineMatchResults()
        archives_to_remove = []
        for prog_idx, ca in enumerate(ca_list):
            self.auto_tag_log("==========================================================================\n")
            self.auto_tag_log(f"Auto-Tagging {prog_idx} of {len(ca_list)}\n")
            self.auto_tag_log(f"{ca.path}\n")
            try:
                cover_idx = ca.read_tags(self.selected_read_tags[0]).get_cover_page_index_list()[0]
            except Exception as e:
                cover_idx = 0
                logger.error("Failed to load metadata for %s: %s", ca.path, e)
            image_data = ca.get_page(cover_idx)
            self.atprogdialog.set_archive_image(image_data)
            self.atprogdialog.set_test_image(b"")

            QtCore.QCoreApplication.processEvents()
            if self.atprogdialog.isdone:
                break
            self.atprogdialog.progressBar.setValue(prog_idx)

            self.atprogdialog.label.setText(str(ca.path))
            QtCore.QCoreApplication.processEvents()

            if ca.is_writable():
                success, match_results = self.identify_and_tag_single_archive(ca, match_results, atstartdlg)

                if success and atstartdlg.remove_after_success:
                    archives_to_remove.append(ca)

        self.atprogdialog.close()

        if atstartdlg.remove_after_success:
            self.fileSelectionList.remove_archive_list(archives_to_remove)
        self.fileSelectionList.update_selected_rows()

        new_ca = self.fileSelectionList.get_current_archive()
        if new_ca is not None:
            self.load_archive(new_ca)
        self.atprogdialog = None

        summary = ""
        summary += f"Successfully added {tag_names} tags to {len(match_results.good_matches)} archive(s)\n"

        if len(match_results.multiple_matches) > 0:
            summary += f"Archives with multiple matches: {len(match_results.multiple_matches)}\n"
        if len(match_results.low_confidence_matches) > 0:
            summary += (
                f"Archives with one or more low-confidence matches: {len(match_results.low_confidence_matches)}\n"
            )
        if len(match_results.no_matches) > 0:
            summary += f"Archives with no matches: {len(match_results.no_matches)}\n"
        if len(match_results.fetch_data_failures) > 0:
            summary += f"Archives that failed due to data fetch errors: {len(match_results.fetch_data_failures)}\n"
        if len(match_results.write_failures) > 0:
            summary += f"Archives that failed due to file writing errors: {len(match_results.write_failures)}\n"

        self.auto_tag_log(summary)

        sum_selectable = len(match_results.multiple_matches) + len(match_results.low_confidence_matches)
        if sum_selectable > 0:
            summary += (
                "\n\nDo you want to manually select the ones with multiple matches and/or low-confidence matches now?"
            )

            reply = QtWidgets.QMessageBox.question(
                self,
                "Auto-Tag Summary",
                summary,
                QtWidgets.QMessageBox.StandardButton.Yes,
                QtWidgets.QMessageBox.StandardButton.No,
            )

            match_results.multiple_matches.extend(match_results.low_confidence_matches)
            if reply == QtWidgets.QMessageBox.StandardButton.Yes:
                matchdlg = AutoTagMatchWindow(
                    self,
                    match_results.multiple_matches,
                    self.selected_write_tags,
                    lambda match: self.current_talker().fetch_comic_data(match.issue_id),
                    self.config[0],
                    self.current_talker(),
                )
                matchdlg.setModal(True)
                matchdlg.exec()
                self.fileSelectionList.update_selected_rows()
                new_ca = self.fileSelectionList.get_current_archive()
                if new_ca is not None:
                    self.load_archive(new_ca)

        else:
            QtWidgets.QMessageBox.information(self, self.tr("Auto-Tag Summary"), self.tr(summary))
        logger.info(summary)

    def exception(self, message: str) -> None:
        errorbox = QtWidgets.QMessageBox()
        errorbox.setText(message)
        errorbox.exec()

    def dirty_flag_verification(self, title: str, desc: str) -> bool:
        if self.dirty_flag:
            reply = QtWidgets.QMessageBox.question(
                self,
                title,
                desc,
                (
                    QtWidgets.QMessageBox.StandardButton.Save
                    | QtWidgets.QMessageBox.StandardButton.Cancel
                    | QtWidgets.QMessageBox.StandardButton.Discard
                ),
                QtWidgets.QMessageBox.StandardButton.Cancel,
            )

            if reply == QtWidgets.QMessageBox.StandardButton.Discard:
                return True
            if reply == QtWidgets.QMessageBox.StandardButton.Save:
                self.write_tags()
                return True
            return False
        return True

    def closeEvent(self, event: QtGui.QCloseEvent) -> None:
        if self.dirty_flag_verification(
            f"Exit {self.appName}", "If you quit now, data in the form will be lost.  Are you sure?"
        ):
            appsize = self.size()
            self.config[0].internal__window_width = appsize.width()
            self.config[0].internal__window_height = appsize.height()
            self.config[0].internal__window_x = self.x()
            self.config[0].internal__window_y = self.y()
            self.config[0].internal__form_width = self.splitter.sizes()[0]
            self.config[0].internal__list_width = self.splitter.sizes()[1]
            (
                self.config[0].internal__sort_column,
                self.config[0].internal__sort_direction,
            ) = self.fileSelectionList.get_sorting()
            ctsettings.save_file(self.config, self.config[0].Runtime_Options__config.user_config_dir / "settings.json")

            event.accept()
        else:
            event.ignore()

    def show_page_browser(self) -> None:
        if self.page_browser is None:
            self.page_browser = PageBrowserWindow(self, self.metadata)
            if self.comic_archive is not None:
                self.page_browser.set_comic_archive(self.comic_archive)
            self.page_browser.finished.connect(self.page_browser_closed)

    def page_browser_closed(self) -> None:
        self.page_browser = None

    def view_raw_tags(self, tag_id: str) -> None:
        tag = tags[tag_id]
        if self.comic_archive is not None and self.comic_archive.has_tags(tag.id):
            dlg = LogWindow(self)
            dlg.set_text(self.comic_archive.read_raw_tags(tag.id))
            dlg.setWindowTitle(f"Raw {tag.name()} Tag View")
            dlg.exec()

    def show_wiki(self) -> None:
        webbrowser.open("https://github.com/comictagger/comictagger/wiki")

    def report_bug(self) -> None:
        webbrowser.open("https://github.com/comictagger/comictagger/issues")

    def show_forum(self) -> None:
        webbrowser.open("https://github.com/comictagger/comictagger/discussions")

    def front_cover_changed(self) -> None:
        self.metadata.pages = self.page_list_editor.get_page_list()
        self.update_cover_image()

    def page_list_order_changed(self) -> None:
        self.metadata.pages = self.page_list_editor.get_page_list()

    def apply_cbl_transform(self) -> None:
        self.form_to_metadata()
        self.metadata = CBLTransformer(self.metadata, self.config[0]).apply()
        self.metadata_to_form()

    def recalc_page_dimensions(self) -> None:
        QtWidgets.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.CursorShape.WaitCursor))
        for p in self.metadata.pages:
            p.byte_size = None
            p.height = None
            p.width = None
        self.set_dirty_flag()
        QtWidgets.QApplication.restoreOverrideCursor()

    def rename_archive(self) -> None:
        ca_list = self.fileSelectionList.get_selected_archive_list()

        if len(ca_list) == 0:
            QtWidgets.QMessageBox.information(self, "Rename", "No archives selected!")
            return

        if self.dirty_flag_verification(
            "File Rename", "If you rename files now, unsaved data in the form will be lost.  Are you sure?"
        ):
            dlg = RenameWindow(self, ca_list, self.selected_read_tags, self.config, self.talkers)
            dlg.setModal(True)
            if dlg.exec() and self.comic_archive is not None:
                self.fileSelectionList.update_selected_rows()
                self.load_archive(self.comic_archive)

    def load_archive(self, comic_archive: ComicArchive) -> None:
        self.comic_archive = None
        self.clear_form()
        self.metadata = GenericMetadata()

        if not os.path.exists(comic_archive.path):
            self.fileSelectionList.dirty_flag = False
            self.fileSelectionList.remove_archive_list([comic_archive])
            QtCore.QTimer.singleShot(1, self.fileSelectionList.revert_selection)
            return

        self.config[0].internal__last_opened_folder = os.path.abspath(os.path.split(comic_archive.path)[0])
        self.comic_archive = comic_archive

        self.metadata, error = self.read_selected_tags(self.selected_read_tags, self.comic_archive)
        if error is not None:
            logger.error("Failed to load tags from %s: %s", self.comic_archive.path, error)
            self.exception(f"Failed to load tags from {self.comic_archive.path}, see log for details\n\n")

        self.update_ui_for_archive()

    def read_selected_tags(self, tag_ids: list[str], ca: ComicArchive) -> tuple[GenericMetadata, Exception | None]:
        md = GenericMetadata()
        error = None
        try:
            for tag_id in tag_ids:
                metadata = ca.read_tags(tag_id)
                md.overlay(
                    metadata,
                    mode=self.config[0].Metadata_Options__tag_merge,
                    merge_lists=self.config[0].Metadata_Options__tag_merge_lists,
                )
        except Exception as e:
            error = e

        return md, error

    def file_list_cleared(self) -> None:
        self.reset_app()

    def splitter_moved_event(self, w1: int, w2: int) -> None:
        scrollbar_w = 0
        if self.scrollArea.verticalScrollBar().isVisible():
            scrollbar_w = self.scrollArea.verticalScrollBar().width()

        new_w = self.scrollArea.width() - scrollbar_w - 5
        self.scrollAreaWidgetContents.resize(new_w, self.scrollAreaWidgetContents.height())

    def resizeEvent(self, ev: QtGui.QResizeEvent | None) -> None:
        self.splitter_moved_event(0, 0)

    def tab_changed(self, idx: int) -> None:
        if idx == 0:
            self.splitter_moved_event(0, 0)

    def check_latest_version_online(self) -> None:
        version_checker = VersionChecker()
        self.version_check_complete(version_checker.get_latest_version())

    def version_check_complete(self, new_version: tuple[str, str]) -> None:
        if new_version[0] not in (self.version, self.config[0].Dialog_Flags__dont_notify_about_this_version):
            website = "https://github.com/comictagger/comictagger"
            checked = OptionalMessageDialog.msg(
                self,
                "New version available!",
                f"New version ({new_version[1]}) available!<br>(You are currently running {self.version})<br><br>"
                f"Visit <a href='{website}/releases/latest'>{website}/releases/latest</a> for more info.<br><br>",
                False,
                "Don't tell me about this version again",
            )
            if checked:
                self.config[0].Dialog_Flags__dont_notify_about_this_version = new_version[0]

    def on_incoming_socket_connection(self) -> None:
        # Accept connection from other instance.
        # Read in the file list if they're giving it, and add to our own list
        local_socket = self.socketServer.nextPendingConnection()
        if local_socket.waitForReadyRead(3000):
            byte_array = local_socket.readAll().data()
            if len(byte_array) > 0:
                obj = pickle.loads(byte_array)
                local_socket.disconnectFromServer()
                if isinstance(obj, list):
                    self.fileSelectionList.add_path_list(obj)

        self.bring_to_top()

    def bring_to_top(self) -> None:
        if platform.system() == "Windows":
            self.showNormal()
            self.raise_()
            self.activateWindow()
            try:
                import win32con
                import win32gui

                hwnd = self.effectiveWinId()
                rect = win32gui.GetWindowRect(hwnd)
                x = rect[0]
                y = rect[1]
                w = rect[2] - x
                h = rect[3] - y
                # mark it "always on top", just for a moment, to force it to
                # the top
                win32gui.SetWindowPos(hwnd, win32con.HWND_TOPMOST, x, y, w, h, 0)
                win32gui.SetWindowPos(hwnd, win32con.HWND_NOTOPMOST, x, y, w, h, 0)
            except Exception:
                logger.exception("Fail to bring window to top")
        elif platform.system() == "Darwin":
            self.raise_()
            self.showNormal()
            self.activateWindow()
        else:
            flags = QtCore.Qt.WindowType(self.windowFlags())
            self.setWindowFlags(
                flags | QtCore.Qt.WindowType.WindowStaysOnTopHint | QtCore.Qt.WindowType.X11BypassWindowManagerHint
            )
            QtCore.QCoreApplication.processEvents()
            self.setWindowFlags(flags)
            self.show()

    def auto_imprint(self) -> None:
        self.form_to_metadata()
        self.metadata.fix_publisher()
        self.metadata_to_form()
