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

from collections import namedtuple
from datetime import datetime, timedelta
from typing import cast, List, Optional, TYPE_CHECKING

from PyQt5.QtCore import (
    pyqtSignal,
    QEvent,
    QModelIndex,
    QObject,
    QSize,
    Qt,
    QTimer,
)
from PyQt5.QtGui import (
    QBrush,
    QColor,
    QFont,
    QDropEvent,
    QKeyEvent
)
from PyQt5.QtWidgets import (
    QAbstractItemDelegate,
    QAbstractItemView,
    QApplication,
    QLineEdit,
    QMainWindow,
    QMenu,
    QMessageBox,
    QPlainTextEdit,
    QStyledItemDelegate,
    QTableWidget,
    QTableWidgetItem,
    QWidget
)

from config import Config
from dbconfig import Session, scoped_session
from helpers import (
    ask_yes_no,
    file_is_readable,
    get_relative_date,
    ms_to_mmss,
    open_in_audacity,
    send_mail,
    set_track_metadata,
)
from log import log
from models import (
    Playdates,
    Playlists,
    PlaylistRows,
    Settings,
    Tracks,
    NoteColours
)

if TYPE_CHECKING:
    from musicmuster import Window

start_time_re = re.compile(r"@\d\d:\d\d:\d\d")
HEADER_NOTES_COLUMN = 2
MINIMUM_ROW_HEIGHT = 30

# Columns
Column = namedtuple("Column", ['idx', 'heading'])
columns = {}
columns["userdata"] = Column(idx=0, heading=Config.COLUMN_NAME_AUTOPLAY)
columns["start_gap"] = Column(idx=1,
                              heading=Config.COLUMN_NAME_LEADING_SILENCE)
columns["title"] = Column(idx=2, heading=Config.COLUMN_NAME_TITLE)
columns["artist"] = Column(idx=3, heading=Config.COLUMN_NAME_ARTIST)
columns["duration"] = Column(idx=4, heading=Config.COLUMN_NAME_LENGTH)
columns["start_time"] = Column(idx=5, heading=Config.COLUMN_NAME_START_TIME)
columns["end_time"] = Column(idx=6, heading=Config.COLUMN_NAME_END_TIME)
columns["lastplayed"] = Column(idx=7, heading=Config.COLUMN_NAME_LAST_PLAYED)
columns["bitrate"] = Column(idx=8, heading=Config.COLUMN_NAME_BITRATE)
columns["row_notes"] = Column(idx=9, heading=Config.COLUMN_NAME_NOTES)

USERDATA = columns["userdata"].idx
START_GAP = columns["start_gap"].idx
TITLE = columns["title"].idx
ARTIST = columns["artist"].idx
DURATION = columns["duration"].idx
START_TIME = columns["start_time"].idx
END_TIME = columns["end_time"].idx
LASTPLAYED = columns["lastplayed"].idx
BITRATE = columns["bitrate"].idx
ROW_NOTES = columns["row_notes"].idx


class NoSelectDelegate(QStyledItemDelegate):
    """
    This originally used the following link to not select text on edit;
    however, using a QPlainTextBox means a) text isn't selected anyway and
    b) it provides a multiline edit.

    https://stackoverflow.com/questions/72790705/
    dont-select-text-in-qtablewidget-cell-when-editing/72792962#72792962
    """

    def createEditor(self, parent, option, index):
        if isinstance(index.data(), str):
            # Make row just a little bit taller
            row = index.row()
            row_height = self.parent().rowHeight(row)
            self.parent().setRowHeight(row, row_height + MINIMUM_ROW_HEIGHT)
            return QPlainTextEdit(parent)
        return super().createEditor(parent, option, index)

    def eventFilter(self, editor: QObject, event: QEvent):
        """By default, QPlainTextEdit doesn't handle enter or return"""

        if event.type() == QEvent.KeyPress:
            key_event = cast(QKeyEvent, event)
            if key_event.key() == Qt.Key_Return:
                if key_event.modifiers() == Qt.ControlModifier:
                    self.commitData.emit(editor)
                    self.closeEditor.emit(editor)

        return super().eventFilter(editor, event)


class PlaylistTab(QTableWidget):
    # Qt.UserRoles
    ROW_FLAGS = Qt.UserRole
    ROW_TRACK_ID = Qt.UserRole + 1
    ROW_DURATION = Qt.UserRole + 2
    PLAYLISTROW_ID = Qt.UserRole + 3

    def __init__(self, musicmuster: "Window",
                 session: scoped_session,
                 playlist_id: int, *args, **kwargs) -> None:
        super().__init__(*args, **kwargs)
        self.musicmuster: Window = musicmuster
        self.playlist_id = playlist_id

        self.menu: Optional[QMenu] = None

        # Don't select text on edit
        self.setItemDelegate(NoSelectDelegate(self))

        # Set up widget
        self.setEditTriggers(QAbstractItemView.DoubleClicked)
        self.setAlternatingRowColors(True)
        self.setSelectionMode(QAbstractItemView.ExtendedSelection)
        self.setSelectionBehavior(QAbstractItemView.SelectRows)
        self.setVerticalScrollMode(QAbstractItemView.ScrollPerPixel)
        self.setRowCount(0)
        self.setColumnCount(len(columns))
        self.v_header = self.verticalHeader()
        self.v_header.setMinimumSectionSize(MINIMUM_ROW_HEIGHT)
        self.horizontalHeader().setStretchLastSection(True)

        # Header row
        for idx in [a for a in range(len(columns))]:
            item = QTableWidgetItem()
            self.setHorizontalHeaderItem(idx, item)
        self.horizontalHeader().setMinimumSectionSize(0)
        # Set column headings sorted by idx
        self.setHorizontalHeaderLabels(
            [a.heading for a in list(sorted(columns.values(),
                                            key=lambda item: item.idx))]
        )
        self.horizontalHeader().sectionResized.connect(
            self.resizeRowsToContents)

        self.setAcceptDrops(True)
        self.viewport().setAcceptDrops(True)
        self.setDragDropOverwriteMode(False)
        self.setDropIndicatorShown(True)
        self.setDragDropMode(QAbstractItemView.InternalMove)
        self.setDragEnabled(False)

        # This property defines how the widget shows a context menu
        self.setContextMenuPolicy(Qt.CustomContextMenu)
        # This signal is emitted when the widget's contextMenuPolicy is
        # Qt::CustomContextMenu, and the user has requested a context
        # menu on the widget.
        self.customContextMenuRequested.connect(self._context_menu)
        self.viewport().installEventFilter(self)

        self.itemSelectionChanged.connect(self._select_event)

        self.search_text: str = ""
        self.edit_cell_type: Optional[int]
        self.selecting_in_progress = False
        # Connect signals
        self.horizontalHeader().sectionResized.connect(self._column_resize)
        # self.horizontalHeader().sectionClicked.connect(self._header_click)
        # self.setSortingEnabled(True)

        # Now load our tracks and notes
        self.populate_display(session, self.playlist_id)

    def __repr__(self) -> str:
        return f"<PlaylistTab(id={self.playlist_id}>"

