import re
import subprocess
import threading

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

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

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

start_time_re = re.compile(r"@\d\d:\d\d:\d\d")


class RowMeta:
    CLEAR = 0
    NOTE = 1
    UNREADABLE = 2
    NEXT = 3
    CURRENT = 4


# 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["row_notes"] = Column(idx=8, heading=Config.COLUMN_NAME_NOTES)


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

    def createEditor(self, parent, option, index):
        editor = super().createEditor(parent, option, index)
        if isinstance(editor, QLineEdit):
            def deselect():
                # Important! First disconnect, otherwise editor.deselect()
                # will call again this function
                editor.selectionChanged.disconnect(deselect)
                editor.deselect()
            editor.selectionChanged.connect(deselect)
        return editor


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: QMainWindow, session: Session,
                 playlist_id: int, *args, **kwargs) -> None:
        super().__init__(*args, **kwargs)
        self.musicmuster = musicmuster
        self.playlist_id = playlist_id

        self.menu: Optional[QMenu] = None
        self.current_track_start_time: Optional[datetime] = 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))

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

        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.row_filter: Optional[str] = None
        self.edit_cell_type = None
        self.selecting_in_progress = False
        # Connect signals
        self.horizontalHeader().sectionResized.connect(self._column_resize)

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

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

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

    def closeEvent(self, event) -> None:
        """Handle closing playist tab"""

        with Session() as session:
            # Record playlist as closed
            playlist = session.get(Playlists, self.playlist_id)
            playlist.close(session)

        event.accept()

    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)) 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, 1, 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)
                if track_id:
                    current = row_number == self._get_current_track_row()
                    next_row = row_number == self._get_next_track_row()
                else:
                    current = next_row = False

                if track_id:
                    # 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))

                    # Open in Audacity
                    if not current:
                        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()

                        # Remove track
                        act_remove_track = self.menu.addAction('Remove track')
                        act_remove_track.triggered.connect(
                            lambda: self._remove_track(row_number)
                        )

                else:
                    # Add track to section header (ie, make this a track
                    # row)
                    act_add_track = self.menu.addAction('Add track')
                    act_add_track.triggered.connect(self._add_track)

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

                    # Remove row
                    act_delete = self.menu.addAction('Remove row')
                    act_delete.triggered.connect(self._delete_rows)

        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()  (called twice; not sure why)
    #       _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)

        new_text = self.item(row, column).text()
        track_id = self._get_row_track_id(row)

        # Determin cell type changed
        with Session() as session:
            if self.edit_cell_type == "row_notes":
                # Get playlistrow object
                plr_id = self._get_playlistrow_id(row)
                plr_item = session.get(PlaylistRows, plr_id)
                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
                        elif self.edit_cell_type == "artist":
                            track.artist = new_text
                        # Headers will be incorrect if the edited track is
                        # previous / current / next TODO: this will require
                        # the stored data in musicmuster to be updated,
                        # which currently it isn't).
                        self.musicmuster.update_headers()

    def closeEditor(self,
                    editor: QWidget,
                    hint: QAbstractItemDelegate.EndEditHint) -> None:
        """
        Override 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()

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

    def edit(self, index: QModelIndex,
             trigger: QAbstractItemView.EditTrigger,
             event: QEvent) -> bool:
        """
        Override 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.
                self.edit_cell_type = None
                if column == columns['title'].idx:
                    self.edit_cell_type = "title"
                elif column == columns['artist'].idx:
                    self.edit_cell_type = "artist"
                elif column == columns['row_notes'].idx:
                    self.edit_cell_type = "row_notes"
                else:
                    # Can't edit other columns
                    return

                # Check whether we're editing a notes row for later
                if self.edit_cell_type == "row_notes":
                    note_column = columns['row_notes'].idx
            else:
                # This is a section header. Text is always in row 1.
                if column != 1:
                    return
                note_column = 1
                self.edit_cell_type = "row_notes"

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

            # 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)
                    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_next(self, session) -> None:
        """Clear next track marker"""

        self._meta_clear_next()
        self.update_display(session)

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

        self.clearSelection()
        self.setDragEnabled(False)

    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._selected_rows()]

    def insert_header(self, session: 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.
        """

        # PlaylistRows object requires a row number, but that number
        # can be reset by calling PlaylistRows.fixup_rownumbers() later,
        # so just fudge a row number for now.
        row_number = 0
        plr = PlaylistRows(session, self.playlist_id, None, row_number)
        plr.note = note
        self.insert_row(session, plr)
        PlaylistRows.fixup_rownumbers(session, self.playlist_id)
        if repaint:
            self.update_display(session)
#
#     def _get_selected_rows(self) -> List[int]:
#         """Return a sorted list of selected row numbers"""
#
#         rows = self.selectionModel().selectedRows()
#         return sorted([row.row() for row in rows])
#
#     def get_selected_title(self) -> Optional[str]:
#         """Return title of selected row or None"""
#
#         if self.selectionModel().hasSelection():
#             row = self.currentRow()
#             return self.item(row, FIXUP.COL_TITLE).text()
#         else:
#             return None

    def insert_row(self, session: Session, row_data: PlaylistRows,
                   repaint: bool = True) -> None:
        """
        Insert a row into playlist tab.

        If playlist has a row selected, add new row above. Otherwise,
        add to end of playlist.

        Note: we ignore the row number in the PlaylistRows record. That is
        used only to order the query that generates the records.
        """

        if self.selectionModel().hasSelection():
            row = self.currentRow()
        else:
            row = self.rowCount()
        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, row_data.id)
        userdata_item.setData(self.ROW_TRACK_ID, row_data.track_id)
        self.setItem(row, columns['userdata'].idx, userdata_item)

        if row_data.track_id:
            # Add track details to items
            start_gap = row_data.track.start_gap
            start_gap_item = QTableWidgetItem(str(start_gap))
            if start_gap and start_gap >= 500:
                start_gap_item.setBackground(QColor(Config.COLOUR_LONG_START))
            self.setItem(row, columns['start_gap'].idx, start_gap_item)

            title_item = QTableWidgetItem(row_data.track.title)
            self.setItem(row, columns['title'].idx, title_item)

            artist_item = QTableWidgetItem(row_data.track.artist)
            self.setItem(row, columns['artist'].idx, artist_item)

            duration_item = QTableWidgetItem(
                ms_to_mmss(row_data.track.duration))
            self.setItem(row, columns['duration'].idx, duration_item)
            self._set_row_duration(row, row_data.track.duration)

            start_item = QTableWidgetItem()
            self.setItem(row, columns['start_time'].idx, start_item)

            end_item = QTableWidgetItem()
            self.setItem(row, columns['end_time'].idx, end_item)

            # As we have track info, any notes should be contained in
            # the notes column
            notes_item = QTableWidgetItem(row_data.note)
            self.setItem(row, columns['row_notes'].idx, notes_item)

            last_playtime = Playdates.last_played(session, row_data.track.id)
            last_played_str = get_relative_date(last_playtime)
            last_played_item = QTableWidgetItem(last_played_str)
            self.setItem(row, columns['lastplayed'].idx, last_played_item)

            # Mark track if file is unreadable
            if not file_is_readable(row_data.track.path):
                self._set_unreadable_row(row)

        else:
            # This is a section header so make empty items (row
            # background won't be coloured without items present). Any
            # notes should displayed starting in column 0
            for i in range(2, len(columns) - 1):
                self.setItem(row, i, QTableWidgetItem())
            notes_item = QTableWidgetItem(row_data.note)
            self.setItem(row, 1, notes_item)
            self.setSpan(row, 1, 1, len(columns))

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

        if repaint:
            self.save_playlist(session)
            self.update_display(session, clear_selection=False)

    def insert_track(self, session: Session, track: Tracks,
                     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.
        """

        # PlaylistRows object requires a row number, but that number
        # can be reset by calling PlaylistRows.fixup_rownumbers() later,
        # so just fudge a row number for now.
        row_number = 0
        plr = PlaylistRows(session, self.playlist_id, track.id, row_number)
        self.insert_row(session, plr)
        PlaylistRows.fixup_rownumbers(session, self.playlist_id)
        if repaint:
            self.update_display(session)
#
#     def move_selected_to_playlist(self, session: Session, playlist_id: int) \
#             -> None:
#         """
#         Move selected rows and any immediately preceding notes to
#         other playlist
#         """
#
#         notes_rows = self._get_notes_rows()
#         destination_row = Playlists.next_free_row(session, playlist_id)
#         rows_to_remove = []
#
#         for row in self._get_selected_rows():
#             if row in notes_rows:
#                 note_obj = self._get_row_notes_object(row, session)
#                 note_obj.move_row(session, destination_row, playlist_id)
#             else:
#                 # For tracks, check for a preceding notes row and move
#                 # that as well if it exists
#                 if row - 1 in notes_rows:
#                     note_obj = self._get_row_notes_object(row - 1, session)
#                     note_obj.move_row(session, destination_row, playlist_id)
#                     destination_row += 1
#                     rows_to_remove.append(row - 1)
#                 # Move track
#                 PlaylistTracks.move_row(
#                     session, row, self.playlist_id,
#                     destination_row, playlist_id
#                 )
#             destination_row += 1
#             rows_to_remove.append(row)
#
#         # Remove rows. Row number will change as we delete rows so
#         # remove them in reverse order.
#
#         try:
#             self.selecting_in_progress = True
#             for row in sorted(rows_to_remove, reverse=True):
#                 self.removeRow(row)
#         finally:
#             self.selecting_in_progress = False
#             self._select_event()
#
#         self.save_playlist(session)
#         self.update_display(session)

    def play_started(self, session: 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 current track as required
        - Set next track
        - Update display
        """

        # Note start time
        self.current_track_start_time = datetime.now()

        # Mark next-track row as current
        current_row = self._get_next_track_row()
        if current_row is None:
            return
        self._set_current_track_row(current_row)

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

        # Scroll to put current track Config.SCROLL_TOP_MARGIN from the
        # top. Rows number from zero, so set (current_row -
        # Config.SCROLL_TOP_MARGIN + 1) row to be top row

        top_row = max(0, current_row - Config.SCROLL_TOP_MARGIN + 1)
        scroll_item = self.item(top_row, 0)
        self.scrollToItem(scroll_item, QAbstractItemView.PositionAtTop)

        # Set next track
        search_from = current_row + 1
        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 play_stopped(self) -> None:
        """
        Notification from musicmuster that track has ended.

        Actions required:
        - Remove current track marker
        - Reset current track start time
        """

        self._clear_current_track_row()
        self.current_track_start_time = None

    def populate(self, session: Session, playlist_id: int) -> None:
        """
        Populate from the associated playlist ID
        """

        # data: List[Union[Tuple[List[int], Tracks], Tuple[List[int], Notes]]] \
        #     = []
        # item: Union[Notes, Tracks]
        # note: Notes
        # row: int
        # track: Tracks

        # 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)
        for row in playlist.rows:
            self.insert_row(session, row, repaint=False)

        # Scroll to top
        scroll_to: QTableWidgetItem = self.item(0, 0)
        self.scrollToItem(scroll_to, QAbstractItemView.PositionAtTop)

        # 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_selected_rows(self) -> None:
        """Remove selected rows from display"""

        # Remove rows from display. Do so in reverse order so that
        # row numbers remain valid.
        for row in sorted(self._selected_rows(), reverse=True):
            self.removeRow(row)
        # Reset drag mode
        self.setDragEnabled(False)

    def save_playlist(self, session: Session) -> None:
        """
        All playlist rows have a PlaylistRows id. Check that that id points
        to this playlist (in case track has been moved from other) and that
        the row number is correct (in case tracks have been reordered).
        """

        for row in range(self.rowCount()):
            plr = session.get(PlaylistRows, self._get_playlistrow_id(row))
            # Set the row number and playlist id (even if correct)
            plr.row_number = row
            plr.playlist_id = self.playlist_id

        # Any rows in the database with a row_number higher that the
        # current value of 'row' should not be there. Commit session
        # first to ensure any changes made above are committed.
        session.commit()
        PlaylistRows.delete_higher_rows(session, self.playlist_id, row)

#     def save_playlist(self, session) -> None:
#         """
#         Save playlist to database.
#
#         For notes: check the database entry is correct and update it if
#         necessary. Playlists:Note is one:many, so each note may only appear
#         in one playlist.
#
#         For tracks: erase the playlist tracks and recreate. This is much
#         simpler than trying to implement any Playlists:Tracks many:many
#         changes.
#         """
#
#         playlist = Playlists.get_by_id(session, self.playlist_id)
#
#         # Notes first
#         # Create dictionaries indexed by note_id
#         playlist_notes: Dict[int, Notes] = {}
#         database_notes: Dict[int, Notes] = {}
#         notes_rows: List[int] = self._get_notes_rows()
#
#         # PlaylistTab
#         for row in notes_rows:
#             note: Notes = self._get_row_notes_object(row, session)
#             session.add(note)
#             playlist_notes[note.id] = note
#             if row != note.row:
#                 log.debug(f"Updating: {playlist.name=}, {row=}, {note.row=}")
#                 note.update(session=session, row=row)
#
#         # Database
#         for note in playlist.notes:
#             database_notes[note.id] = note
#
#         # We don't need to check for notes to add to the database as
#         # they can't exist in the playlist without being in the database
#         # and pointing at this playlist.
#
#         # Notes to remove from database
#         for note_id in set(database_notes.keys()) - set(playlist_notes.keys()):
#             log.debug(
#                 "_save_playlist(): "
#                 f"Delete {note_id=} from {self=} in database"
#             )
#             database_notes[note_id].delete_note(session)
#
#         # Note rows to update in playlist database
#         for note_id in set(playlist_notes.keys()) & set(database_notes.keys()):
#             if playlist_notes[note_id].row != database_notes[note_id].row:
#                 log.debug(
#                     f"_save_playlist(): Update notes row in database "
#                     f"from {database_notes[note_id]=} "
#                     f"to {playlist_notes[note_id]=}"
#                 )
#                 database_notes[note_id].update(
#                     session, row=playlist_notes[note_id].row)
#
#         # Tracks
#         # Remove all tracks from this playlist
#         playlist.remove_all_tracks(session)
#         # Iterate on-screen playlist and add tracks back in
#         for row in range(self.rowCount()):
#             if row in notes_rows:
#                 continue
#             track_id: int = self.item(
#                 row, FIXUP.COL_USERDATA).data(self.CONTENT_OBJECT)
#             playlist.add_track(session, track_id, row)
#             session.commit()

    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._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_played_tracks(self) -> None:
#         """Select all played tracks in playlist"""
#
#         try:
#             self.selecting_in_progress = True
#             self._select_tracks(played=True)
#         finally:
#             self.selecting_in_progress = False
#             self._select_event()

    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._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 select_unplayed_tracks(self) -> None:
#         """Select all unplayed tracks in playlist"""
#
#         try:
#             self.selecting_in_progress = True
#             self._select_tracks(played=False)
#         finally:
#             self.selecting_in_progress = False
#             self._select_event()
#
#     def set_filter(self, text: Optional[str]) -> None:
#         """Filter rows to only show those containing text"""
#
#         self.row_filter = text
#         with Session() as session:
#             self.update_display(session)

    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 update_display(self, session, clear_selection: bool = True) -> None:
        """
        Set row colours, fonts, etc

        Actions required:
        - Clear selection if 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
        """

        # Clear selection if required
        if clear_selection:
            self.clear_selection()

        current_row: Optional[int] = self._get_current_track_row()
        next_row: Optional[int] = self._get_next_track_row()
        played = [
            p.row_number for p in PlaylistRows.get_played_rows(
                session, self.playlist_id)
        ]
        unreadable: List[int] = self._get_unreadable_track_rows()

        if self.row_filter:
            filter_text = self.row_filter.lower()
        else:
            filter_text = None
        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))
            note_text = playlist_row.note
            # Get note colour
            note_colour = NoteColours.get_colour(session, note_text)
            if not note_colour:
                note_colour = Config.COLOUR_NOTES_PLAYLIST

            # 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 track:
                # 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

                # If filtering, only show matching tracks
                if filter_text:
                    try:
                        if (track.title
                                and filter_text not in track.title.lower()
                                and track.artist
                                and filter_text not in track.artist.lower()):
                            self.hideRow(row)
                            continue
                        else:
                            self.showRow(row)
                    except TypeError:
                        print(f"TypeError: {track=}")
                else:
                    self.showRow(row)

                # Colour any note
                if note_text:
                    (self.item(row, columns['row_notes'].idx)
                     .setBackground(QColor(note_colour)))

                # Render playing track
                if row == current_row:
                    # Set start time
                    self._set_row_start_time(
                        row, self.current_track_start_time)
                    # Set last played time to "Today"
                    self.item(row, columns['lastplayed'].idx).setText("Today")
                    # Calculate next_start_time
                    next_start_time = self._calculate_end_time(
                        self.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
                    if current_row is not None:
                        start_time = self._calculate_end_time(
                            self.current_track_start_time, track.duration)
                    else:
                        # 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
                    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

                # This is a track row other than next or current
                # Reset colour in case it was current/next
                self._set_row_colour(row, None)

                if row in played:
                    # Played today, so update last played column
                    self.item(row, columns['lastplayed'].idx).setText(
                        Config.LAST_PLAYED_TODAY_STRING)
                    if self.musicmuster.hide_played_tracks:
                        self.hideRow(row)
                    else:
                        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)
                        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
            if filter_text:
                if filter_text not in note_text.lower():
                    self.hideRow(row)
                    continue
                else:
                    self.showRow(row)
            else:
                self.showRow(row)
            # 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
            self._set_row_colour(
                row, QColor(note_colour)
            )
            # Section headers are always bold
            self._set_row_bold(row)
            continue

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

        print("playlists._add_track() not yet implemented")

    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 _clear_current_track_row(self) -> None:
        """
        Clear current row if there is one.
        """

        current_row = self._get_current_track_row()

        if current_row is None:
            return

        self._meta_clear_attribute(current_row, RowMeta.CURRENT)

    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
                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):

        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:
                # 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 _clear_played_row_status(self, row: int) -> None:
