import os
import re
import stackprinter  # type: ignore
import subprocess
import threading

import obsws_python as obs  # type: ignore

from datetime import datetime, timedelta
from pprint import pprint
from typing import Any, Callable, cast, List, Optional, Tuple, TYPE_CHECKING

from PyQt6.QtCore import (
    QEvent,
    QModelIndex,
    QObject,
    QItemSelection,
    Qt,
    # QTimer,
)
from PyQt6.QtGui import QAction, QBrush, QColor, QFont, QDropEvent, QKeyEvent
from PyQt6.QtWidgets import (
    QAbstractItemDelegate,
    QAbstractItemView,
    QApplication,
    QHeaderView,
    QMenu,
    QMessageBox,
    QPlainTextEdit,
    QStyledItemDelegate,
    QStyleOptionViewItem,
    QTableView,
    QTableWidgetItem,
    QWidget,
    QProxyStyle,
    QStyle,
    QStyleOption,
)

from dbconfig import Session, scoped_session
from dialogs import TrackSelectDialog
from classes import MusicMusterSignals, track_sequence
from config import Config
from helpers import (
    ask_yes_no,
    file_is_unreadable,
    get_relative_date,
    ms_to_mmss,
    open_in_audacity,
    send_mail,
    set_track_metadata,
)
from log import log
from models import PlaylistRows, Settings, Tracks, NoteColours

if TYPE_CHECKING:
    from musicmuster import Window
from playlistmodel import PlaylistModel

# HEADER_NOTES_COLUMN = 2


class EscapeDelegate(QStyledItemDelegate):
    """
    - increases the height of a row when editing to make editing easier
    - closes the edit on control-return
    - checks with user before abandoning edit on Escape
    """

    def __init__(self, parent) -> None:
        super().__init__(parent)
        self.signals = MusicMusterSignals()

    def createEditor(
        self,
        parent: Optional[QWidget],
        option: QStyleOptionViewItem,
        index: QModelIndex,
    ):
        """
        Intercept createEditor call and make row just a little bit taller
        """

        self.signals = MusicMusterSignals()
        self.signals.enable_escape_signal.emit(False)
        if isinstance(self.parent(), PlaylistTab):
            p = cast(PlaylistTab, self.parent())
        if isinstance(index.data(), str):
            row = index.row()
            row_height = p.rowHeight(row)
            p.setRowHeight(row, row_height + Config.MINIMUM_ROW_HEIGHT)
            return QPlainTextEdit(parent)
        return super().createEditor(parent, option, index)

    def destroyEditor(self, editor: Optional[QWidget], index: QModelIndex) -> None:
        """
        Intercept editor destroyment
        """

        self.signals.enable_escape_signal.emit(True)
        return super().destroyEditor(editor, index)

    def eventFilter(self, editor: Optional[QObject], event: Optional[QEvent]) -> bool:
        """By default, QPlainTextEdit doesn't handle enter or return"""

        if editor is None or event is None:
            return False

        if event.type() == QEvent.Type.KeyPress:
            key_event = cast(QKeyEvent, event)
            if key_event.key() == Qt.Key.Key_Return:
                if key_event.modifiers() == (Qt.KeyboardModifier.ControlModifier):
                    self.commitData.emit(editor)
                    self.closeEditor.emit(editor)
                    return True
            elif key_event.key() == Qt.Key.Key_Escape:
                discard_edits = QMessageBox.question(
                    cast(QWidget, self.parent()), "Abandon edit", "Discard changes?"
                )
                if discard_edits == QMessageBox.StandardButton.Yes:
                    self.closeEditor.emit(editor)
                    return True
        return False

    def setEditorData(self, editor, index):
        value = index.model().data(index, Qt.ItemDataRole.EditRole)
        editor.setPlainText(value.value())

    def setModelData(self, editor, model, index):
        value = editor.toPlainText()
        model.setData(index, value, Qt.ItemDataRole.EditRole)

    def updateEditorGeometry(self, editor, option, index):
        editor.setGeometry(option.rect)


