"""Playlist table model and spreadsheet-like table view."""

from __future__ import annotations

import json
import re
from dataclasses import dataclass
from pathlib import Path

from PySide6.QtCore import (
    QAbstractTableModel,
    QByteArray,
    QEvent,
    QItemSelectionModel,
    QMimeData,
    QModelIndex,
    QPersistentModelIndex,
    QTimer,
    Qt,
    Signal,
)
from PySide6.QtGui import QKeySequence, QPalette
from PySide6.QtWidgets import (
    QApplication,
    QAbstractItemDelegate,
    QAbstractItemView,
    QCompleter,
    QLineEdit,
    QStyledItemDelegate,
    QStyle,
    QStyleOptionViewItem,
    QTableView,
)

from .metadata import Track
from .i18n import tr
from .tag_schema import (
    TAG_KEYS,
    english_tag_name,
    normalize_tag_columns,
)


class GenreCompletionLineEdit(QLineEdit):
    """Line editor that can expose every genre choice as a dropdown."""

    def show_all_completions(self) -> None:
        completer = self.completer()
        if completer is None:
            return
        completer.setCompletionPrefix("")
        if not completer.completionCount():
            return
        completer.setCurrentRow(0)
        completer.complete()

    def mousePressEvent(self, event) -> None:
        super().mousePressEvent(event)
        QTimer.singleShot(0, self.show_all_completions)


@dataclass(frozen=True, slots=True)
class TableColumn:
    english: str
    japanese: str
    key: str
    editable: bool