#         """Clear played status on row"""
#
#         self._meta_clear_attribute(row, RowMeta.PLAYED)

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

        Actions required:
        - Delete the rows from the PlaylistRows table
        - Correct the row numbers in the PlaylistRows table
        - Remove the rows from the display
        """

        # Delete rows from database
        plr_ids = self.get_selected_playlistrow_ids()
        with Session() as session:
            PlaylistRows.delete_rows(session, plr_ids)

            # Fix up row numbers left in this playlist
            PlaylistRows.fixup_rownumbers(session, self.playlist_id)
            # Remove selected rows from display
            self.remove_selected_rows()

    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 _get_notes_rows(self) -> List[int]:
#         """Return rows marked as notes, or None"""
#
#         return self._meta_search(RowMeta.NOTE, one=False)
#

    def _find_next_track_row(self, session: Session,
                             starting_row: 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()):
            if row not in track_rows or row in played_rows:
                continue
            else:
                return row

        return None

    def _get_current_track_row(self) -> Optional[int]:
        """Return row marked as current, or None"""

        row = self._meta_search(RowMeta.CURRENT)
        if len(row) > 0:
            return row[0]
        else:
            return None

    def _get_next_track_row(self) -> Optional[int]:
        """Return row marked as next, or None"""

        row = self._meta_search(RowMeta.NEXT)
        if len(row) > 0:
            return row[0]
        else:
            return None

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

        match = start_time_re.search(text)
        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) -> int:
        """Return the playlistrow_id associated with this row"""

        playlistrow_id = (self.item(row, columns['userdata'].idx)
                          .data(self.PLAYLISTROW_ID))

        return playlistrow_id

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

        duration = (self.item(row, columns['userdata'].idx)
                    .data(self.ROW_DURATION))
        if duration:
            return duration
        else:
            return 0

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

        track_id = (self.item(row, columns['userdata'].idx)
                    .data(self.ROW_TRACK_ID))

        return track_id

#
#     def _get_row_end_time(self, row) -> Optional[datetime]:
#         """
#         Return row end time as string
#         """
#
#         try:
#             if self.item(row, FIXUP.COL_END_TIME):
#                 return datetime.strptime(self.item(
#                     row, FIXUP.COL_END_TIME).text(),
#                     Config.NOTE_TIME_FORMAT
#                 )
#             else:
#                 return None
#         except ValueError:
#             return None
#
#     def _get_row_notes_object(self, row: int, session: Session) \
#             -> Optional[Notes]:
#         """Return note associated with this row"""
#
#         note_id = self.item(row, FIXUP.COL_USERDATA).data(self.CONTENT_OBJECT)
#         note = Notes.get_by_id(session, note_id)
#         return note
#
#     def _get_unplayed_track_rows(self) -> Optional[List[int]]:
#         """Return rows marked as unplayed, or None"""
#
#         unplayed_rows: Set[int] = set(self._meta_notset(RowMeta.PLAYED))
#         notes_rows: Set[int] = set(self._get_notes_rows())
#
#         return list(unplayed_rows - notes_rows)

    def _get_row_start_time(self, row: int) -> Optional[datetime]:
        try:
            if self.item(row, columns['start_time'].idx):
                return datetime.strptime(self.item(
                    row, columns['start_time'].idx).text(),
                    Config.NOTE_TIME_FORMAT
                )
            else:
                return None
        except ValueError:
            return None

    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_row_track_object(self, row: int, session: Session) \