class PlaylistStyle(QProxyStyle):
    def drawPrimitive(self, element, option, painter, widget=None):
        """
        Draw a line across the entire row rather than just the column
        we're hovering over.
        """
        if (
            element == QStyle.PrimitiveElement.PE_IndicatorItemViewItemDrop
            and not option.rect.isNull()
        ):
            option_new = QStyleOption(option)
            option_new.rect.setLeft(0)
            if widget:
                option_new.rect.setRight(widget.width())
            option = option_new
        super().drawPrimitive(element, option, painter, widget)


class PlaylistTab(QTableView):
    def __init__(
        self,
        musicmuster: "Window",
        playlist_id: int,
    ) -> None:
        super().__init__()

        # Save passed settings
        self.musicmuster = musicmuster
        self.playlist_id = playlist_id

        # Set up widget
        self.setItemDelegate(EscapeDelegate(self))
        self.setAlternatingRowColors(True)
        self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
        self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
        # self.setEditTriggers(QAbstractItemView.EditTrigger.DoubleClicked)
        self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
        self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
        self.setDragDropOverwriteMode(False)
        self.setAcceptDrops(True)
        # Set our custom style - this draws the drop indicator across the whole row
        self.setStyle(PlaylistStyle())
        # We will enable dragging when rows are selected. Disabling it
        # here means we can click and drag to select rows.
        self.setDragEnabled(False)
        # Prepare for context menu
        self.menu = QMenu()
        self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.customContextMenuRequested.connect(self._context_menu)

        # Connect signals
        # This dancing is to satisfy mypy
        h_header = self.horizontalHeader()
        if isinstance(h_header, QHeaderView):
            h_header.sectionResized.connect(self._column_resize)
            h_header.setStretchLastSection(True)
        # self.signals.set_next_track_signal.connect(self._reset_next)
        self.signals = MusicMusterSignals()
        self.signals.span_cells_signal.connect(self._span_cells)

        # Call self.eventFilter() for events
        # self.installEventFilter(self)

        # Initialise miscellaneous instance variables
        self.search_text: str = ""
        self.sort_undo: List[int] = []
        # self.edit_cell_type: Optional[int]

        # Load playlist rows
        self.setModel(PlaylistModel(playlist_id))
        self._set_column_widths()

    def closeEditor(
        self, editor: QWidget | None, hint: QAbstractItemDelegate.EndEditHint
    ) -> None:
        """
        Override closeEditor to enable play controls and update display.
        """

        self.musicmuster.enable_play_next_controls()
        self.musicmuster.actionSetNext.setEnabled(True)
        self.musicmuster.action_Clear_selection.setEnabled(True)

        super(PlaylistTab, self).closeEditor(editor, hint)

        # Optimise row heights after increasing row height for editing
        self.resizeRowsToContents()

        # Update start times in case a start time in a note has been
        # edited
        model = cast(PlaylistModel, self.model())
        model.update_track_times()

    def dropEvent(self, event):
        if event.source() is not self or (
            event.dropAction() != Qt.DropAction.MoveAction
            and self.dragDropMode() != QAbstractItemView.InternalMove
        ):
            super().dropEvent(event)

        from_rows = list(set([a.row() for a in self.selectedIndexes()]))
        to_row = self.indexAt(event.position().toPoint()).row()
        if (
            0 <= min(from_rows) <= self.model().rowCount()
            and 0 <= max(from_rows) <= self.model().rowCount()
            and 0 <= to_row <= self.model().rowCount()
        ):
            self.model().move_rows(from_rows, to_row)

            # Reset drag mode to allow row selection by dragging
            self.setDragEnabled(False)
            # Deselect rows
            self.clear_selection()

            event.accept()

    def edit(
        self,
        index: QModelIndex,
        trigger: QAbstractItemView.EditTrigger,
        event: Optional[QEvent],
    ) -> bool:
        """
        Override PySide2.QAbstractItemView.edit to catch when editing starts

        Editing only ever starts with a double click on a cell
        """

        # 'result' will only be true on double-click
        result = super().edit(index, trigger, event)
        if result:
            self.musicmuster.disable_play_next_controls()

        return result

    def _add_context_menu(
        self,
        text: str,
        action: Callable,
        disabled: bool = False,
        parent_menu: Optional[QMenu] = None,
    ) -> Optional[QAction]:
        """
        Add item to self.menu
        """

        if parent_menu is None:
            parent_menu = self.menu

        menu_item = parent_menu.addAction(text)
        if not menu_item:
            return None
        menu_item.setDisabled(disabled)
        menu_item.triggered.connect(action)

        return menu_item

    def mouseReleaseEvent(self, event):
        """
        Enable dragging if rows are selected
        """

        if self.selectedIndexes():
            self.setDragEnabled(True)
        else:
            self.setDragEnabled(False)
            self.reset()
        super().mouseReleaseEvent(event)

    #     # ########## Externally called functions ##########

    def clear_selection(self) -> None:
        """Unselect all tracks and reset drag mode"""

        self.clearSelection()
        self.setDragEnabled(False)

    def get_selected_row_number(self) -> Optional[int]:
        """
        Return the selected row number or None if none selected.
        """

        sm = self.selectionModel()
        if sm and sm.hasSelection():
            index = sm.currentIndex()
            if index.isValid():
                return index.row()
        return None

    def get_selected_row_track_path(self) -> str:
        """
        Return the path of the selected row. If no row selected or selected
        row does not have a track, return empty string.
        """

        sm = self.selectionModel()
        if sm and sm.hasSelection():
            index = sm.currentIndex()
            if index.isValid():
                model = cast(PlaylistModel, self.model())
                return model.get_row_track_path(index.row())
        return ""

    # def lookup_row_in_songfacts(self) -> None:
    #     """
    #     If there is a selected row and it is a track row,
    #     look up its title in songfacts.

    #     If multiple rows are selected, only consider the first one.

    #     Otherwise return.
    #     """

    #     self._look_up_row(website="songfacts")

    # def lookup_row_in_wikipedia(self) -> None:
    #     """
    #     If there is a selected row and it is a track row,
    #     look up its title in wikipedia.

    #     If multiple rows are selected, only consider the first one.

    #     Otherwise return.
    #     """

    #     self._look_up_row(website="wikipedia")

    # def scroll_current_to_top(self) -> None:
    #     """Scroll currently-playing row to top"""

    #     current_row = self._get_current_track_row_number()
    #     if current_row is not None:
    #         self._scroll_to_top(current_row)

    # def scroll_next_to_top(self) -> None:
    #     """Scroll nextly-playing row to top"""

    #     next_row = self._get_next_track_row_number()
    #     if next_row is not None:
    #         self._scroll_to_top(next_row)

    def set_search(self, text: str) -> None:
        """Set search text and find first match"""

        self.search_text = text
        if not text:
            # Search string has been reset
            return
        self._search(next=True)

    # def search_next(self) -> None:
    #     """
    #     Select next row containg self.search_string.
    #     """

    #     self._search(next=True)

    # def search_previous(self) -> None:
    #     """
    #     Select previous row containg self.search_string.
    #     """

    #     self._search(next=False)

    # def select_next_row(self) -> None:
    #     """
    #     Select next or first row. Don't select section headers.

    #     Wrap at last row.
    #     """

    #     selected_rows = self._get_selected_rows()
    #     # we will only handle zero or one selected rows
    #     if len(selected_rows) > 1:
    #         return
    #     # select first row if none selected
    #     if len(selected_rows) == 0:
    #         row_number = 0
    #     else:
    #         row_number = selected_rows[0] + 1
    #         if row_number >= self.rowCount():
    #             row_number = 0

    #     # Don't select section headers
    #     wrapped = False
    #     track_id = self._get_row_track_id(row_number)
    #     while not track_id:
    #         row_number += 1
    #         if row_number >= self.rowCount():
    #             if wrapped:
    #                 # we're already wrapped once, so there are no
    #                 # non-headers
    #                 return
    #             row_number = 0
    #             wrapped = True
    #         track_id = self._get_row_track_id(row_number)

    #     self.selectRow(row_number)

    # def select_previous_row(self) -> None:
    #     """
    #     Select previous or last track. Don't select section headers.
    #     Wrap at first row.
    #     """

    #     selected_rows = self._get_selected_rows()
    #     # we will only handle zero or one selected rows
    #     if len(selected_rows) > 1:
    #         return
    #     # select last row if none selected
    #     last_row = self.rowCount() - 1
    #     if len(selected_rows) == 0:
    #         row_number = last_row
    #     else:
    #         row_number = selected_rows[0] - 1
    #         if row_number < 0:
    #             row_number = last_row

    #     # Don't select section headers
    #     wrapped = False
    #     track_id = self._get_row_track_id(row_number)
    #     while not track_id:
    #         row_number -= 1
    #         if row_number < 0:
    #             if wrapped:
    #                 # we're already wrapped once, so there are no
    #                 # non-notes
    #                 return
    #             row_number = last_row
    #             wrapped = True
    #         track_id = self._get_row_track_id(row_number)

    #     self.selectRow(row_number)

    def set_row_as_next_track(self) -> None:
        """
        Set selected row as next track
        """

        selected_row = self.get_selected_row_number()
        if selected_row is None:
            return
        model = cast(PlaylistModel, self.model())
        model.set_next_row(selected_row)
        self.clearSelection()

    # #     # ########## Internally called functions ##########

    def _add_track(self, row_number: int) -> None:
        """Add a track to a section header making it a normal track row"""

        with Session() as session:
            dlg = TrackSelectDialog(
                session=session,
                new_row_number=row_number,
                playlist_id=self.playlist_id,
                add_to_header=True,
            )
            dlg.exec()

    def _build_context_menu(self, item: QTableWidgetItem) -> None:
        """Used to process context (right-click) menu, which is defined here"""

        self.menu.clear()
        model = cast(PlaylistModel, self.model())
        if not model:
            return

        row_number = item.row()
        header_row = model.is_header_row(row_number)
        track_row = not header_row
        current_row = row_number == track_sequence.now.plr_rownum
        next_row = row_number == track_sequence.next.plr_rownum

        # Open in Audacity
        if track_row and not current_row:
            self._add_context_menu(
                "Open in Audacity", lambda: model.open_in_audacity(row_number)
            )

        # Rescan
        if track_row and not current_row:
            self._add_context_menu("Rescan track", lambda: self._rescan(row_number))

        # ----------------------
        self.menu.addSeparator()

        # Delete row
        if not current_row and not next_row:
            self._add_context_menu("Delete row", lambda: self._delete_rows())

        # Remove track from row
        if track_row and not current_row and not next_row:
            self._add_context_menu(
                "Remove track from row", lambda: model.remove_track(row_number)
            )

        # Add track to section header (ie, make this a track row)
        # TODO
        if header_row:
            self._add_context_menu("Add a track", lambda: print("Add a track"))

        #     # ----------------------
        self.menu.addSeparator()

        # Mark unplayed
        if track_row and model.is_unplayed_row(row_number):
            self._add_context_menu(
                "Mark unplayed", lambda: self._mark_as_unplayed(self.get_selected_rows())
            )

        # Unmark as next
        if next_row:
            self._add_context_menu(
                "Unmark as next track", lambda: self._unmark_as_next()
            )

        # ----------------------
        self.menu.addSeparator()

        # Sort
        sort_menu = self.menu.addMenu("Sort")
        self._add_context_menu(
            "by title",
            lambda: model.sort_by_title(self.get_selected_rows()),
            parent_menu=sort_menu,
        )
        self._add_context_menu(
            "by artist",
            lambda: model.sort_by_artist(self.get_selected_rows()),
            parent_menu=sort_menu,
        )
        self._add_context_menu(
            "by duration",
            lambda: model.sort_by_duration(self.get_selected_rows()),
            parent_menu=sort_menu,
        )
        self._add_context_menu(
            "by last played",
            lambda: model.sort_by_lastplayed(self.get_selected_rows()),
            parent_menu=sort_menu,
        )

        # Info
        if track_row:
            self._add_context_menu("Info", lambda: self._info_row(row_number))

        # Track path TODO
        if track_row:
            self._add_context_menu("Copy track path", lambda: self._copy_path(row_number))

    def _calculate_end_time(
        self, start: Optional[datetime], duration: int
    ) -> Optional[datetime]:
        """Return datetime 'duration' ms after 'start'"""

        if start is None:
            return None

        return start + timedelta(milliseconds=duration)

    def _column_resize(self, column_number: int, _old: int, _new: int) -> None:
        """
        Called when column width changes. Save new width to database.
        """

        header = self.horizontalHeader()
        if not header:
            return

        # Resize rows if necessary
        self.resizeRowsToContents()

        with Session() as session:
            attr_name = f"playlist_col_{column_number}_width"
            record = Settings.get_int_settings(session, attr_name)
            record.f_int = self.columnWidth(column_number)

    def _context_menu(self, pos):
        """Display right-click menu"""

        item = self.indexAt(pos)
        self._build_context_menu(item)
        self.menu.exec(self.mapToGlobal(pos))

    def _copy_path(self, row_number: int) -> None:
        """
        If passed row_number has a track, copy the track path, single-quoted,
        to the clipboard. Otherwise, return None.
        """

        model = cast(PlaylistModel, self.model())
        track_path = model.get_row_info(row_number).path
        if not track_path:
            return

        replacements = [
            ("'", "\\'"),
            (" ", "\\ "),
            ("(", "\\("),
            (")", "\\)"),
        ]
        for old, new in replacements:
            track_path = track_path.replace(old, new)

        cb = QApplication.clipboard()
        if cb:
            cb.clear(mode=cb.Mode.Clipboard)
            cb.setText(track_path, mode=cb.Mode.Clipboard)

    def _delete_rows(self) -> None:
        """
        Delete mutliple rows

        Actions required:
        - Confirm deletion should go ahead
        - Pass to model to do the deed
        """

        rows_to_delete = self.get_selected_rows()
        row_count = len(rows_to_delete)
        if row_count < 1:
            return

        # Get confirmation
        plural = "s" if row_count > 1 else ""
        if not ask_yes_no("Delete rows", f"Really delete {row_count} row{plural}?"):
            return

        model = cast(PlaylistModel, self.model())
        model.delete_rows(self.get_selected_rows())

    def get_selected_rows(self) -> List[int]:
        """Return a list of selected row numbers sorted by row"""

        # Use a set to deduplicate result (a selected row will have all
        # items in that row selected)
        return sorted(list(set([a.row() for a in self.selectedIndexes()])))

    def _info_row(self, row_number: int) -> None:
        """Display popup with info re row"""

        model = cast(PlaylistModel, self.model())
        prd = model.get_row_info(row_number)
        if prd:
            txt = (
                f"Title: {prd.title}\n"
                f"Artist: {prd.artist}\n"
                f"Track ID: {prd.track_id}\n"
                f"Track duration: {ms_to_mmss(prd.duration)}\n"
                f"Track bitrate: {prd.bitrate}\n"
                "\n\n"
                f"Path: {prd.path}\n"
            )
        else:
            txt = f"Can't find info about row{row_number}"

        info: QMessageBox = QMessageBox(self)
        info.setIcon(QMessageBox.Icon.Information)
        info.setText(txt)
        info.setStandardButtons(QMessageBox.StandardButton.Ok)
        info.setDefaultButton(QMessageBox.StandardButton.Cancel)
        info.exec()

    def _look_up_row(self, website: str) -> None:
        """
        If there is a selected row and it is a track row,
        look up its title in the passed website

        If multiple rows are selected, only consider the first one.

        Otherwise return.
        """

        print("playlists_v3:_look_up_row()")
        return
        # selected_row = self._get_selected_row()
        # if not selected_row:
        #     return

        # if not self._get_row_track_id(selected_row):
        #     return

        # title = self._get_row_title(selected_row)

        # if website == "wikipedia":
        #     QTimer.singleShot(
        #         0, lambda: self.musicmuster.tabInfolist.open_in_wikipedia(title)
        #     )
        # elif website == "songfacts":
        #     QTimer.singleShot(
        #         0, lambda: self.musicmuster.tabInfolist.open_in_songfacts(title)
        #     )
        # else:
        #     return

    def _mark_as_unplayed(self, row_numbers: List[int]) -> None:
        """Rescan track"""

        model = cast(PlaylistModel, self.model())
        model.mark_unplayed(row_numbers)
        self.clear_selection()

    def _obs_change_scene(self, current_row: int) -> None:
        """
        Try to change OBS scene to the name passed
        """

        check_row = current_row
        while True:
            # If we have a note and it has a scene change command,
            # execute it
            note_text = self._get_row_note(check_row)
            if note_text:
                match_obj = scene_change_re.search(note_text)
                if match_obj:
                    scene_name = match_obj.group(1)
                    if scene_name:
                        try:
                            cl = obs.ReqClient(
                                host=Config.OBS_HOST,
                                port=Config.OBS_PORT,
                                password=Config.OBS_PASSWORD,
                            )
                        except ConnectionRefusedError:
                            log.error("OBS connection refused")
                            return

                        try:
                            cl.set_current_program_scene(scene_name)
                            log.info(f"OBS scene changed to '{scene_name}'")
                            return
                        except obs.error.OBSSDKError as e:
                            log.error(f"OBS SDK error ({e})")
                            return
            # After current track row, only check header rows and stop
            # at first non-header row
            check_row -= 1
            if check_row < 0:
                break
            if self._get_row_track_id(check_row):
                break

    def _rescan(self, row_number: int) -> None:
        """Rescan track"""

        model = cast(PlaylistModel, self.model())
        model.rescan_track(row_number)
        self.clear_selection()

    # def _reset_next(self, old_plrid: int, new_plrid: int) -> None:
    #     """
    #     Called when set_next_track_signal signal received.

    #     Actions required:
    #     - If old_plrid points to this playlist:
    #         - Remove existing next track
    #     - If new_plrid points to this playlist:
    #         - Set track as next
    #         - Display row as next track
    #         - Update start/stop times
    #     """

    #     with Session() as session:
    #         # Get plrs
    #         old_plr = new_plr = None
    #         if old_plrid:
    #             old_plr = session.get(PlaylistRows, old_plrid)

    #         # Unmark next track
    #         if old_plr and old_plr.playlist_id == self.playlist_id:
    #             self._set_row_colour_default(old_plr.plr_rownum)

    #         # Mark next track
    #         if new_plrid:
    #             new_plr = session.get(PlaylistRows, new_plrid)
    #             if not new_plr:
    #                 log.error(f"_reset_next({new_plrid=}): plr not found")
    #                 return
    #             if new_plr.playlist_id == self.playlist_id:
    #                 self._set_row_colour_next(new_plr.plr_rownum)

    #         # Update start/stop times
    #         self._update_start_end_times(session)

    #     self.clear_selection()

    def _run_subprocess(self, args):
        """Run args in subprocess"""

        subprocess.call(args)

    def _scroll_to_top(self, row_number: int) -> None:
        """
        Scroll to put passed row_number Config.SCROLL_TOP_MARGIN from the
        top.
        """

        if row_number is None:
            return

        padding_required = Config.SCROLL_TOP_MARGIN
        top_row = row_number

        if row_number > Config.SCROLL_TOP_MARGIN:
            # We can't scroll to a hidden row. Calculate target_row as
            # the one that is ideal to be at the top. Then count upwards
            # from passed row_number until we either reach the target,
            # pass it or reach row_number 0.
            for i in range(row_number - 1, -1, -1):
                if self.isRowHidden(i):
                    continue
                if padding_required == 0:
                    break
                top_row = i
                padding_required -= 1

        scroll_item = self.item(top_row, 0)
        self.scrollToItem(scroll_item, QAbstractItemView.ScrollHint.PositionAtTop)

    # def _search(self, next: bool = True) -> None:
    #     """
    #     Select next/previous row containg self.search_string. Start from
    #     top selected row if there is one, else from top.

    #     Wrap at last/first row.
    #     """

    #     if not self.search_text:
    #         return

    #     selected_row = self._get_selected_row()
    #     if next:
    #         if selected_row is not None and selected_row < self.rowCount() - 1:
    #             starting_row = selected_row + 1
    #         else:
    #             starting_row = 0
    #     else:
    #         if selected_row is not None and selected_row > 0:
    #             starting_row = selected_row - 1
    #         else:
    #             starting_row = self.rowCount() - 1

    #     wrapped = False
    #     match_row = None
    #     row_number = starting_row
    #     needle = self.search_text.lower()
    #     while True:
    #         # Check for match in title, artist or notes
    #         title = self._get_row_title(row_number)
    #         if title and needle in title.lower():
    #             match_row = row_number
    #             break
    #         artist = self._get_row_artist(row_number)
    #         if artist and needle in artist.lower():
    #             match_row = row_number
    #             break
    #         note = self._get_row_note(row_number)
    #         if note and needle in note.lower():
    #             match_row = row_number
    #             break
    #         if next:
    #             row_number += 1
    #             if wrapped and row_number >= starting_row:
    #                 break
    #             if row_number >= self.rowCount():
    #                 row_number = 0
    #                 wrapped = True
    #         else:
    #             row_number -= 1
    #             if wrapped and row_number <= starting_row:
    #                 break
    #             if row_number < 0:
    #                 row_number = self.rowCount() - 1
    #                 wrapped = True

    #     if match_row is not None:
    #         self.selectRow(row_number)

    def select_duplicate_rows(self) -> None:
        """
        Select the last of any rows with duplicate tracks in current playlist.
        This allows the selection to typically come towards the end of the playlist away
        from any show specific sections.
        """

        # Clear any selected rows to avoid confustion
        self.clear_selection()
        # We need to be in MultiSelection mode
        self.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection)
        # Get the duplicate rows
        model = cast(PlaylistModel, self.model())
        duplicate_rows = model.get_duplicate_rows()
        # Select the rows
        for duplicate_row in duplicate_rows:
            self.selectRow(duplicate_row)
        # Reset selection mode
        self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)

    def selectionChanged(
        self, selected: QItemSelection, deselected: QItemSelection
    ) -> None:
        """
        Toggle drag behaviour according to whether rows are selected
        """

        selected_rows = self.get_selected_rows()
        # If no rows are selected, we have nothing to do
        if len(selected_rows) == 0:
            self.musicmuster.lblSumPlaytime.setText("")
        else:
            model = cast(PlaylistModel, self.model())
            selected_duration = model.get_rows_duration(self.get_selected_rows())
            if selected_duration > 0:
                self.musicmuster.lblSumPlaytime.setText(
                    f"Selected duration: {ms_to_mmss(selected_duration)}"
                )
            else:
                self.musicmuster.lblSumPlaytime.setText("")

        super().selectionChanged(selected, deselected)

    def _set_column_widths(self) -> None:
        """Column widths from settings"""

        header = self.horizontalHeader()
        if not header:
            return

        # Set width of last column to zero as it's set to stretch
        self.setColumnWidth(header.count() - 1, 0)

        # Set remaining column widths from settings
        with Session() as session:
            for column_number in range(header.count() - 1):
                attr_name = f"playlist_col_{column_number}_width"
                record = Settings.get_int_settings(session, attr_name)
                if record.f_int is not None:
                    self.setColumnWidth(column_number, record.f_int)
                else:
                    self.setColumnWidth(column_number, Config.DEFAULT_COLUMN_WIDTH)

    # def _set_row_note_colour(self, session: scoped_session, row_number: int) -> None:
    #     """
    #     Set row note colour
    #     """

    #     # Sanity check: this should be a track row and thus have a
    #     # track associated
    #     if not self._get_row_track_id(row_number):
    #         if os.environ["MM_ENV"] == "PRODUCTION":
    #             send_mail(
    #                 Config.ERRORS_TO,
    #                 Config.ERRORS_FROM,
    #                 "playlists:_set_row_note_colour() on header row",
    #                 stackprinter.format(),
    #             )
    #         # stackprinter.show(add_summary=True, style="darkbg")
    #         print(f"playists:_set_row_note_colour() called on track row ({row_number=}")
    #         return

    #     # Set colour
    #     note_text = self._get_row_note(row_number)
    #     note_colour = NoteColours.get_colour(session, note_text)
    #     self._set_cell_colour(row_number, ROW_NOTES, note_colour)

    def _span_cells(self, row: int, column: int, rowSpan: int, columnSpan: int) -> None:
        """
        Implement spanning of cells, initiated by signal
        """

        # Don't set spanning if already in place because that is seen as
        # a change to the view and thus it refreshes the data which
        # again calls us here.
        if (
            self.rowSpan(row, column) == rowSpan
            and self.columnSpan(row, column) == columnSpan
        ):
            return

        self.setSpan(row, column, rowSpan, columnSpan)

    def _unmark_as_next(self) -> None:
        """Rescan track"""

        model = cast(PlaylistModel, self.model())
        model.set_next_row(None)
        self.clear_selection()