class PlaylistModel(QAbstractTableModel):
    tracksChanged = Signal()
    undoAvailabilityChanged = Signal(bool)
    playlistUndoAvailabilityChanged = Signal(bool)

    UNDO_LIMIT = 5

    SYSTEM_PREFIX = (TableColumn("#", "#", "number", False),)
    SYSTEM_SUFFIX = (
        TableColumn("Filename", "ファイル名", "filename", True),
        TableColumn("Format", "形式", "codec", False),
        TableColumn("Quality", "音質", "quality_text", False),
        TableColumn("Duration", "長さ", "duration_text", False),
    )
    INTERNAL_MIME = "application/x-musicgridstudio-playlist-rows"

    def __init__(self, tag_columns: list[dict[str, str]] | None = None) -> None:
        super().__init__()
        self.tag_columns = normalize_tag_columns(tag_columns)
        self.COLUMNS: list[TableColumn] = []
        self._rebuild_columns()
        self.tracks: list[Track] = []
        self.dirty_rows: set[int] = set()
        self._undo_history: list[list[tuple[int, str, object]]] = []
        self._undo_group: list[tuple[int, str, object]] = []
        self._undo_group_depth = 0
        self._playlist_undo_history: list[list[Track]] = []

    def _rebuild_columns(self) -> None:
        tag_specs = [
            TableColumn(
                english_tag_name(entry["key"]),
                entry["ja"],
                entry["key"],
                True,
            )
            for entry in self.tag_columns
        ]
        self.COLUMNS = [
            *self.SYSTEM_PREFIX,
            *tag_specs,
            *self.SYSTEM_SUFFIX,
        ]

    def set_tag_columns(self, columns: list[dict[str, str]]) -> None:
        normalized = normalize_tag_columns(columns)
        if normalized == self.tag_columns:
            return
        self.beginResetModel()
        self.tag_columns = normalized
        self._rebuild_columns()
        self.endResetModel()

    def column_key(self, column: int) -> str:
        if not 0 <= column < len(self.COLUMNS):
            return ""
        return self.COLUMNS[column].key

    def column_for_key(self, key: str) -> int | None:
        return next(
            (
                column
                for column, definition in enumerate(self.COLUMNS)
                if definition.key == key
            ),
            None,
        )

    @property
    def undo_count(self) -> int:
        return len(self._undo_history)

    @property
    def playlist_undo_count(self) -> int:
        return len(self._playlist_undo_history)

    def begin_edit_group(self) -> None:
        if self._undo_group_depth == 0:
            self._undo_group = []
        self._undo_group_depth += 1

    def end_edit_group(self) -> None:
        if self._undo_group_depth == 0:
            return
        self._undo_group_depth -= 1
        if self._undo_group_depth == 0:
            changes = self._undo_group
            self._undo_group = []
            self._push_undo_entry(changes)

    def clear_undo_history(self) -> None:
        had_history = bool(self._undo_history)
        self._undo_history.clear()
        self._undo_group.clear()
        self._undo_group_depth = 0
        if had_history:
            self.undoAvailabilityChanged.emit(False)

    def clear_playlist_undo_history(self) -> None:
        had_history = bool(self._playlist_undo_history)
        self._playlist_undo_history.clear()
        if had_history:
            self.playlistUndoAvailabilityChanged.emit(False)

    def _push_playlist_undo(self, tracks: list[Track] | None = None) -> None:
        snapshot = list(self.tracks if tracks is None else tracks)
        self._playlist_undo_history.append(snapshot)
        if len(self._playlist_undo_history) > self.UNDO_LIMIT:
            del self._playlist_undo_history[:-self.UNDO_LIMIT]
        self.playlistUndoAvailabilityChanged.emit(True)

    def undo_last_playlist_operation(self) -> bool:
        """Restore the track list before the latest structural operation."""

        if not self._playlist_undo_history:
            return False
        tracks = self._playlist_undo_history.pop()
        self.beginResetModel()
        self.tracks = list(tracks)
        self.dirty_rows.clear()
        self.endResetModel()
        # Row-based metadata history is no longer safe after a structural
        # restore, so keep the two undo systems independent.
        self.clear_undo_history()
        self.playlistUndoAvailabilityChanged.emit(
            bool(self._playlist_undo_history)
        )
        self.tracksChanged.emit()
        return True

    def undo_last_edit(self) -> bool:
        if not self._undo_history:
            return False
        changes = self._undo_history.pop()
        changed_rows: set[int] = set()
        for row, key, old_value in reversed(changes):
            if not 0 <= row < len(self.tracks):
                continue
            self._assign_value(self.tracks[row], key, old_value)
            self.dirty_rows.add(row)
            changed_rows.add(row)
            column = self.column_for_key(key)
            if column is not None:
                index = self.index(row, column)
                self.dataChanged.emit(
                    index,
                    index,
                    [Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole],
                )
        self.undoAvailabilityChanged.emit(bool(self._undo_history))
        if changed_rows:
            self.tracksChanged.emit()
            return True
        return False

    def _push_undo_entry(
        self, changes: list[tuple[int, str, object]]
    ) -> None:
        if not changes:
            return
        self._undo_history.append(changes)
        if len(self._undo_history) > self.UNDO_LIMIT:
            del self._undo_history[:-self.UNDO_LIMIT]
        self.undoAvailabilityChanged.emit(True)

    def _record_undo(self, row: int, key: str, old_value: object) -> None:
        change = (row, key, old_value)
        if self._undo_group_depth:
            if not any(
                previous_row == row and previous_key == key
                for previous_row, previous_key, _ in self._undo_group
            ):
                self._undo_group.append(change)
            return
        self._push_undo_entry([change])

    @staticmethod
    def _value(track: Track, key: str) -> object:
        if key in TAG_KEYS:
            return track.tag_value(key)
        return getattr(track, key)

    @staticmethod
    def _assign_value(track: Track, key: str, value: object) -> None:
        if key in TAG_KEYS:
            track.set_tag_value(key, value)
        else:
            setattr(track, key, value)

    def _set_value(self, row: int, key: str, value: object) -> bool:
        track = self.tracks[row]
        old_value = self._value(track, key)
        if old_value == value:
            return False
        self._record_undo(row, key, old_value)
        self._assign_value(track, key, value)
        self.dirty_rows.add(row)
        return True

    def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
        return 0 if parent.isValid() else len(self.tracks)

    def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:
        return 0 if parent.isValid() else len(self.COLUMNS)

    def headerData(self, section: int, orientation: Qt.Orientation, role: int = Qt.ItemDataRole.DisplayRole):
        if role != Qt.ItemDataRole.DisplayRole:
            return None
        if orientation == Qt.Orientation.Horizontal:
            definition = self.COLUMNS[section]
            return tr(definition.english, definition.japanese)
        return section + 1

    def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
        if not index.isValid() or not 0 <= index.row() < len(self.tracks):
            return None
        track = self.tracks[index.row()]
        key = self.COLUMNS[index.column()].key
        if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole):
            if key == "number":
                return index.row() + 1
            value = self._value(track, key)
            if key == "rating wmp" and role == Qt.ItemDataRole.DisplayRole:
                rating = int(value or 0)
                return "★" * rating + "☆" * (5 - rating)
            if key == "unsycedlyrics" and role == Qt.ItemDataRole.DisplayRole:
                return tr(
                    "Lyrics available — Open…" if value else "Open Lyrics…",
                    "歌詞あり・開く…" if value else "歌詞を開く…",
                )
            return value
        if role == Qt.ItemDataRole.ToolTipRole:
            if key == "rating wmp":
                return tr(
                    "Click a star in Playback or Edit Mode to set 1–5. Click the current star again or right-click to clear it. Playback-mode changes are saved automatically.",
                    "再生モード・編集モードのどちらでも、星をクリックして1～5を設定できます。同じ星を再クリック、または右クリックすると未評価へ戻ります。再生モードの変更は自動保存されます。",
                )
            if key == "unsycedlyrics":
                return tr(
                    "Click to open the unsynchronized lyrics.",
                    "クリックして非同期歌詞を開きます。",
                )
            return None
        if role == Qt.ItemDataRole.TextAlignmentRole and (
            key in {
                "number",
                "discnumber",
                "disctotal",
                "track",
                "tracktotal",
                "rating wmp",
                "duration_text",
            }
        ):
            return int(Qt.AlignmentFlag.AlignCenter)
        if role == Qt.ItemDataRole.UserRole:
            return track
        return None

    def flags(self, index: QModelIndex) -> Qt.ItemFlag:
        base = (
            Qt.ItemFlag.ItemIsEnabled
            | Qt.ItemFlag.ItemIsSelectable
            | Qt.ItemFlag.ItemIsDragEnabled
            | Qt.ItemFlag.ItemIsDropEnabled
        )
        if index.isValid() and self.COLUMNS[index.column()].editable:
            base |= Qt.ItemFlag.ItemIsEditable
        return base

    def setData(self, index: QModelIndex, value, role: int = Qt.ItemDataRole.EditRole) -> bool:
        if (
            role != Qt.ItemDataRole.EditRole
            or not index.isValid()
            or not self.COLUMNS[index.column()].editable
        ):
            return False
        key = self.COLUMNS[index.column()].key
        if key == "rating wmp":
            if isinstance(value, str) and ("★" in value or "☆" in value):
                normalized_value: object = value.count("★")
            else:
                try:
                    normalized_value = max(0, min(5, int(value)))
                except (TypeError, ValueError):
                    normalized_value = 0
        else:
            normalized_value = str(value).strip()
        changed = self._set_value(index.row(), key, normalized_value)
        if not changed:
            return True
        self.dataChanged.emit(index, index, [role, Qt.ItemDataRole.DisplayRole])
        self.tracksChanged.emit()
        return True

    def set_tracks(self, tracks: list[Track]) -> None:
        self.beginResetModel()
        self.tracks = tracks
        self.dirty_rows.clear()
        self.endResetModel()
        self.clear_undo_history()
        self.clear_playlist_undo_history()
        self.tracksChanged.emit()

    def retranslate(self) -> None:
        self.headerDataChanged.emit(
            Qt.Orientation.Horizontal, 0, len(self.COLUMNS) - 1
        )
        if self.rowCount() and self.columnCount():
            self.dataChanged.emit(
                self.index(0, 0),
                self.index(self.rowCount() - 1, self.columnCount() - 1),
                [Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.ToolTipRole],
            )

    def auto_number(self, disc_number: int, track_start: int) -> None:
        if not self.tracks:
            return
        disc_text = f"{disc_number:02d}"
        final_track = track_start + len(self.tracks) - 1
        width = max(2, len(str(final_track)))
        total_text = f"{final_track:0{width}d}"
        changed_rows: set[int] = set()
        self.begin_edit_group()
        try:
            for offset, _track in enumerate(self.tracks):
                values = {
                    "discnumber": disc_text,
                    "disctotal": disc_text,
                    "track": f"{track_start + offset:0{width}d}",
                    "tracktotal": total_text,
                }
                row_changed = False
                for key, value in values.items():
                    if self._set_value(offset, key, value):
                        row_changed = True
                if row_changed:
                    changed_rows.add(offset)
        finally:
            self.end_edit_group()
        if not changed_rows:
            return
        visible_columns = [
            column
            for key in ("discnumber", "disctotal", "track", "tracktotal")
            if (column := self.column_for_key(key)) is not None
        ]
        if visible_columns:
            self.dataChanged.emit(
                self.index(min(changed_rows), min(visible_columns)),
                self.index(max(changed_rows), max(visible_columns)),
                [Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole],
            )
        self.tracksChanged.emit()

    def apply_title_suffix(
        self,
        rows: list[int],
        suffix: str,
        *,
        known_suffixes: list[str] | tuple[str, ...] | None = None,
        insert_space: bool = False,
    ) -> int:
        suffix = str(suffix).strip()
        if not suffix:
            raise ValueError(tr("The suffix is empty.", "接尾辞が空欄です。"))
        choices = [
            suffix,
            *(known_suffixes or ()),
            "(off vocal)",
            "(instrumental)",
        ]
        unique_choices: list[str] = []
        seen: set[str] = set()
        for choice in choices:
            normalized = str(choice).strip()
            identity = normalized.casefold()
            if not normalized or identity in seen:
                continue
            unique_choices.append(normalized)
            seen.add(identity)
        suffix_pattern = re.compile(
            r"\s*(?:"
            + "|".join(
                re.escape(choice)
                for choice in sorted(unique_choices, key=len, reverse=True)
            )
            + r")\s*$",
            re.IGNORECASE,
        )
        changed_rows: list[int] = []
        self.begin_edit_group()
        try:
            for row in sorted(set(rows)):
                if not 0 <= row < len(self.tracks):
                    continue
                track = self.tracks[row]
                base_title = track.title.rstrip()
                while suffix_pattern.search(base_title):
                    base_title = suffix_pattern.sub("", base_title).rstrip()
                separator = " " if insert_space and base_title else ""
                updated = f"{base_title}{separator}{suffix}"
                if self._set_value(row, "title", updated):
                    changed_rows.append(row)
        finally:
            self.end_edit_group()
        if changed_rows:
            title_column = self.column_for_key("title")
            if title_column is not None:
                self.dataChanged.emit(
                    self.index(min(changed_rows), title_column),
                    self.index(max(changed_rows), title_column),
                    [Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole],
                )
            self.tracksChanged.emit()
        return len(changed_rows)

    def apply_bulk_values(
        self, rows: list[int], values: dict[str, object]
    ) -> int:
        """Apply checked metadata fields as one undoable edit operation."""
        normalized_values: dict[str, object] = {}
        for key, value in values.items():
            if key not in TAG_KEYS:
                continue
            if key == "rating wmp":
                try:
                    normalized_values[key] = max(0, min(5, int(value)))
                except (TypeError, ValueError):
                    normalized_values[key] = 0
            else:
                normalized_values[key] = str(value).strip()
        if not normalized_values:
            return 0

        changed_rows: set[int] = set()
        changed_columns: set[int] = set()
        self.begin_edit_group()
        try:
            for row in sorted(set(rows)):
                if not 0 <= row < len(self.tracks):
                    continue
                for key, value in normalized_values.items():
                    if self._set_value(row, key, value):
                        changed_rows.add(row)
                        column = self.column_for_key(key)
                        if column is not None:
                            changed_columns.add(column)
        finally:
            self.end_edit_group()

        if not changed_rows:
            return 0
        if changed_columns:
            self.dataChanged.emit(
                self.index(min(changed_rows), min(changed_columns)),
                self.index(max(changed_rows), max(changed_columns)),
                [Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole],
            )
        self.tracksChanged.emit()
        return len(changed_rows)

    def sort(
        self,
        column: int,
        order: Qt.SortOrder = Qt.SortOrder.AscendingOrder,
    ) -> None:
        if not 0 <= column < len(self.COLUMNS) or len(self.tracks) < 2:
            return
        previous_tracks = list(self.tracks)
        dirty_ids = {
            id(self.tracks[row])
            for row in self.dirty_rows
            if 0 <= row < len(self.tracks)
        }
        reverse = order == Qt.SortOrder.DescendingOrder
        self.layoutAboutToBeChanged.emit()
        if column == 0:
            if reverse:
                self.tracks.reverse()
        else:
            key = self.COLUMNS[column].key

            def natural_key(track: Track):
                if key == "duration_text":
                    return (0, track.duration_ms)
                if key == "quality_text":
                    return (
                        0,
                        track.sample_rate,
                        track.bits_per_sample,
                        track.channels,
                    )
                value = self._value(track, key)
                if key in {
                    "discnumber",
                    "disctotal",
                    "track",
                    "tracktotal",
                    "rating wmp",
                    "bpm",
                    "duration_ms",
                    "sample_rate",
                    "bits_per_sample",
                    "channels",
                }:
                    try:
                        return (0, int(value))
                    except (TypeError, ValueError):
                        pass
                parts = re.split(r"(\d+)", str(value).casefold())
                return (
                    1,
                    tuple(
                        (1, int(part)) if part.isdecimal() else (0, part)
                        for part in parts
                    ),
                )

            self.tracks.sort(key=natural_key, reverse=reverse)
        changed = any(
            before is not after
            for before, after in zip(previous_tracks, self.tracks)
        )
        self.dirty_rows = {
            row for row, track in enumerate(self.tracks) if id(track) in dirty_ids
        }
        self.layoutChanged.emit()
        self.clear_undo_history()
        if changed:
            self._push_playlist_undo(previous_tracks)
        self.tracksChanged.emit()

    def append_tracks(self, tracks: list[Track]) -> None:
        if not tracks:
            return
        first = len(self.tracks)
        self.beginInsertRows(QModelIndex(), first, first + len(tracks) - 1)
        self.tracks.extend(tracks)
        self.endInsertRows()
        self.clear_undo_history()
        # Adding files starts a new structural sequence.  Keeping an older
        # deletion snapshot here could unexpectedly discard the newly added
        # files when Ctrl+Z is pressed.
        self.clear_playlist_undo_history()
        self.tracksChanged.emit()

    def remove_rows(self, rows: list[int]) -> None:
        valid_rows = sorted(
            {row for row in rows if 0 <= row < len(self.tracks)},
            reverse=True,
        )
        if not valid_rows:
            return
        self._push_playlist_undo()
        for row in valid_rows:
            self.beginRemoveRows(QModelIndex(), row, row)
            del self.tracks[row]
            self.endRemoveRows()
        self.dirty_rows.clear()
        self.clear_undo_history()
        self.tracksChanged.emit()

    def clear_tracks(self) -> int:
        """Remove every track as one playlist operation and return its size."""

        count = len(self.tracks)
        if not count:
            return 0
        self._push_playlist_undo()
        self.beginResetModel()
        self.tracks = []
        self.dirty_rows.clear()
        self.endResetModel()
        self.clear_undo_history()
        self.tracksChanged.emit()
        return count

    def supportedDropActions(self) -> Qt.DropAction:
        # Rows are moved for drops inside the table and copied when dropped
        # onto another playlist in the sidebar.
        return Qt.DropAction.MoveAction | Qt.DropAction.CopyAction

    def mimeTypes(self) -> list[str]:
        return [self.INTERNAL_MIME]

    def mimeData(self, indexes: list[QModelIndex]) -> QMimeData:
        data = QMimeData()
        rows = sorted({index.row() for index in indexes})
        data.setData(self.INTERNAL_MIME, QByteArray(json.dumps(rows).encode("utf-8")))
        return data

    def dropMimeData(
        self,
        data: QMimeData,
        action: Qt.DropAction,
        row: int,
        column: int,
        parent: QModelIndex,
    ) -> bool:
        if action == Qt.DropAction.IgnoreAction:
            return True
        if not data.hasFormat(self.INTERNAL_MIME):
            return False
        try:
            rows = sorted(set(json.loads(bytes(data.data(self.INTERNAL_MIME)))))
        except (TypeError, ValueError, json.JSONDecodeError):
            return False
        if not rows:
            return False
        target = parent.row() if row < 0 and parent.isValid() else row
        if target < 0:
            target = len(self.tracks)
        moving = [self.tracks[item] for item in rows if 0 <= item < len(self.tracks)]
        remaining = [
            track for item, track in enumerate(self.tracks) if item not in set(rows)
        ]
        adjusted_target = target - sum(1 for item in rows if item < target)
        adjusted_target = max(0, min(adjusted_target, len(remaining)))
        reordered = (
            remaining[:adjusted_target] + moving + remaining[adjusted_target:]
        )
        if len(reordered) == len(self.tracks) and all(
            before is after
            for before, after in zip(self.tracks, reordered)
        ):
            return False
        self._push_playlist_undo()
        self.beginResetModel()
        self.tracks = reordered
        self.dirty_rows.clear()
        self.endResetModel()
        self.clear_undo_history()
        self.tracksChanged.emit()
        return True