#             -> Optional[Tracks]:
#         """Return track associated with this row"""
#
#         track_id = self.item(row, FIXUP.COL_USERDATA).data(self.CONTENT_OBJECT)
#         track = Tracks.get_by_id(session, track_id)
#         return track
#
#     def _get_track_rows(self) -> List[int]:
#         """Return rows marked as tracks, or None"""
#
#         return self._meta_notset(RowMeta.NOTE)

    def _get_unreadable_track_rows(self) -> List[int]:
        """Return rows marked as unreadable, or None"""

        return self._meta_search(RowMeta.UNREADABLE, one=False)

    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 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 _insert_note(self, session: Session, note: Notes,
#                      row: Optional[int] = None, repaint: bool = True) -> None:
#         """
#         Insert a note to playlist tab.
#
#         If a row is given, add note above. Otherwise, add to end of
#         playlist.
#         """
#
#         if row is None:
#             row = self.rowCount()
#         log.debug(f"playlist.inset_note(): row={row}")
#
#         self.insertRow(row)
#
#         # Add empty items to unused columns because
#         # colour won't be set for columns without items
#         item: QTableWidgetItem = QTableWidgetItem()
#         self.setItem(row, FIXUP.COL_USERDATA, item)
#         item = QTableWidgetItem()
#         self.setItem(row, FIXUP.COL_MSS, item)
#
#         # Add text of note from title column onwards
#         titleitem: QTableWidgetItem = QTableWidgetItem(note.note)
#         self.setItem(row, FIXUP.COL_NOTE, titleitem)
#         self.setSpan(row, FIXUP.COL_NOTE, self.NOTE_ROW_SPAN,
#                      self.NOTE_COL_SPAN)
#
#         # Attach note id to row
#         self._set_row_content(row, note.id)
#
#         # Mark row as a Note row
#         self._set_note_row(row)
#
#         # Scroll to new row
#         self.scrollToItem(titleitem, QAbstractItemView.PositionAtCenter)
#
#         if repaint:
#             self.save_playlist(session)
#             self.update_display(session, clear_selection=False)

    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 _is_note_row(self, row: int) -> bool:
#         """
#         Return True if passed row is a note row, else False
#         """
#
#         if self._meta_get(row):
#             if self._meta_get(row) & (1 << RowMeta.NOTE):
#                 return True
#         return False

    def _meta_clear_attribute(self, row: int, attribute: int) -> None:
        """Clear given metadata for row"""

        if row is None:
            raise ValueError(f"_meta_clear_attribute({row=}, {attribute=})")

        new_metadata: int = self._meta_get(row) & ~(1 << attribute)
        self.item(row, columns['userdata'].idx).setData(
            self.ROW_FLAGS, new_metadata)

    def _meta_clear_next(self) -> None:
        """
            Clear next row if there is one.
            """

        next_row: Optional[int] = self._get_next_track_row()
        if next_row is not None:
            self._meta_clear_attribute(next_row, RowMeta.NEXT)

    def _meta_get(self, row: int) -> int:
        """Return row metadata"""

        return (self.item(row, columns['userdata'].idx)
                .data(self.ROW_FLAGS))
#
#     def _meta_notset(self, metadata: int) -> List[int]:
#         """
#         Search rows for metadata not set.
#
#         Return a list of matching row numbers.
#         """
#
#         matches = []
#         for row in range(self.rowCount()):
#             row_meta = self._meta_get(row)
#             if row_meta is not None:
#                 if not self._meta_get(row) & (1 << metadata):
#                     matches.append(row)
#
#         return matches

    def _meta_search(self, metadata: int, one: bool = True) -> List[int]:
        """
        Search rows for metadata.

        If one is True, check that only one row matches and return
        the row number.

        If one is False, return a list of matching row numbers.
        """

        matches = []
        for row in range(self.rowCount()):
            if self._meta_get(row):
                if self._meta_get(row) & (1 << metadata):
                    matches.append(row)

        if not one:
            return matches

        if len(matches) <= 1:
            return matches
        else:
            log.error(
                f"Multiple matches for metadata '{metadata}' found "
                f"in rows: {', '.join([str(x) for x in matches])}"
            )
            raise AttributeError(f"Multiple '{metadata}' metadata {matches}")

    def _meta_set_attribute(self, row: int, attribute: int) -> None:
        """Set row metadata"""

        if row is None:
            raise ValueError(f"_meta_set_attribute({row=}, {attribute=})")

        current_metadata: int = self._meta_get(row)
        if not current_metadata:
            new_metadata: int = (1 << attribute)
        else:
            new_metadata = self._meta_get(row) | (1 << attribute)
        self.item(row, columns['userdata'].idx).setData(
            self.ROW_FLAGS, new_metadata)

    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

            open_in_audacity(track.path)

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

        # Update playlist_rows record
        with Session() as session:
            plr = session.get(PlaylistRows, self._get_playlistrow_id(row))
            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.item(row, i).setText("")
            # Set note text in correct column for section head
            self.item(row, 1).setText(plr.note)
            # Remove row duration
            self._set_row_duration(row, 0)
            # Remote track_id from row
            self.item(row, columns['userdata'].idx).setData(
                self.ROW_TRACK_ID, 0)
            # Span the rows
            self.setSpan(row, 1, 1, len(columns))
            # 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

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

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

        subprocess.call(args)

    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._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 _selected_rows(self) -> List[int]:
        """Return a list of selected row numbers"""

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

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

        for column_name, data in columns.items():
            idx = data.idx
            attr_name = f"playlist_{column_name}_col_width"
            record: Settings = Settings.get_int_settings(session, attr_name)
            if record and record.f_int is not None:
                self.setColumnWidth(idx, record.f_int)
            else:
                self.setColumnWidth(idx, Config.DEFAULT_COLUMN_WIDTH)

    def _set_current_track_row(self, row: int) -> None:
        """Mark this row as current track"""

        self._clear_current_track_row()
        self._meta_set_attribute(row, RowMeta.CURRENT)

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

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

        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):
            self._set_unreadable_row(row_number)
            return None

        # Mark as next track
        self._set_next_track_row(row_number)

        # Update display
        self.update_display(session)

        # Notify musicmuster
        self.musicmuster.this_is_the_next_track(session, self, track)

    def _set_next_track_row(self, row: int) -> None:
        """Mark this row as next track"""

        self._meta_clear_next()
        self._meta_set_attribute(row, RowMeta.NEXT)

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

        plr = session.get(PlaylistRows, self._get_playlistrow_id(row))
        plr.played = True
        session.commit()

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

        j: int

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

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

        j: int

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

        for j in range(1, self.columnCount()):
            if self.item(row, j):
                self.item(row, j).setBackground(brush)
        #
        #     def _set_row_content(self, row: int, object_id: int) -> None:
        #         """Set content associated with this row"""
        #
        #         assert self.item(row, FIXUP.COL_USERDATA)
        #
        #         self.item(row, FIXUP.COL_USERDATA).setData(
        #             self.CONTENT_OBJECT, object_id)

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

        self.item(row, columns['userdata'].idx).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: str = time.strftime(Config.TRACK_TIME_FORMAT)
        except AttributeError:
            time_str = ""
        item = QTableWidgetItem(time_str)
        self.setItem(row, columns['end_time'].idx, 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: str = time.strftime(Config.TRACK_TIME_FORMAT)
        except AttributeError:
            time_str = ""
        item: QTableWidgetItem = QTableWidgetItem(time_str)
        self.setItem(row, columns['start_time'].idx, item)

    def _set_unreadable_row(self, row: int) -> None:
        """Mark this row as unreadable"""

        self._meta_set_attribute(row, RowMeta.UNREADABLE)
#
#     def _select_tracks(self, played: bool) -> None:
#         """
#         Select all played (played=True) or unplayed (played=False)
#         tracks in playlist
#         """
#
#         # Need to allow multiple rows to be selected
#         self.setSelectionMode(QAbstractItemView.MultiSelection)
#         self.clear_selection()
#
#         if played:
#             rows = self._get_played_track_rows()
#         else:
#             rows = self._get_unplayed_track_rows()
#
#         for row in rows:
#             self.selectRow(row)
#
#         # Reset extended selection
#         self.setSelectionMode(QAbstractItemView.ExtendedSelection)

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

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

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

        # Column to update is either 1 for a section header or the
        # appropriate row_notes column for a track row
        if playlist_row.track_id:
            column = columns['row_notes'].idx
        else:
            column = 1

        # Update text
        new_text = playlist_row.note + additional_text
        self.item(playlist_row.row_number, column).setText(new_text)

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

        columns['start_time'].idx
        item_startgap = self.item(row, columns['start_gap'].idx)
        item_startgap.setText(str(track.start_gap))
        if track.start_gap >= 500:
            item_startgap.setBackground(QColor(Config.COLOUR_LONG_START))
        else:
            item_startgap.setBackground(QColor("white"))

        item_title = self.item(row, columns['title'].idx)
        item_title.setText(track.title)

        item_artist = self.item(row, columns['artist'].idx)
        item_artist.setText(track.artist)

        item_duration = self.item(row, columns['duration'].idx)
        item_duration.setText(ms_to_mmss(track.duration))

        self.update_display(session)