# ########## Events other than cell editing ##########

    def dropEvent(self, event: QDropEvent) -> None:
        """
        Handle drag/drop of rows

        https://stackoverflow.com/questions/26227885/drag-and-drop-rows-within-qtablewidget
        """

        if not event.source() == self:
            return  # We don't accept external drops

        drop_row: int = self._drop_on(event)

        rows: List = sorted(set(item.row() for item in self.selectedItems()))
        rows_to_move = [
            [QTableWidgetItem(
                self.item(row_index, column_index)    # type: ignore
            ) for
             column_index in range(self.columnCount())]
            for row_index in rows
        ]
        for row_index in reversed(rows):
            self.removeRow(row_index)
            if row_index < drop_row:
                drop_row -= 1

        for row_index, data in enumerate(rows_to_move):
            row_index += drop_row
            self.insertRow(row_index)
            for column_index, column_data in enumerate(data):
                self.setItem(row_index, column_index, column_data)
        event.accept()
        # The above doesn't handle column spans, which we use in note
        # rows. Check and fix:
        for row in range(drop_row, drop_row + len(rows_to_move)):
            if not self._get_row_track_id(row):
                self.setSpan(row, HEADER_NOTES_COLUMN, 1, len(columns))

        # Scroll to drop zone
        self.scrollToItem(self.item(row, 1))

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

        super().dropEvent(event)

        log.debug(
            "playlist.dropEvent(): "
            f"Moved row(s) {rows} to become row {drop_row}"
        )

        with Session() as session:       # checked
            self.save_playlist(session)
            self.update_display(session)

    def eventFilter(self, source, event):
        """Used to process context (right-click) menu, which is defined here"""

        if (event.type() == QEvent.MouseButtonPress and  # noqa W504
                event.buttons() == Qt.RightButton and  # noqa W504
                source is self.viewport()):
            self.menu = QMenu(self)
            item = self.itemAt(event.pos())
            if item is not None:
                row_number = item.row()
                track_id = self._get_row_track_id(row_number)
                track_row = track_id > 0
                header_row = not track_row
                if track_row:
                    current = (
                        row_number == self._get_current_track_row_number()
                    )
                    next_row = row_number == self._get_next_track_row_number()
                else:
                    current = next_row = False

                # Cut/paste
                act_cut = self.menu.addAction(
                    "Mark for moving")
                act_cut.triggered.connect(
                    lambda: self.musicmuster.cut_rows())

                act_paste = self.menu.addAction(
                    "Paste")
                act_paste.setDisabled(
                    self.musicmuster.selected_plrs is None)
                act_paste.triggered.connect(
                    lambda: self.musicmuster.paste_rows())

                self.menu.addSeparator()

                if track_row:
                    # Info
                    act_info = self.menu.addAction('Info')
                    act_info.triggered.connect(
                        lambda: self._info_row(track_id)
                    )
                    act_copypath = self.menu.addAction("Copy track path")
                    act_copypath.triggered.connect(
                        lambda: self._copy_path(row_number))

                    self.menu.addSeparator()

                    # Play with mplayer
                    act_mplayer = self.menu.addAction(
                        "Play with mplayer")
                    act_mplayer.triggered.connect(
                        lambda: self._mplayer_play(track_id))

                    # Set next
                    if not current and not next_row:
                        act_setnext = self.menu.addAction("Set next")
                        with Session() as session:
                            act_setnext.triggered.connect(
                                lambda: self._set_next(session, row_number))

                    if not current:
                        # Open in Audacity
                        act_audacity = self.menu.addAction(
                            "Open in Audacity")
                        act_audacity.triggered.connect(
                            lambda: self._open_in_audacity(track_id))

                        # Rescan
                        act_rescan = self.menu.addAction("Rescan")
                        act_rescan.triggered.connect(
                            lambda: self._rescan(row_number, track_id)
                        )

                        self.menu.addSeparator()

                        # Look up in wikipedia
                        act_wikip = self.menu.addAction("Wikipedia")
                        act_wikip.triggered.connect(
                            lambda: self._wikipedia(row_number)
                        )

                        # Look up in songfacts
                        act_songfacts = self.menu.addAction("Songfacts")
                        act_songfacts.triggered.connect(
                            lambda: self._songfacts(row_number)
                        )

                        self.menu.addSeparator()

                        # Remove track
                        act_remove_track = self.menu.addAction('Remove track')
                        act_remove_track.triggered.connect(
                            lambda: self._remove_track(row_number)
                        )
                if header_row:
                    # Add track to section header (ie, make this a track
                    # row)
                    act_add_track = self.menu.addAction('Add track')
                    act_add_track.triggered.connect(
                        lambda: self._add_track(row_number))

                if not current and not next_row:
                    # Remove row
                    act_delete = self.menu.addAction('Remove row')
                    act_delete.triggered.connect(self._delete_rows)

                self.menu.addSeparator()

                if not current and not next_row:
                    act_move = self.menu.addAction('Move to playlist...')
                    act_move.triggered.connect(self.musicmuster.move_selected)
                    self.menu.addSeparator()

        return super(PlaylistTab, self).eventFilter(source, event)

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

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

    # ########## Cell editing ##########
    #
    # We only want to allow cell editing on tracks, artists and notes,
    # although notes may be section headers.
    #
    # Once editing starts, we need to disable play controls so that a
    # 'return' doesn't play the next track.
    #
    # Earlier in this file:
    #    - self.setEditTriggers(QAbstractItemView.DoubleClicked) - triggers
    #    editing on double-click
    #    - self.setItemDelegate(NoSelectDelegate(self)) and associated class
    #    ensure that the text is not selected when editing starts
    #
    # Call sequences:
    #   Start editing:
    #       edit()
    #       _cell_edit_started()
    #   End editing:
    #       _cell_changed()  (only if changes made)
    #       closeEditor()
    #       _cell_edit_ended()

    def _cell_changed(self, row: int, column: int) -> None:
        """Called when cell content has changed"""

        # Disable cell changed signal connection as note updates will
        # change cell again (metadata)
        self.cellChanged.disconnect(self._cell_changed)

        cell = self.item(row, column)
        if not cell:
            return

        new_text = cell.text().strip()

        # Update cell with strip()'d text
        cell.setText(new_text)

        track_id = self._get_row_track_id(row)

        # Determin cell type changed
        with Session() as session:
            # Get playlistrow object
            plr_id = self._get_playlistrow_id(row)
            plr_item = session.get(PlaylistRows, plr_id)
            if not plr_item:
                return

            # Note any updates needed to PlaylistTrack objects
            update_current = self.musicmuster.current_track.plr_id == plr_id
            update_next = self.musicmuster.next_track.plr_id == plr_id

            if self.edit_cell_type == ROW_NOTES:
                plr_item.note = new_text
                # Set/clear row start time accordingly
                start_time = self._get_note_text_time(new_text)
                if start_time:
                    self._set_row_start_time(row, start_time)
                else:
                    self._set_row_start_time(row, None)
            else:
                track = None
                if track_id:
                    track = session.get(Tracks, track_id)
                    if track:
                        if self.edit_cell_type == TITLE:
                            track.title = new_text
                            if update_current:
                                self.musicmuster.current_track.title = new_text
                            if update_next:
                                self.musicmuster.next_track.title = new_text
                        elif self.edit_cell_type == ARTIST:
                            track.artist = new_text
                            if update_current:
                                self.musicmuster.current_track.artist = \
                                    new_text
                            if update_next:
                                self.musicmuster.next_track.artist = new_text

                        if update_next or update_current:
                            self.musicmuster.update_headers()

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

        # update_display to update start times, such as when a note has
        # been edited
        with Session() as session:
            self.update_display(session)

        self.edit_cell_type = None
        self.musicmuster.enable_play_next_controls()
        self.musicmuster.actionSetNext.setEnabled(True)

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

    def edit(self, index: QModelIndex,  # type: ignore # FIXME
             trigger: QAbstractItemView.EditTrigger,
             event: QEvent) -> bool:
        """
        Override PySide2.QAbstractItemView.edit to catch when editing starts
        """

        result = super(PlaylistTab, self).edit(index, trigger, event)
        if result:      # will only be true on double-clicke
            row = index.row()
            column = index.column()

            # Is this a track row?
            track_row = self._get_row_track_id(row)

            note_column = 0
            if track_row:
                # If a track row, we only allow editing of title, artist and
                # note. Check that this column is one of those.
                if column in [TITLE, ARTIST, ROW_NOTES]:
                    self.edit_cell_type = column
                else:
                    # Can't edit other columns
                    return False

                # Check whether we're editing a notes row for later
                if self.edit_cell_type == ROW_NOTES:
                    note_column = ROW_NOTES
            else:
                # This is a section header.
                if column != HEADER_NOTES_COLUMN:
                    return False
                note_column = HEADER_NOTES_COLUMN
                self.edit_cell_type = ROW_NOTES

            # Disable play controls so that keyboard input doesn't
            # disturb playing
            self.musicmuster.disable_play_next_controls()
            self.musicmuster.actionSetNext.setEnabled(False)

            # If this is a note cell, we need to remove any existing section
            # timing so user can't edit that. Keep it simple: refresh text
            # from database. Note column will only be non-zero if we are
            # editing a note.
            if note_column:
                with Session() as session:
                    plr_id = self._get_playlistrow_id(row)
                    plr_item = session.get(PlaylistRows, plr_id)
                    item = self.item(row, note_column)
                    if not plr_item or not plr_item.note or not item:
                        return False
                    item.setText(plr_item.note)

            # Connect signal so we know when cell has changed.
            self.cellChanged.connect(self._cell_changed)

        return result

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

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

        self.clearSelection()
        self.setDragEnabled(False)

    def get_new_row_number(self) -> int:
        """
        Return the selected row or the row count if no row selected
        (ie, new row will be appended)
        """

        if self.selectionModel().hasSelection():
            return self.currentRow()
        else:
            return self.rowCount()

    def get_selected_playlistrow_ids(self) -> Optional[List]:
        """
        Return a list of PlaylistRow ids of the selected rows
        """

        return [self._get_playlistrow_id(a) for a in self._get_selected_rows()]

    def get_selected_playlistrows(self,
                                  session: scoped_session) -> Optional[List]:
        """
        Return a list of PlaylistRows of the selected rows
        """

        plr_ids = self.get_selected_playlistrow_ids()
        if not plr_ids:
            return None
        return [session.get(PlaylistRows, a) for a in plr_ids]

    def insert_header(self, session: scoped_session, note: str,
                      repaint: bool = True) -> None:
        """
        Insert section header into playlist tab.

        If a row is selected, add header above. Otherwise, add to end of
        playlist.

        We simply build a PlaylistRows object and pass it to insert_row()
        to do the heavy lifing.
        """

        row_number = self.get_new_row_number()
        plr = PlaylistRows(session, self.playlist_id, None, row_number, note)
        self.insert_row(session, plr, repaint)
        self.save_playlist(session)

    def insert_row(self, session: scoped_session, plr: PlaylistRows,
                   repaint: bool = True) -> None:
        """
        Insert passed playlist row (plr) into playlist tab.
        """

        if plr.row_number is None:
            return

        row = plr.row_number
        self.insertRow(row)

        # Add row metadata to userdata column
        userdata_item = QTableWidgetItem()
        userdata_item.setData(self.ROW_FLAGS, 0)
        userdata_item.setData(self.PLAYLISTROW_ID, plr.id)
        userdata_item.setData(self.ROW_TRACK_ID, plr.track_id)
        self.setItem(row, USERDATA, userdata_item)

        if plr.track_id:
            # Add track details to items
            try:
                start_gap = plr.track.start_gap
            except AttributeError:
                return
            start_gap_item = self._set_item_text(
                row, START_GAP, str(start_gap))
            if start_gap and start_gap >= 500:
                start_gap_item.setBackground(QColor(Config.COLOUR_LONG_START))

            track_title = plr.track.title
            if not track_title:
                track_title = ""
            _ = self._set_item_text(row, TITLE, track_title)

            track_artist = plr.track.artist
            if not track_artist:
                track_artist = ""
            _ = self._set_item_text(row, ARTIST, track_artist)

            _ = self._set_item_text(row, DURATION,
                                    ms_to_mmss(plr.track.duration))
            if plr.track.duration:
                self._set_row_duration(row, plr.track.duration)

            _ = self._set_item_text(row, START_TIME, "")

            _ = self._set_item_text(row, END_TIME, "")

            if plr.track.bitrate:
                bitrate = str(plr.track.bitrate)
            else:
                bitrate = ""
            _ = self._set_item_text(row, BITRATE, bitrate)

            # As we have track info, any notes should be contained in
            # the notes column
            plr_note = plr.note
            if not plr_note:
                plr_note = ""
            _ = self._set_item_text(row, ROW_NOTES, plr_note)

            last_playtime = Playdates.last_played(session, plr.track.id)
            last_played_str = get_relative_date(last_playtime)
            _ = self._set_item_text(row, LASTPLAYED, last_played_str)

        else:
            # This is a section header so it must have note text
            if plr.note is None:
                log.debug(
                    f"insert_row({plr=}) with no track_id and no note"
                )
                return

            # Make empty items (row background won't be coloured without
            # items present). Any notes should displayed starting in
            # column 2 for now - bug in Qt means that when row size is
            # set, spanned columns are ignored, so put notes in col2
            # (typically title).
            for i in range(1, len(columns)):
                if i == 2:
                    continue
                self.setItem(row, i, QTableWidgetItem())
            self.setSpan(row, HEADER_NOTES_COLUMN, 1, len(columns) - 1)
            _ = self._set_item_text(row, HEADER_NOTES_COLUMN, plr.note)

            # Save (no) track_id
            userdata_item.setData(self.ROW_TRACK_ID, 0)

        if repaint:
            # Schedule so that display can update with new row first
            QTimer.singleShot(0, lambda: self.update_display(session))

    def insert_track(self, session: scoped_session, track: Tracks,
                     note: Optional[str] = None, repaint: bool = True) -> None:
        """
        Insert track into playlist tab.

        If a row is selected, add track above. Otherwise, add to end of
        playlist.

        We simply build a PlaylistRows object and pass it to insert_row()
        to do the heavy lifing.
        """

        if not track:
            log.debug(
                f"insert_track({session=}, {note=}, {repaint=}"
                " called with no track"
            )
            return

        row_number = self.get_new_row_number()

        # Check to see whether track is already in playlist
        existing_plr = PlaylistRows.get_track_plr(session, track.id,
                                                  self.playlist_id)
        if existing_plr and ask_yes_no("Duplicate row",
                                       "Track already in playlist. "
                                       "Move to new location?"):
            # Yes it is and we should reuse it
            # If we've been passed a note, we need to add that to the
            # existing track
            if note:
                existing_plr.append_note(note)
            return self._move_row(session, existing_plr, row_number)

        # Build playlist_row object
        plr = PlaylistRows(session, self.playlist_id, track.id,
                           row_number, note)
        self.insert_row(session, plr, repaint)
        # Let display update, then save playlist
        QTimer.singleShot(0, lambda: self.save_playlist(session))

    def play_started(self, session: scoped_session) -> None:
        """
        Notification from musicmuster that track has started playing.

        Actions required:
        - Note start time
        - Mark next-track row as current
        - Mark current row as played
        - Scroll to put next track as required
        - Set next track
        - Update display
        """

        current_row = self._get_current_track_row_number()
        if current_row is None:
            send_mail(Config.ERRORS_TO,
                      Config.ERRORS_FROM,
                      "MusicMuster unexpected failure",
                      stackprinter.format()
                      )
            return

        search_from = current_row + 1

        # Mark current row as played
        self._set_played_row(session, current_row)

        next_row = self._find_next_track_row(session, search_from)
        if next_row:
            self._set_next(session, next_row)

        # Update display
        self.update_display(session)

    def populate_display(self, session: scoped_session, playlist_id: int,
                         scroll_to_top: bool = True) -> None:
        """
        Populate display from the associated playlist ID
        """

        # Sanity check row numbering before we load
        PlaylistRows.fixup_rownumbers(session, playlist_id)

        # Clear playlist
        self.setRowCount(0)

        # Add the rows
        playlist = session.get(Playlists, playlist_id)
        if not playlist:
            send_mail(Config.ERRORS_TO,
                      Config.ERRORS_FROM,
                      "MusicMuster unexpected failure",
                      stackprinter.format()
                      )
            return

        for plr in playlist.rows:
            self.insert_row(session, plr, repaint=False)

        # Scroll to top
        if scroll_to_top:
            row0_item = self.item(0, 0)
            if row0_item:
                self.scrollToItem(row0_item, QAbstractItemView.PositionAtTop)

        # Set widths
        self._set_column_widths(session)

        # Needed to wrap notes column correctly - add to event queue so
        # that it's processed after list populated
        QTimer.singleShot(0, self.tab_visible)

        # We possibly don't need to save the playlist here, but row
        # numbers may have changed during population, and it's cheap to do
        # self.save_playlist(session)
        self.update_display(session)

    def remove_rows(self, row_numbers: List[int]) -> None:
        """Remove passed rows from display"""

        # Remove rows from display. Do so in reverse order so that
        # row numbers remain valid.
        for row in sorted(row_numbers, reverse=True):
            self.removeRow(row)

    def remove_selected_rows(self) -> None:
        """Remove selected rows from display"""

        self.remove_rows(self._get_selected_rows())
        # Reset drag mode
        self.setDragEnabled(False)

    def save_playlist(self, session: scoped_session) -> None:
        """
        Get the PlaylistRow objects for each row in the display. Correct
        the row_number and playlist_id if necessary. Remove any row
        numbers in the database that are higher than the last row in
        the display.
        """

        # Build a dictionary of
        #   {display_row_number: display_row_plr_id}
        display_plr_ids = {row_number: self._get_playlistrow_id(row_number)
                           for row_number in range(self.rowCount())}

        # Now build a dictionary of
        #   {display_row_number: display_row_plr}
        plr_dict_by_id = PlaylistRows.indexed_by_id(
            session, display_plr_ids.values())

        # Finally a dictionary of
        #   {display_row_number: plr}
        row_plr = {row_number: plr_dict_by_id[display_plr_ids[row_number]]
                   for row_number in range(self.rowCount())}

        # Ensure all row plrs have correct row number and playlist_id
        for row in range(self.rowCount()):
            row_plr[row].row_number = row
            row_plr[row].playlist_id = self.playlist_id

        # Any rows in the database for this playlist that have a plr id
        # that's not in the displayed playlist need to be deleted.

        # Ensure changes flushed
        session.flush()
        PlaylistRows.delete_plrids_not_in_list(
            session, self.playlist_id,
            display_plr_ids.values())

    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.
        """

        row: int
        selected_rows: List[int]

        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 = 0
        else:
            row = selected_rows[0] + 1
            if row >= self.rowCount():
                row = 0

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

        self.selectRow(row)

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

        row: int
        selected_rows: List[int]

        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 = last_row
        else:
            row = selected_rows[0] - 1
            if row < 0:
                row = last_row

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

        self.selectRow(row)

    def set_selected_as_next(self) -> None:
        """Sets the select track as next to play"""

        row = self._get_selected_row()
        if row is None:
            return None

        with Session() as session:
            self._set_next(session, row)

    def tab_visible(self) -> None:
        """Called when tab becomes visible"""

        # Set row heights
        self.resizeRowsToContents()
        self.setColumnWidth(len(columns) - 1, 0)
        with Session() as session:
            self.update_display(session)

    def update_display(self, session: scoped_session) -> None:
        """
        Set row colours, fonts, etc

        Actions required:
        - Render notes in correct colour
        - Render current, next and unplayable tracks in correct colour
        - Set start and end times
        - Show unplayed tracks in bold
        """

        current_row = self._get_current_track_row_number()
        next_row = self._get_next_track_row_number()
        played = [
            p.row_number for p in PlaylistRows.get_played_rows(
                session, self.playlist_id)
        ]

        next_start_time = None
        section_start_plr = None
        section_time = 0

        # Start time calculations
        # Don't change start times for tracks that have been played.
        # For unplayed tracks, if there's a 'current' or 'next'
        # track marked, populate start times from then onwards. A note
        # with a start time will reset the next track start time.

        # Cycle through all rows
        for row in range(self.rowCount()):

            # Extract note text from database to ignore section timings
            playlist_row = session.get(PlaylistRows,
                                       self._get_playlistrow_id(row))
            if not playlist_row:
                continue
            note_text = playlist_row.note
            note_colour = None
            if not note_text:
                note_text = ""
            # Get note colour
            else:
                note_colour = NoteColours.get_colour(session, note_text)

            # Get track if there is one
            track_id = self._get_row_track_id(row)
            track = None
            if track_id:
                track = session.get(Tracks, track_id)
                if not track:
                    # We have a track_id but we can't find the track.
                    # Update playlist_row accordingly
                    missing_track = playlist_row.track_id
                    playlist_row.track_id = None
                    if note_text:
                        note_text += f"track_id {missing_track} not found"
                    else:
                        note_text = f"track_id {missing_track} not found"
                    playlist_row.note = note_text
                    session.flush()
                    _ = self._set_item_text(row, HEADER_NOTES_COLUMN,
                                            note_text)

            if track:
                # Reset colour in case it was current/next/unplayable
                self._set_row_colour(row, None)

                # Render unplayable tracks in correct colour
                if not file_is_readable(track.path):
                    self._set_row_colour(row, QColor(Config.COLOUR_UNREADABLE))
                    self._set_row_bold(row)
                    continue

                # Add track time to section time if in timed section
                if section_start_plr is not None:
                    section_time += track.duration

                # Colour any note
                if note_colour:
                    notes_item = self.item(row, ROW_NOTES)
                    if notes_item:
                        notes_item.setBackground(QColor(note_colour))

                # Highlight low bitrates
                if track.bitrate:
                    bitrate_str = str(track.bitrate)
                    bitrate_item = self._set_item_text(
                        row, BITRATE, str(track.bitrate))
                    if bitrate_item:
                        if track.bitrate < Config.BITRATE_LOW_THRESHOLD:
                            cell_colour = Config.COLOUR_BITRATE_LOW
                        elif track.bitrate < Config.BITRATE_OK_THRESHOLD:
                            cell_colour = Config.COLOUR_BITRATE_MEDIUM
                        else:
                            cell_colour = Config.COLOUR_BITRATE_OK
                        brush = QBrush(QColor(cell_colour))
                        bitrate_item.setBackground(brush)

                # Render playing track
                if row == current_row:
                    # Set last played time to "Today"
                    self._set_item_text(
                        row, LASTPLAYED, Config.LAST_PLAYED_TODAY_STRING)
                    # Calculate next_start_time
                    if track.duration:
                        next_start_time = self._calculate_end_time(
                            self.musicmuster.current_track.start_time,
                            track.duration
                        )
                    # Set end time
                    self._set_row_end_time(row, next_start_time)
                    # Set colour
                    self._set_row_colour(row, QColor(
                        Config.COLOUR_CURRENT_PLAYLIST))
                    # Make bold
                    self._set_row_bold(row)
                    continue

                # Render next track
                if row == next_row:
                    # Set start time
                    # if there's a track playing, set start time from
                    # that. It may be on a different tab, so we get
                    # start time from musicmuster.
                    start_time = self.musicmuster.current_track.end_time
                    if start_time is None:
                        # No current track to base from, but don't change
                        # time if it's already set
                        start_time = self._get_row_start_time(row)
                        if not start_time:
                            start_time = next_start_time
                    self._set_row_start_time(row, start_time)
                    # Calculate next_start_time
                    if track.duration:
                        next_start_time = self._calculate_end_time(
                            start_time, track.duration)
                    # Set end time
                    self._set_row_end_time(row, next_start_time)
                    # Set colour
                    self._set_row_colour(
                        row, QColor(Config.COLOUR_NEXT_PLAYLIST))
                    # Make bold
                    self._set_row_bold(row)
                    continue

                if row in played:
                    # Played today, so update last played column
                    self._set_item_text(
                        row, LASTPLAYED, Config.LAST_PLAYED_TODAY_STRING)
                    if self.musicmuster.hide_played_tracks:
                        self.hideRow(row)
                    else:
                        self.showRow(row)
                        self._set_row_not_bold(row)
                else:
                    # Set start/end times as we haven't played it yet
                    if next_start_time:
                        self._set_row_start_time(row, next_start_time)
                        if track.duration:
                            next_start_time = self._calculate_end_time(
                                next_start_time, track.duration)
                        # Set end time
                        self._set_row_end_time(row, next_start_time)
                    else:
                        # Clear start and end time
                        self._set_row_start_time(row, None)
                        self._set_row_end_time(row, None)
                    # Don't dim unplayed tracks
                    self._set_row_bold(row)

                continue

            # No track associated, so this row is a section header
            # Does the note have a start time?
            row_time = self._get_note_text_time(note_text)
            if row_time:
                next_start_time = row_time
            # Does it delimit a section?
            if section_start_plr is not None:
                if note_text.endswith("-"):
                    self._update_note_text(
                        section_start_plr,
                        self._get_section_timing_string(section_time)
                    )
                    section_start_plr = None
                    section_time = 0
            elif note_text.endswith("+"):
                section_start_plr = playlist_row
                section_time = 0
            if not note_colour:
                note_colour = Config.COLOUR_NOTES_PLAYLIST
            self._set_row_colour(row, QColor(note_colour))
            # Section headers are always bold
            self._set_row_bold(row)
            continue

        # Set row heights
        self.resizeRowsToContents()

        # Have we had a section start but not end?
        if section_start_plr is not None:
            self._update_note_text(
                section_start_plr,
                self._get_section_timing_string(section_time, no_end=True)
            )

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

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

        with Session() as session:
            track = self.musicmuster.get_one_track(session)
            if not track:
                return

            # Add track to playlist row
            plr = session.get(PlaylistRows, self._get_playlistrow_id(row))
            if not plr:
                return

            plr.track_id = track.id
            session.flush()

            # Reset row span
            for column in range(len(columns)):
                self.setSpan(row, column, 1, 1)

            # Update attributes of row
            userdata_item = self.item(row, USERDATA)
            if not userdata_item:
                userdata_item = QTableWidgetItem()
            userdata_item.setData(self.ROW_TRACK_ID, track.id)

            last_playtime = Playdates.last_played(session, track.id)
            last_played_str = get_relative_date(last_playtime)
            _ = self._set_item_text(row, LASTPLAYED, last_played_str)

            _ = self._set_item_text(row, ROW_NOTES, plr.note)

            start_gap_item = self._set_item_text(row, START_GAP,
                                                 track.start_gap)
            if track.start_gap and track.start_gap >= 500:
                start_gap_item.setBackground(QColor(Config.COLOUR_LONG_START))

            self._update_row(session, row, track)

    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, idx: int, _old: int, _new: int) -> None:
        """
        Called when column widths are changed.

        Save column sizes to database
        """

        with Session() as session:
            for column_name, data in columns.items():
                idx = data.idx
                if idx == len(columns) - 1:
                    # Don't set width of last column as it's set to
                    # stretch
                    continue
                width = self.columnWidth(idx)
                attribute_name = f"playlist_{column_name}_col_width"
                record = Settings.get_int_settings(session, attribute_name)
                if record.f_int != self.columnWidth(idx):
                    record.update(session, {'f_int': width})

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

        assert self.menu
        self.menu.exec_(self.mapToGlobal(pos))

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

        track_id = self._get_row_track_id(row)
        if track_id is None:
            return

        with Session() as session:
            track = session.get(Tracks, track_id)
            if track and track.path:
                # Escape single quotes and spaces in name
                path = track.path
                pathq = path.replace("'", "\\'")
                pathqs = pathq.replace(" ", "\\ ")
                cb = QApplication.clipboard()
                cb.clear(mode=cb.Clipboard)
                cb.setText(pathqs, mode=cb.Clipboard)

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

        Actions required:
        - Remove the rows from the display
        - Save the playlist
        """

        # Delete rows from database
        plr_ids = self.get_selected_playlistrow_ids()
        if not plr_ids:
            return

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

        self.remove_selected_rows()

        with Session() as session:
            QTimer.singleShot(0, lambda: self.save_playlist(session))

    def _drop_on(self, event):
        """
        https://stackoverflow.com/questions/26227885/drag-and-drop-rows-within-qtablewidget
        """

        index = self.indexAt(event.pos())
        if not index.isValid():
            return self.rowCount()

        return (index.row() + 1 if self._is_below(event.pos(), index)
                else index.row())

    def _find_next_track_row(self, session: scoped_session,
                             starting_row: Optional[int] = None) \
            -> Optional[int]:
        """
        Find next track to play. If a starting row is given, start there;
        otherwise, start from top. Skip rows already played.

        If not found, return None.

        If found, return row number.
        """

        if starting_row is None:
            starting_row = 0

        track_rows = [
            p.row_number for p in PlaylistRows.get_rows_with_tracks(
                session, self.playlist_id)
        ]
        played_rows = [
            p.row_number for p in PlaylistRows.get_played_rows(
                session, self.playlist_id)
        ]
        for row in range(starting_row, self.rowCount()):
            plr = self._get_playlistrow_object(session, row)
            if not plr:
                continue
            if (
                    row not in track_rows or
                    row in played_rows or
                    not file_is_readable(plr.track.path)
            ):
                continue
            else:
                return row

        return None

    def _get_current_track_row_number(self) -> Optional[int]:
        """Return current track row or None"""

        current_track = self.musicmuster.current_track
        if not current_track or not current_track.plr_id:
            return None

        return self._plrid_to_row_number(current_track.plr_id)

    def _get_next_track_row_number(self) -> Optional[int]:
        """Return next track row or None"""

        next_track = self.musicmuster.next_track
        if not next_track or not next_track.plr_id:
            return None

        return self._plrid_to_row_number(next_track.plr_id)

    @staticmethod
    def _get_note_text_time(text: str) -> Optional[datetime]:
        """Return time specified as @hh:mm:ss in text"""

        try:
            match = start_time_re.search(text)
        except TypeError:
            return None
        if not match:
            return None

        try:
            return datetime.strptime(match.group(0)[1:],
                                     Config.NOTE_TIME_FORMAT)
        except ValueError:
            return None

    def _get_playlistrow_id(self, row: int) -> Optional[int]:
        """Return the playlistrow_id associated with this row"""

        userdata_item = self.item(row, USERDATA)
        if not userdata_item:
            return None

        return userdata_item.data(self.PLAYLISTROW_ID)

    def _get_playlistrow_object(self, session: scoped_session,
                                row: int) -> Optional[PlaylistRows]:
        """Return the playlistrow object associated with this row"""

        userdata_item = self.item(row, USERDATA)
        if not userdata_item:
            return None

        playlistrow_id = userdata_item.data(self.PLAYLISTROW_ID)
        if not playlistrow_id:
            return None

        return session.get(PlaylistRows, playlistrow_id)

    def _get_row_artist(self, row: int) -> Optional[str]:
        """Return artist on this row or None if none"""

        track_id = self._get_row_track_id(row)
        if not track_id:
            return None

        item_artist = self.item(row, ARTIST)
        if not item_artist:
            return None

        return item_artist.text()

    def _get_row_duration(self, row: int) -> int:
        """Return duration associated with this row"""

        userdata_item = self.item(row, USERDATA)
        if not userdata_item:
            return 0

        duration = userdata_item.data(self.ROW_DURATION)
        if duration:
            return duration
        else:
            return 0

    def _get_row_note(self, row: int) -> Optional[str]:
        """Return note on this row or None if none"""

        track_id = self._get_row_track_id(row)
        if track_id:
            item_note = self.item(row, ROW_NOTES)
        else:
            item_note = self.item(row, HEADER_NOTES_COLUMN)
        if not item_note:
            return None

        return item_note.text()

    def _get_row_start_time(self, row: int) -> Optional[datetime]:
        """Return row start time as string or None"""

        start_time_item = self.item(row, START_TIME)
        if not start_time_item:
            return None

        try:
            return datetime.strptime(start_time_item.text(),
                                     Config.NOTE_TIME_FORMAT)
        except ValueError:
            return None

    def _get_row_title(self, row: int) -> Optional[str]:
        """Return title on this row or None if none"""

        track_id = self._get_row_track_id(row)
        if not track_id:
            return None

        item_title = self.item(row, TITLE)
        if not item_title:
            return None

        return item_title.text()

    def _get_row_track_id(self, row: int) -> int:
        """Return the track_id associated with this row or None"""

        userdata_item = self.item(row, USERDATA)
        if not userdata_item:
            return 0

        try:
            track_id = userdata_item.data(self.ROW_TRACK_ID)
        except AttributeError:
            return 0

        return track_id

    def _get_selected_row(self) -> Optional[int]:
        """Return row number of first selected row, or None if none selected"""

        if not self.selectionModel().hasSelection():
            return None
        else:
            return self.selectionModel().selectedRows()[0].row()

    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(
            [row for row in set([a.row() for a in self.selectedItems()])]
        )

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

        with Session() as session:
            track = session.get(Tracks, track_id)
            if track:
                txt = (
                    f"Title: {track.title}\n"
                    f"Artist: {track.artist}\n"
                    f"Track ID: {track.id}\n"
                    f"Track duration: {ms_to_mmss(track.duration)}\n"
                    f"Track bitrate: {track.bitrate}\n"
                    f"Track fade at: {ms_to_mmss(track.fade_at)}\n"
                    f"Track silence at: {ms_to_mmss(track.silence_at)}"
                    "\n\n"
                    f"Path: {track.path}\n"
                )
            else:
                txt = f"Can't find {track_id=}"

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

    def _is_below(self, pos, index):   # review
        """
        https://stackoverflow.com/questions/26227885/drag-and-drop-rows-within-qtablewidget
        """

        rect = self.visualRect(index)
        margin = 2
        if pos.y() - rect.top() < margin:
            return False
        elif rect.bottom() - pos.y() < margin:
            return True
        return (
                rect.contains(pos, True) and not
        (int(self.model().flags(index)) & Qt.ItemIsDropEnabled)
                and pos.y() >= rect.center().y()  # noqa W503
        )

    def _move_row(self, session: scoped_session, plr: PlaylistRows,
                  new_row_number: int) -> None:
        """Move playlist row to new_row_number using parent copy/paste"""

        if plr.row_number is None:
            return

        # Remove source row
        self.removeRow(plr.row_number)
        # Fixup plr row number
        if plr.row_number < new_row_number:
            plr.row_number = new_row_number - 1
        else:
            plr.row_number = new_row_number
        self.insert_row(session, plr)
        self.save_playlist(session)

    def _mplayer_play(self, track_id: int) -> None:
        """Play track with mplayer"""

        with Session() as session:
            track = session.get(Tracks, track_id)
            if not track:
                log.error(
                    f"playlists._mplayer_play({track_id=}): "
                    "Track not found"
                )
                return

            cmd_list = ['gmplayer', '-vc', 'null', '-vo', 'null', track.path]
            thread = threading.Thread(
                target=self._run_subprocess, args=(cmd_list,))
            thread.start()

    def _open_in_audacity(self, track_id: int) -> None:
        """Open track in Audacity. Audacity must be already running"""

        with Session() as session:
            track = session.get(Tracks, track_id)
            if not track:
                log.error(
                    f"playlists._open_in_audacity({track_id=}): "
                    "Track not found"
                )
                return

            if track.path is None:
                log.error(
                    f"playlists._open_in_audacity({track_id=}): "
                    "Track has no path"
                )
            else:
                open_in_audacity(track.path)

    def _plrid_to_row_number(self, plrid: int) -> Optional[int]:
        """
        Return row number of passed plrid, or None if not found
        """

        for row_number in range(self.rowCount()):
            if self._get_playlistrow_id(row_number) == plrid:
                return row_number

        return None

    def _remove_track(self, row: int) -> None:
        """Remove track from row, making it a section header"""

        # Get confirmation
        if not ask_yes_no("Remove music",
                          "Really remove the music track from this row?"):
            return

        # Update playlist_rows record
        with Session() as session:
            plr = session.get(PlaylistRows, self._get_playlistrow_id(row))
            if not plr:
                return

            plr.track_id = None
            # We can't have null text
            if not plr.note:
                plr.note = Config.TEXT_NO_TRACK_NO_NOTE
            session.commit()

            # Clear track text items
            for i in range(2, len(columns)):
                _ = self._set_item_text(row, i, "")
            # Remove row duration
            self._set_row_duration(row, 0)
            # Remote track_id from row
            userdata_item = self.item(row, USERDATA)
            if userdata_item:
                userdata_item.setData(self.ROW_TRACK_ID, 0)
            # Span the rows
            self.setSpan(row, HEADER_NOTES_COLUMN, 1, len(columns) - 1)
            # Set note text in correct column for section head
            _ = self._set_item_text(row, HEADER_NOTES_COLUMN, plr.note)
            # And refresh display
            self.update_display(session)

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

        with Session() as session:
            track = session.get(Tracks, track_id)
            if not track:
                log.error(
                    f"playlists._rescan({track_id=}): "
                    "Track not found"
                )
                return

            set_track_metadata(session, track)
            self._update_row(session, row, track)

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

        subprocess.call(args)

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

        if row is None:
            return

        padding_required = Config.SCROLL_TOP_MARGIN
        top_row = row

        if row > 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 until we either reach the target, pass it or reach
            # row 0.
            # target_row = max(0, row - Config.SCROLL_TOP_MARGIN + 1)
            for i in range(row - 1, -1, -1):
                if padding_required == 0:
                    break
                if self.isRowHidden(i):
                    continue
                top_row = i
                padding_required -= 1

        scroll_item = self.item(top_row, 0)
        self.scrollToItem(scroll_item, QAbstractItemView.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 = starting_row
        needle = self.search_text.lower()
        while True:
            # Check for match in title, artist or notes
            title = self._get_row_title(row)
            if title and needle in title.lower():
                match_row = row
                break
            artist = self._get_row_artist(row)
            if artist and needle in artist.lower():
                match_row = row
                break
            note = self._get_row_note(row)
            if note and needle in note.lower():
                match_row = row
                break
            if next:
                row += 1
                if wrapped and row >= starting_row:
                    break
                if row >= self.rowCount():
                    row = 0
                    wrapped = True
            else:
                row -= 1
                if wrapped and row <= starting_row:
                    break
                if row < 0:
                    row = self.rowCount() - 1
                    wrapped = True

        if match_row is not None:
            self.selectRow(row)

    def _select_event(self) -> None:
        """
        Called when item selection changes.
        If multiple rows are selected, display sum of durations in status bar.
        """

        # If we are in the process of selecting multiple tracks, no-op here
        if self.selecting_in_progress:
            return

        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("")
            return

        ms = 0
        for row in selected_rows:
            ms += self._get_row_duration(row)

        # Only paint message if there are selected track rows
        if ms > 0:
            self.musicmuster.lblSumPlaytime.setText(
                f"Selected duration: {ms_to_mmss(ms)}")
        else:
            self.musicmuster.lblSumPlaytime.setText("")

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

        for column_name, data in columns.items():
            idx = data.idx
            if idx == len(columns) - 1:
                # Set width of last column to zero as it's set to stretch
                self.setColumnWidth(idx, 0)
                continue
            attr_name = f"playlist_{column_name}_col_width"
            record: Settings = Settings.get_int_settings(session, attr_name)
            if record and record.f_int >= 0:
                self.setColumnWidth(idx, record.f_int)
            else:
                self.setColumnWidth(idx, Config.DEFAULT_COLUMN_WIDTH)

    def _set_item_text(self, row, column, text) -> QTableWidgetItem:
        """
        Set text for item if it exists, else create it, and return item
        """

        item = self.item(row, column)
        if not item:
            item = QTableWidgetItem(text)
            self.setItem(row, column, item)
        else:
            item.setText(text)
        return item

    def _set_next(self, session: scoped_session, row_number: int) -> None:
        """
        Set passed row as next playlist row to play.

        Actions required:
        - Check row has a track
        - Check track is readable
        - Notify musicmuster
        - Update display
        """

        track_id = self._get_row_track_id(row_number)
        if not track_id:
            log.error(
                f"playlists._set_next({row_number=}) has no track associated"
            )
            return

        track = session.get(Tracks, track_id)
        if not track:
            log.error(f"playlists._set_next({row_number=}): Track not found")
            return

        # Check track is readable
        if not file_is_readable(track.path):
            return None

        # Notify musicmuster
        plr = session.get(PlaylistRows, self._get_playlistrow_id(row_number))
        if not plr:
            log.debug(f"playists._set_next({row_number=}) can't retrieve plr")
        else:
            self.musicmuster.this_is_the_next_playlist_row(session, plr, self)

        # Update display
        self.clear_selection()
        self.update_display(session)

    def _set_played_row(self, session: scoped_session, row: int) -> None:
        """Mark this row as played"""

        plr = session.get(PlaylistRows, self._get_playlistrow_id(row))
        if not plr:
            return

        plr.played = True
        session.commit()

    def _set_row_bold(self, row: int, bold: bool = True) -> None:
        """
        Make row bold (bold=True) or not bold.

        Don't make notes column bold.
        """

        boldfont = QFont()
        boldfont.setBold(bold)
        for column in range(self.columnCount()):
            if column == ROW_NOTES:
                continue
            item = self.item(row, column)
            if item:
                item.setFont(boldfont)

    def _set_row_colour(self, row: int,
                        colour: Optional[QColor] = None) -> None:
        """
        Set or reset row background colour
        """

        column: int

        if colour:
            brush = QBrush(colour)
        else:
            brush = QBrush()

        for column in range(1, self.columnCount()):
            # Don't change colour on start gap columns
            if column == START_GAP:
                continue
            item = self.item(row, column)
            if item:
                item.setBackground(brush)

    def _set_row_duration(self, row: int, ms: int) -> None:
        """Set duration of this row in row metadata"""

        item = self.item(row, USERDATA)
        if item:
            item.setData(self.ROW_DURATION, ms)

    def _set_row_end_time(self, row: int, time: Optional[datetime]) -> None:
        """Set passed row end time to passed time"""

        try:
            time_str = time.strftime(Config.TRACK_TIME_FORMAT)  # type: ignore
        except AttributeError:
            time_str = ""

        item = QTableWidgetItem(time_str)
        self.setItem(row, END_TIME, item)

    def _set_row_not_bold(self, row: int) -> None:
        """Set row to not be bold"""

        self._set_row_bold(row, False)

    def _set_row_start_time(self, row: int, time: Optional[datetime]) -> None:
        """Set passed row start time to passed time"""

        try:
            time_str = time.strftime(Config.TRACK_TIME_FORMAT)  # type: ignore
        except AttributeError:
            time_str = ""
        _ = self._set_item_text(row, START_TIME, time_str)

    def _get_section_timing_string(self, ms: int,
                                   no_end: bool = False) -> str:
        """Return string describing section duration"""

        duration = ms_to_mmss(ms)
        caveat = ""
        if no_end:
            caveat = " (to end of playlist)"
        return ' [' + duration + caveat + ']'

    def _songfacts(self, row_number: int) -> None:
        """Look up passed row title in songfacts and display info tab"""

        title = self._get_row_title(row_number)

        self.musicmuster.tabInfolist.open_in_songfacts(title)

    def _update_note_text(self, playlist_row: PlaylistRows,
                          additional_text: str) -> None:
        """Append additional_text to row display"""

        # Column to update is either HEADER_NOTES_COLUMN for a section
        # header or the appropriate row_notes column for a track row
        if playlist_row.track_id:
            column = ROW_NOTES
        else:
            column = HEADER_NOTES_COLUMN

        # Update text
        if playlist_row.note:
            new_text = playlist_row.note + additional_text
        else:
            new_text = additional_text

        _ = self._set_item_text(playlist_row.row_number, column, new_text)

    def _update_row(self, session, row: int, track: Tracks) -> None:
        """
        Update the passed row with info from the passed track.
        """

        start_gap_item = self._set_item_text(
            row, START_GAP, str(track.start_gap))
        if track.start_gap and track.start_gap >= 500:
            start_gap_item.setBackground(QColor(Config.COLOUR_LONG_START))
        else:
            start_gap_item.setBackground(QColor("white"))

        _ = self._set_item_text(row, TITLE, track.title)
        _ = self._set_item_text(row, ARTIST, track.artist)
        _ = self._set_item_text(row, DURATION, track.duration)
        _ = self._set_item_text(row, BITRATE, track.bitrate)

        self.update_display(session)

    def _wikipedia(self, row_number: int) -> None:
        """Look up passed row title in Wikipedia and display info tab"""

        title = self._get_row_title(row_number)

        self.musicmuster.tabInfolist.open_in_wikipedia(title)