class SpreadsheetDelegate(QStyledItemDelegate):
    editorCreated = Signal()
    fillSelectionRequested = Signal(str)
    editGroupStarted = Signal()
    editGroupFinished = Signal()
    undoRequested = Signal()

    def paint(self, painter, option, index) -> None:
        column_key = getattr(index.model(), "column_key", lambda _column: "")(
            index.column()
        )
        if column_key not in {"rating wmp", "unsycedlyrics"}:
            super().paint(painter, option, index)
            return
        background_option = QStyleOptionViewItem(option)
        self.initStyleOption(background_option, index)
        background_option.text = ""
        style = (
            background_option.widget.style()
            if background_option.widget is not None
            else QApplication.style()
        )
        style.drawControl(
            QStyle.ControlElement.CE_ItemViewItem,
            background_option,
            painter,
            background_option.widget,
        )
        if column_key == "unsycedlyrics":
            role = (
                QPalette.ColorRole.HighlightedText
                if option.state & QStyle.StateFlag.State_Selected
                else QPalette.ColorRole.Link
            )
            painter.save()
            link_font = painter.font()
            link_font.setUnderline(True)
            painter.setFont(link_font)
            painter.setPen(option.palette.color(role))
            painter.drawText(
                option.rect.adjusted(8, 1, -5, -1),
                int(
                    Qt.AlignmentFlag.AlignLeft
                    | Qt.AlignmentFlag.AlignVCenter
                ),
                str(index.data(Qt.ItemDataRole.DisplayRole) or ""),
            )
            painter.restore()
            return
        try:
            rating = max(
                0,
                min(
                    5,
                    int(index.model().data(index, Qt.ItemDataRole.EditRole) or 0),
                ),
            )
        except (TypeError, ValueError):
            rating = 0
        role = (
            QPalette.ColorRole.HighlightedText
            if option.state & QStyle.StateFlag.State_Selected
            else QPalette.ColorRole.Text
        )
        painter.save()
        painter.setPen(option.palette.color(role))
        star_rect = option.rect.adjusted(3, 1, -3, -1)
        for star in range(5):
            left = star_rect.left() + star_rect.width() * star // 5
            right = star_rect.left() + star_rect.width() * (star + 1) // 5
            painter.drawText(
                left,
                star_rect.top(),
                max(1, right - left),
                star_rect.height(),
                int(Qt.AlignmentFlag.AlignCenter),
                "★" if star < rating else "☆",
            )
        painter.restore()

    def createEditor(self, parent, option, index):
        column_key = getattr(index.model(), "column_key", lambda _column: "")(
            index.column()
        )
        if column_key in {"rating wmp", "unsycedlyrics"}:
            return None
        editor = (
            GenreCompletionLineEdit(parent)
            if column_key == "genre"
            else super().createEditor(parent, option, index)
        )
        if editor is not None:
            if isinstance(editor, QLineEdit):
                # Table cells are translucent so a custom background image can
                # remain visible.  The in-place editor must instead be fully
                # opaque, otherwise the delegate's original text remains
                # visible underneath the text being edited.
                editor.setObjectName("tableCellEditor")
                editor.setAttribute(
                    Qt.WidgetAttribute.WA_StyledBackground,
                    True,
                )
                if column_key == "genre":
                    editor.setFrame(False)
                    suggestions = getattr(
                        self.parent(),
                        "genre_suggestions",
                        [],
                    )
                    completer = QCompleter(list(suggestions), editor)
                    completer.setCaseSensitivity(
                        Qt.CaseSensitivity.CaseInsensitive
                    )
                    completer.setFilterMode(Qt.MatchFlag.MatchStartsWith)
                    completer.setCompletionMode(
                        QCompleter.CompletionMode.PopupCompletion
                    )
                    completer.setWrapAround(False)
                    editor.setCompleter(completer)
                    completer.activated[str].connect(
                        lambda completion, target=editor: self._commit_genre_completion(
                            target,
                            completion,
                        )
                    )
            self.editorCreated.emit()
        return editor

    def _commit_genre_completion(
        self,
        editor: QLineEdit,
        completion: str,
    ) -> None:
        editor.setText(completion)
        self.commitData.emit(editor)
        self.closeEditor.emit(
            editor,
            QAbstractItemDelegate.EndEditHint.NoHint,
        )

    def eventFilter(self, editor, event) -> bool:
        if (
            event.type() == QEvent.Type.KeyPress
            and event.key() in {Qt.Key.Key_Return, Qt.Key.Key_Enter}
            and not event.modifiers() & Qt.KeyboardModifier.ControlModifier
            and isinstance(editor, QLineEdit)
            and editor.completer() is not None
            and editor.completer().popup().isVisible()
        ):
            completer = editor.completer()
            completion_index = completer.popup().currentIndex()
            if not completion_index.isValid() and completer.completionCount():
                completer.setCurrentRow(0)
                completion_index = completer.currentIndex()
            if completion_index.isValid():
                completion = completion_index.data(
                    completer.completionRole()
                )
                if completion is not None:
                    editor.setText(str(completion))
            completer.popup().hide()
            self.commitData.emit(editor)
            self.closeEditor.emit(
                editor,
                QAbstractItemDelegate.EndEditHint.NoHint,
            )
            return True
        if (
            event.type() == QEvent.Type.KeyPress
            and event.matches(QKeySequence.StandardKey.Undo)
            and isinstance(editor, QLineEdit)
        ):
            self.commitData.emit(editor)
            self.closeEditor.emit(
                editor, QAbstractItemDelegate.EndEditHint.NoHint
            )
            self.undoRequested.emit()
            return True
        if (
            event.type() == QEvent.Type.KeyPress
            and event.key() in {Qt.Key.Key_Return, Qt.Key.Key_Enter}
            and event.modifiers() & Qt.KeyboardModifier.ControlModifier
            and isinstance(editor, QLineEdit)
        ):
            value = editor.text()
            self.editGroupStarted.emit()
            try:
                self.commitData.emit(editor)
                self.fillSelectionRequested.emit(value)
                self.closeEditor.emit(
                    editor, QAbstractItemDelegate.EndEditHint.NoHint
                )
            finally:
                self.editGroupFinished.emit()
            return True
        return super().eventFilter(editor, event)


class SpreadsheetTableView(QTableView):
    pathsDropped = Signal(list)
    deleteRequested = Signal(list)
    editModeRequired = Signal()
    undoRequested = Signal()
    ratingChanged = Signal(int, int, int)
    lyricsRequested = Signal(int)

    def __init__(self, parent=None) -> None:
        super().__init__(parent)
        self.setAcceptDrops(True)
        self.setDragEnabled(True)
        self.setDropIndicatorShown(True)
        self.setDragDropMode(QAbstractItemView.DragDropMode.DragDrop)
        self.setDefaultDropAction(Qt.DropAction.MoveAction)
        self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
        self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectItems)
        self.setAlternatingRowColors(True)
        self.setMouseTracking(True)
        self.setSortingEnabled(False)
        self._selection_before_edit: list[QPersistentModelIndex] = []
        self.spreadsheet_delegate = SpreadsheetDelegate(self)
        self.setItemDelegate(self.spreadsheet_delegate)
        self.spreadsheet_delegate.editorCreated.connect(
            self._capture_edit_selection_if_needed
        )
        self.spreadsheet_delegate.fillSelectionRequested.connect(
            self.fill_selection_with_value
        )
        self.spreadsheet_delegate.editGroupStarted.connect(
            self._begin_model_edit_group
        )
        self.spreadsheet_delegate.editGroupFinished.connect(
            self._end_model_edit_group
        )
        self.spreadsheet_delegate.undoRequested.connect(
            self.undoRequested.emit
        )
        self.spreadsheet_delegate.closeEditor.connect(
            lambda *args: self._clear_edit_selection()
        )
        self.genre_suggestions: list[str] = []
        self.editing_enabled = False
        self.set_editing_enabled(False)

    def set_genre_suggestions(self, suggestions: list[str]) -> None:
        self.genre_suggestions = list(suggestions)

    def set_editing_enabled(self, enabled: bool) -> None:
        self.editing_enabled = enabled
        if enabled:
            self.setDragEnabled(False)
            self.setAcceptDrops(False)
            self.setDropIndicatorShown(False)
            self.setDragDropMode(QAbstractItemView.DragDropMode.NoDragDrop)
            self.setEditTriggers(
                QAbstractItemView.EditTrigger.DoubleClicked
                | QAbstractItemView.EditTrigger.EditKeyPressed
                | QAbstractItemView.EditTrigger.AnyKeyPressed
                | QAbstractItemView.EditTrigger.SelectedClicked
            )
        else:
            self._clear_edit_selection()
            self.setAcceptDrops(True)
            self.setDragEnabled(True)
            self.setDropIndicatorShown(True)
            self.setDragDropMode(QAbstractItemView.DragDropMode.DragDrop)
            self.setDefaultDropAction(Qt.DropAction.MoveAction)
            self.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)

    def dragEnterEvent(self, event) -> None:
        if event.mimeData().hasUrls():
            event.acceptProposedAction()
        else:
            super().dragEnterEvent(event)

    def dragMoveEvent(self, event) -> None:
        if event.mimeData().hasUrls():
            event.acceptProposedAction()
        else:
            super().dragMoveEvent(event)

    def dropEvent(self, event) -> None:
        if event.mimeData().hasUrls():
            paths = [
                url.toLocalFile()
                for url in event.mimeData().urls()
                if url.isLocalFile()
            ]
            if paths:
                self.pathsDropped.emit(paths)
                event.acceptProposedAction()
                return
        super().dropEvent(event)

    def keyPressEvent(self, event) -> None:
        if event.matches(QKeySequence.StandardKey.Copy):
            self.copy_selection()
            return
        if event.matches(QKeySequence.StandardKey.Paste):
            if not self.editing_enabled:
                self.editModeRequired.emit()
                return
            self.paste_selection()
            return
        if event.key() == Qt.Key.Key_Delete:
            indexes = self.selectedIndexes()
            if self.editing_enabled:
                self._begin_model_edit_group()
                try:
                    for index in indexes:
                        self.model().setData(
                            index, "", Qt.ItemDataRole.EditRole
                        )
                finally:
                    self._end_model_edit_group()
            else:
                rows = sorted({index.row() for index in indexes})
                if rows:
                    self.deleteRequested.emit(rows)
            return
        if (
            self.editing_enabled
            and event.key() in {Qt.Key.Key_Return, Qt.Key.Key_Enter}
            and event.modifiers() & Qt.KeyboardModifier.ControlModifier
        ):
            current = self.currentIndex()
            if current.isValid():
                value = self.model().data(
                    current, Qt.ItemDataRole.EditRole
                )
                self.fill_selection_with_value(
                    "" if value is None else str(value)
                )
            return
        if (
            self.editing_enabled
            and event.text()
            and not event.modifiers()
            & (
                Qt.KeyboardModifier.ControlModifier
                | Qt.KeyboardModifier.AltModifier
                | Qt.KeyboardModifier.MetaModifier
            )
        ):
            self._selection_before_edit = [
                QPersistentModelIndex(index) for index in self.selectedIndexes()
            ]
        super().keyPressEvent(event)

    def mousePressEvent(self, event) -> None:
        index = self.indexAt(event.position().toPoint())
        column_key = getattr(self.model(), "column_key", lambda _column: "")
        if (
            index.isValid()
            and column_key(index.column()) == "rating wmp"
            and event.button()
            in {Qt.MouseButton.LeftButton, Qt.MouseButton.RightButton}
        ):
            super().mousePressEvent(event)
            current_rating = int(
                self.model().data(index, Qt.ItemDataRole.EditRole) or 0
            )
            if event.button() == Qt.MouseButton.RightButton:
                rating = 0
            else:
                rect = self.visualRect(index)
                relative_x = max(
                    0,
                    min(rect.width() - 1, round(event.position().x()) - rect.left()),
                )
                clicked_rating = max(
                    1,
                    min(5, int(relative_x * 5 / max(1, rect.width())) + 1),
                )
                rating = 0 if clicked_rating == current_rating else clicked_rating
            if rating != current_rating and self.model().setData(
                index, rating, Qt.ItemDataRole.EditRole
            ):
                self.ratingChanged.emit(index.row(), current_rating, rating)
            event.accept()
            return
        super().mousePressEvent(event)

    def mouseReleaseEvent(self, event) -> None:
        index = self.indexAt(event.position().toPoint())
        super().mouseReleaseEvent(event)
        column_key = getattr(self.model(), "column_key", lambda _column: "")
        if (
            index.isValid()
            and column_key(index.column()) == "unsycedlyrics"
            and event.button() == Qt.MouseButton.LeftButton
        ):
            self.lyricsRequested.emit(index.row())
            event.accept()
            return
        self._schedule_genre_dropdown(index)

    def mouseDoubleClickEvent(self, event) -> None:
        index = self.indexAt(event.position().toPoint())
        column_key = getattr(self.model(), "column_key", lambda _column: "")
        if index.isValid() and column_key(index.column()) == "unsycedlyrics":
            event.accept()
            return
        super().mouseDoubleClickEvent(event)
        self._schedule_genre_dropdown(index)

    def mouseMoveEvent(self, event) -> None:
        index = self.indexAt(event.position().toPoint())
        column_key = getattr(self.model(), "column_key", lambda _column: "")
        cursor = (
            Qt.CursorShape.PointingHandCursor
            if index.isValid()
            and column_key(index.column()) == "unsycedlyrics"
            else Qt.CursorShape.ArrowCursor
        )
        self.viewport().setCursor(cursor)
        super().mouseMoveEvent(event)

    def leaveEvent(self, event) -> None:
        self.viewport().setCursor(Qt.CursorShape.ArrowCursor)
        super().leaveEvent(event)

    def _schedule_genre_dropdown(self, index: QModelIndex) -> None:
        column_key = getattr(self.model(), "column_key", lambda _column: "")
        if (
            self.editing_enabled
            and index.isValid()
            and column_key(index.column()) == "genre"
        ):
            QTimer.singleShot(0, self._show_active_genre_dropdown)

    def _show_active_genre_dropdown(self) -> None:
        editor = QApplication.focusWidget()
        if isinstance(editor, GenreCompletionLineEdit):
            editor.show_all_completions()

    def _capture_edit_selection_if_needed(self) -> None:
        if not self._selection_before_edit:
            self._selection_before_edit = [
                QPersistentModelIndex(index) for index in self.selectedIndexes()
            ]

    def _clear_edit_selection(self) -> None:
        self._selection_before_edit = []

    def _begin_model_edit_group(self) -> None:
        model = self.model()
        begin = getattr(model, "begin_edit_group", None)
        if begin is not None:
            begin()

    def _end_model_edit_group(self) -> None:
        model = self.model()
        end = getattr(model, "end_edit_group", None)
        if end is not None:
            end()

    def fill_selection_with_value(self, value: str) -> None:
        if not self.editing_enabled or self.model() is None:
            return
        targets = [
            self.model().index(index.row(), index.column())
            for index in self._selection_before_edit
            if index.isValid()
        ]
        if not targets:
            targets = list(self.selectedIndexes())
        if not targets and self.currentIndex().isValid():
            targets = [self.currentIndex()]
        current = self.currentIndex()
        self._begin_model_edit_group()
        try:
            for index in targets:
                self.model().setData(index, value, Qt.ItemDataRole.EditRole)
        finally:
            self._end_model_edit_group()
        selection_model = self.selectionModel()
        if selection_model is not None and targets:
            selection_model.clearSelection()
            for index in targets:
                selection_model.select(
                    index, QItemSelectionModel.SelectionFlag.Select
                )
            if current.isValid():
                selection_model.setCurrentIndex(
                    current, QItemSelectionModel.SelectionFlag.NoUpdate
                )
        self._clear_edit_selection()

    def copy_selection(self) -> None:
        indexes = self.selectedIndexes()
        if not indexes:
            return
        rows = sorted({index.row() for index in indexes})
        columns = sorted({index.column() for index in indexes})
        selected = {(index.row(), index.column()) for index in indexes}
        lines: list[str] = []
        for row in rows:
            cells = []
            for column in columns:
                if (row, column) in selected:
                    value = self.model().data(
                        self.model().index(row, column), Qt.ItemDataRole.DisplayRole
                    )
                    cells.append("" if value is None else str(value))
                else:
                    cells.append("")
            lines.append("\t".join(cells))
        QApplication.clipboard().setText("\n".join(lines))

    def paste_selection(self) -> None:
        if not self.editing_enabled:
            self.editModeRequired.emit()
            return
        start = self.currentIndex()
        if not start.isValid():
            return
        text = QApplication.clipboard().text().replace("\r\n", "\n").rstrip("\n")
        if not text:
            return
        rows = [line.split("\t") for line in text.split("\n")]
        model = self.model()
        selected = self.selectedIndexes()
        self._begin_model_edit_group()
        try:
            if len(rows) == 1 and len(rows[0]) == 1 and len(selected) > 1:
                value = rows[0][0]
                for index in selected:
                    model.setData(index, value, Qt.ItemDataRole.EditRole)
                return
            if selected:
                start = model.index(
                    min(index.row() for index in selected),
                    min(index.column() for index in selected),
                )
            for row_offset, values in enumerate(rows):
                target_row = start.row() + row_offset
                if target_row >= model.rowCount():
                    break
                for column_offset, value in enumerate(values):
                    target_column = start.column() + column_offset
                    if target_column >= model.columnCount():
                        break
                    model.setData(
                        model.index(target_row, target_column),
                        value,
                        Qt.ItemDataRole.EditRole,
                    )
        finally:
            self._end_model_edit_group()
