# Standard library imports
from typing import Any, Callable, cast, Optional, TYPE_CHECKING

# PyQt imports
from PyQt6.QtCore import (
    QAbstractItemModel,
    QEvent,
    QItemSelection,
    QModelIndex,
    QObject,
    QPoint,
    QSize,
    Qt,
    QTimer,
)
from PyQt6.QtGui import QAction, QDropEvent, QKeyEvent, QTextDocument
from PyQt6.QtWidgets import (
    QAbstractItemDelegate,
    QAbstractItemView,
    QApplication,
    QDoubleSpinBox,
    QFrame,
    QMenu,
    QMessageBox,
    QStyledItemDelegate,
    QStyleOptionViewItem,
    QTableView,
    QTableWidgetItem,
    QTextEdit,
    QWidget,
)

# Third party imports
# import line_profiler

# App imports
from audacity_controller import AudacityController
from classes import ApplicationError, Col, MusicMusterSignals, PlaylistStyle, TrackInfo
from config import Config
from dialogs import TrackInsertDialog
from helpers import (
    ask_yes_no,
    ms_to_mmss,
    show_OK,
    show_warning,
)
from log import log, log_call
from models import db, Settings
from playlistrow import TrackSequence
from playlistmodel import PlaylistModel, PlaylistProxyModel

if TYPE_CHECKING:
    from musicmuster import Window


class PlaylistDelegate(QStyledItemDelegate):
    """
    - closes the edit on control-return
    - checks with user before abandoning edit on Escape
    - positions cursor where double-click occurs
    - expands edit box and parent table row as text is added

    Parts inspired by https://stackoverflow.com/questions/69113867/
    make-row-of-qtableview-expand-as-editor-grows-in-height
    """

    class EditorDocument(QTextDocument):
        def __init__(self, parent):
            super().__init__(parent)
            self.setDocumentMargin(0)
            self.contentsChange.connect(self.contents_change)
            self.height = None
            parent.setDocument(self)

        def contents_change(self, position, chars_removed, chars_added):
            def resize_func():
                if self.size().height() != self.height:
                    doc_size = self.size()
                    self.parent().resize(int(doc_size.width()), int(doc_size.height()))

            QTimer.singleShot(0, resize_func)

    def __init__(self, parent: QWidget, base_model: PlaylistModel) -> None:
        super().__init__(parent)
        self.base_model = base_model
        self.signals = MusicMusterSignals()
        self.click_position = None
        self.current_editor: Optional[Any] = None

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

        editor: QDoubleSpinBox | QTextEdit

        class Editor(QTextEdit):
            def resizeEvent(self, event):
                super().resizeEvent(event)
                parent.parent().resizeRowToContents(index.row())

        self.signals = MusicMusterSignals()
        self.signals.enable_escape_signal.emit(False)

        if self.current_editor:
            editor = self.current_editor
        else:
            if index.column() == Col.INTRO.value:
                editor = QDoubleSpinBox(parent)
                editor.setDecimals(1)
                editor.setSingleStep(0.1)
                return editor
            elif isinstance(index.data(), str):
                editor = Editor(parent)
                editor.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
                editor.setFrameShape(QFrame.Shape.NoFrame)
                self.current_editor = editor
        PlaylistDelegate.EditorDocument(editor)
        return editor

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

        super().destroyEditor(editor, index)
        self.current_editor = None
        # Funky mypy dancing:
        parent = self.parent()
        if parent and hasattr(parent, "resizeRowToContents"):
            parent.resizeRowToContents(index.row())
        self.signals.enable_escape_signal.emit(True)

    def editorEvent(
        self,
        event: Optional[QEvent],
        model: Optional[QAbstractItemModel],
        option: QStyleOptionViewItem,
        index: QModelIndex,
    ) -> bool:
        """Capture mouse click position."""

        if event and event.type() == QEvent.Type.MouseButtonPress:
            if hasattr(event, "pos"):
                self.click_position = event.pos()
        return super().editorEvent(event, model, option, index)

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

        if editor is None or event is None:
            return False

        if event.type() == QEvent.Type.Show:
            if self.click_position and isinstance(editor, QTextEdit):
                # Map click position to editor's local space
                local_click_position = editor.mapFromParent(self.click_position)

                # Move cursor to the calculated position
                cursor = editor.cursorForPosition(local_click_position)
                editor.setTextCursor(cursor)

                # Reset click position
                self.click_position = None

            return False

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

            elif key == Qt.Key.Key_Escape:
                # Close editor if no changes have been made
                data_modified = False
                if isinstance(editor, QTextEdit):
                    data_modified = (
                        self.original_model_data != editor.toPlainText()
                    )
                elif isinstance(editor, QDoubleSpinBox):
                    data_modified = (
                        self.original_model_data != int(editor.value()) * 1000
                    )
                if not data_modified:
                    self.closeEditor.emit(editor)
                    return True

                discard_edits = QMessageBox.question(
                    cast(QWidget, self.parent()), "Abandon edit", "Discard changes?"
                )
                if discard_edits == QMessageBox.StandardButton.Yes:
                    self.closeEditor.emit(editor)
                    return True

        return False

    def sizeHint(self, option, index):
        self.initStyleOption(option, index)
        if self.current_editor:
            doc = self.current_editor.document()
        else:
            doc = QTextDocument()
            doc.setTextWidth(option.rect.width())
            doc.setDefaultFont(option.font)
            doc.setDocumentMargin(Config.ROW_PADDING)
            if "\n" in option.text:
                txt = option.text.replace("\n", "<br>")
            elif "\u2028" in option.text:
                txt = option.text.replace("\u2028", "<br>")
            else:
                txt = option.text
            doc.setHtml(txt)

        # For debugging +++
        # Calculate sizes
        # document_size = doc.documentLayout().documentSize()
        # ideal_width = doc.idealWidth()
        # height = document_size.height()
        # rect_width = option.rect.width()
        # text = option.text

        # # Debug output
        # print(f"Index: {index.row()}, {index.column()}")
        # print(f"Text: {text}")
        # print(f"Option.rect width: {rect_width}")
        # print(f"Document idealWidth: {ideal_width}")
        # print(f"Document height: {height}")
        # print(f"---")
        # --- For debugging

        return QSize(int(doc.idealWidth()), int(doc.size().height()))

    def setEditorData(self, editor, index):
        proxy_model = index.model()
        edit_index = proxy_model.mapToSource(index)

        self.original_model_data = self.base_model.data(
            edit_index, Qt.ItemDataRole.EditRole
        )
        if index.column() == Col.INTRO.value:
            if self.original_model_data:
                editor.setValue(self.original_model_data / 1000)
        else:
            editor.setPlainText(self.original_model_data)

    def setModelData(self, editor, model, index):
        proxy_model = index.model()
        edit_index = proxy_model.mapToSource(index)

        if isinstance(editor, QTextEdit):
            value = editor.toPlainText().strip()
        elif isinstance(editor, QDoubleSpinBox):
            value = editor.value()
        self.base_model.setData(edit_index, value, Qt.ItemDataRole.EditRole)

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


class PlaylistTab(QTableView):
    """
    The playlist view
    """

    def __init__(self, musicmuster: "Window", model: PlaylistProxyModel) -> None:
        super().__init__()

        # Save passed settings
        self.musicmuster = musicmuster

        self.playlist_id = model.sourceModel().playlist_id
        self.track_sequence = TrackSequence()

        # Set up widget
        self.setItemDelegate(PlaylistDelegate(self, model.sourceModel()))
        self.setAlternatingRowColors(True)
        self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
        self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
        self.setDragDropOverwriteMode(False)
        self.setAcceptDrops(True)

        # Set our custom style - this draws the drop indicator across the whole row
        self.setStyle(PlaylistStyle())

        # We will enable dragging when rows are selected. Disabling it
        # here means we can click and drag to select rows.
        self.setDragEnabled(False)

        # Prepare for context menu
        self.menu = QMenu()
        self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.customContextMenuRequested.connect(self._context_menu)

        # Connect signals
        self.signals = MusicMusterSignals()
        self.signals.resize_rows_signal.connect(self.resize_rows)
        self.signals.span_cells_signal.connect(self._span_cells)

        # Selection model
        self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
        self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)

        # Set up for Audacity
        try:
            self.ac: Optional[AudacityController] = AudacityController()
        except ApplicationError as e:
            self.ac = None
            show_warning(self.musicmuster, "Audacity error", str(e))

        # Load model, set column widths
        self.setModel(model)
        self._set_column_widths()

        # Stretch last column *after* setting column widths which is
        # *much* faster
        h_header = self.horizontalHeader()
        if h_header:
            h_header.sectionResized.connect(self._column_resize)
            h_header.setStretchLastSection(True)
        # Resize on vertical header click
        v_header = self.verticalHeader()
        if v_header:
            v_header.setMinimumSectionSize(5)
            v_header.sectionHandleDoubleClicked.disconnect()
            v_header.sectionHandleDoubleClicked.connect(self.resizeRowToContents)

        # Setting ResizeToContents causes screen flash on load
        self.resize_rows()

    # ########## Overridden class functions ##########

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

        self.musicmuster.enable_escape(True)

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

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

        # Update start times in case a start time in a note has been
        # edited
        self.get_base_model().update_track_times()

        # Deselect edited line
        self.clear_selection()

    def dropEvent(self, event: Optional[QDropEvent], dummy: int | None = None) -> None:
        """
        Move dropped rows
        """

        if not event:
            return

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

        from_rows = self.selected_model_row_numbers()
        to_index = self.indexAt(event.position().toPoint())

        # The drop indicator can either be immediately below a row or
        # immediately above a row. There's about a 1 pixel difference,
        # but we always want to drop between rows regardless of where
        # drop indicator is.
        if (
            self.dropIndicatorPosition()
            == QAbstractItemView.DropIndicatorPosition.BelowItem
        ):
            # Drop on the row below
            next_row = to_index.row() + 1
            if next_row < self.model().rowCount():  # Ensure the row exists
                destination_index = to_index.siblingAtRow(next_row)
            else:
                # Handle edge case where next_row is beyond the last row
                destination_index = to_index
        else:
            destination_index = to_index

        to_model_row = self.model().mapToSource(destination_index).row()
        log.debug(
            f"PlaylistTab.dropEvent(): {from_rows=}, {destination_index=}, {to_model_row=}"
        )

        # Sanity check
        base_model_row_count = self.get_base_model().rowCount()
        if (
            0 <= min(from_rows) <= base_model_row_count
            and 0 <= to_model_row <= base_model_row_count
        ):
            # If we move a row to immediately under the current track, make
            # that moved row the next track
            set_next_row: Optional[int] = None
            if (
                self.track_sequence.current
                and to_model_row == self.track_sequence.current.row_number + 1
            ):
                set_next_row = to_model_row

            self.get_base_model().move_rows(from_rows, to_model_row)

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

            # Deselect rows
            self.clear_selection()

            # Resize rows
            self.resize_rows()

            # Set next row if we are immediately under current row
            if set_next_row:
                self.get_base_model().set_next_row(set_next_row)

            event.accept()

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

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

    def resizeRowToContents(self, row):
        super().resizeRowToContents(row)
        self.verticalHeader().resizeSection(row, self.sizeHintForRow(row))

    def resizeRowsToContents(self):
        header = self.verticalHeader()
        for row in range(self.model().rowCount()):
            hint = self.sizeHintForRow(row)
            header.resizeSection(row, hint)

    def selectionChanged(
        self, selected: QItemSelection, deselected: QItemSelection
    ) -> None:
        """
        Tell model which rows are selected.

        Toggle drag behaviour according to whether rows are selected
        """

        selected_row_numbers = self.get_selected_rows()

        # Signal selected rows to model
        self.signals.signal_playlist_selected_rows.emit(self.playlist_id, selected_row_numbers)

        # Put sum of selected tracks' duration in status bar
        # If no rows are selected, we have nothing to do
        if len(selected_row_numbers) == 0:
            self.musicmuster.lblSumPlaytime.setText("")
        else:
            if not self.musicmuster.disable_selection_timing:
                selected_duration = self.get_base_model().get_rows_duration(
                    self.get_selected_rows()
                )
                if selected_duration > 0:
                    self.musicmuster.lblSumPlaytime.setText(
                        f"Selected duration: {ms_to_mmss(selected_duration)}"
                    )
                else:
                    self.musicmuster.lblSumPlaytime.setText("")
            else:
                log.debug(
                    f"playlists.py.selectionChanged: {self.musicmuster.disable_selection_timing=}"
                )

        super().selectionChanged(selected, deselected)

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

        if parent_menu is None:
            parent_menu = self.menu

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

        return menu_item

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

        model_row_number = self.source_model_selected_row_number()
        if model_row_number is None:
            return

        with db.Session() as session:
            dlg = TrackInsertDialog(
                parent=self.musicmuster,
                playlist_id=self.playlist_id,
                add_to_header=True,
            )
            dlg.exec()
            session.commit()

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

        self.menu.clear()

        index = self.model().index(item.row(), item.column())
        model_row_number = self.model().mapToSource(index).row()
        base_model = self.get_base_model()

        header_row = self.get_base_model().is_header_row(model_row_number)
        track_row = not header_row
        if self.track_sequence.current:
            this_is_current_row = model_row_number == self.track_sequence.current.row_number
        else:
            this_is_current_row = False
        if self.track_sequence.next:
            this_is_next_row = model_row_number == self.track_sequence.next.row_number
        else:
            this_is_next_row = False
        track_path = base_model.get_row_info(model_row_number).path

        # Open/import in/from Audacity
        if track_row and not this_is_current_row:
            if self.ac and track_path == self.ac.path:
                # This track was opened in Audacity
                self._add_context_menu(
                    "Update from Audacity",
                    lambda: self._import_from_audacity(model_row_number),
                )
                self._add_context_menu(
                    "Cancel Audacity",
                    lambda: self._cancel_audacity(),
                )
            else:
                self._add_context_menu(
                    "Open in Audacity", lambda: self._open_in_audacity(model_row_number)
                )

        # Rescan
        if track_row and not this_is_current_row:
            self._add_context_menu(
                "Rescan track", lambda: self._rescan(model_row_number)
            )
            self._add_context_menu("Mark for moving", lambda: self._mark_for_moving())
            if self.musicmuster.move_source_rows:
                self._add_context_menu(
                    "Move selected rows here", lambda: self._move_selected_rows()
                )

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

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

        # Remove track from row
        if track_row and not this_is_current_row and not this_is_next_row:
            self._add_context_menu(
                "Remove track from row",
                lambda: base_model.remove_track(model_row_number),
            )

        # Remove comments
        self._add_context_menu("Remove comments", lambda: self._remove_comments())

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

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

        # Mark unplayed
        if track_row and base_model.is_played_row(model_row_number):
            self._add_context_menu(
                "Mark unplayed",
                lambda: self._mark_as_unplayed(self.get_selected_rows()),
            )

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

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

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

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

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

    def _cancel_audacity(self) -> None:
        """
        Cancel Audacity editing. We don't do anything with Audacity, just "forget"
        that we have an edit open.
        """

        if self.ac:
            self.ac.path = None

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

        self.clearSelection()
        # We want to remove the focus from any widget otherwise keyboard
        # activity may edit a cell.
        fw = self.musicmuster.focusWidget()
        if fw:
            fw.clearFocus()
        self.setDragEnabled(False)

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

        log.debug(f"_column_resize({column_number=}, {_old=}, {_new=}")

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

        # Resize rows if necessary
        self.resizeRowsToContents()

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

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

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

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

        track_path = self.get_base_model().get_row_info(row_number).path
        if not track_path:
            return

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

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

    def current_track_started(self) -> None:
        """
        Called when track starts playing
        """

        self.get_base_model().current_track_started()
        # Scroll to current section if hide mode is by section
        if (
            self.musicmuster.hide_played_tracks
            and Config.HIDE_PLAYED_MODE == Config.HIDE_PLAYED_MODE_SECTIONS
        ):
            # Hide section after delay
            QTimer.singleShot(
                Config.HIDE_AFTER_PLAYING_OFFSET + 100,
                lambda: self.hide_played_sections(),
            )

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

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

        rows_to_delete = self.get_selected_rows()
        log.debug(f"_delete_rows({rows_to_delete=}")
        row_count = len(rows_to_delete)
        if row_count < 1:
            return

        # Don't delete current or next tracks
        selected_row_numbers = self.selected_model_row_numbers()
        for ts in [
            self.track_sequence.next,
            self.track_sequence.current,
        ]:
            if ts:
                if (
                    ts.playlist_id == self.playlist_id
                    and ts.row_number in selected_row_numbers
                ):
                    self.musicmuster.show_warning(
                        "Delete not allowed", "Can't delete current or next track"
                    )
                    return

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

        base_model = self.get_base_model()
        base_model.delete_rows(selected_row_numbers)
        self.clear_selection()

    def get_base_model(self) -> PlaylistModel:
        """
        Return the base model for this proxy model
        """

        return cast(PlaylistModel, self.model().sourceModel())

    def get_selected_row_track_info(self) -> Optional[TrackInfo]:
        """
        Return the track_id and row number of the selected
        row. If no row selected or selected row does not have a track,
        return None.
        """

        selected_row = self.get_selected_row()
        if selected_row is None:
            return None

        base_model = self.get_base_model()
        model_row_number = self.source_model_selected_row_number()

        if model_row_number is None:
            return None
        else:
            track_id = base_model.get_row_track_id(model_row_number)
            if not track_id:
                return None
            else:
                return TrackInfo(track_id, selected_row)

    def get_selected_row(self) -> Optional[int]:
        """
        Return selected row number. If no rows or multiple rows selected, return None
        """

        selected = self.get_selected_rows()
        if len(selected) == 1:
            return selected[0]
        else:
            return None

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

        # Use a set to deduplicate result (a selected row will have all
        # items in that row selected)
        selected_indexes = self.selectedIndexes()

        if not selected_indexes:
            return []

        return sorted(list(set([self.model().mapToSource(a).row() for a in selected_indexes])))

    def get_top_visible_row(self) -> int:
        """
        Get the viewport of the table view
        """

        index = self.indexAt(QPoint(0, 0))

        if index.isValid():
            return index.row()
        else:
            # If no index is found, it means the table might be empty or scrolled beyond content
            return -1

    def hide_played_sections(self) -> None:
        """
        Scroll played sections off screen, but only if current top row is above
        the active header
        """

        active_header_row = self.get_base_model().active_section_header()
        if self.get_top_visible_row() < active_header_row:
            self.scroll_to_top(active_header_row)

    def _import_from_audacity(self, row_number: int) -> None:
        """
        Import current Audacity track to passed row
        """

        if not self.ac:
            return
        try:
            self.ac.export()
            self._rescan(row_number)
        except ApplicationError as e:
            show_warning(self.musicmuster, "Audacity error", str(e))
            self._cancel_audacity()

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

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

        show_OK("Track info", txt, self.musicmuster)

    def _mark_as_unplayed(self, row_numbers: list[int]) -> None:
        """Mark row as unplayed"""

        self.get_base_model().mark_unplayed(row_numbers)
        self.clear_selection()

    def _mark_for_moving(self) -> None:
        """
        Mark selected rows for pasting
        """

        self.musicmuster.mark_rows_for_moving()

    def model(self) -> PlaylistProxyModel:
        """
        Override return type to keep mypy happy in this module
        """

        return cast(PlaylistProxyModel, super().model())

    def _move_selected_rows(self) -> None:
        """
        Move selected rows here
        """

        self.musicmuster.paste_rows()

    def _open_in_audacity(self, row_number: int) -> None:
        """
        Open track in passed row in Audacity
        """

        path = self.get_base_model().get_row_track_path(row_number)
        if not path:
            log.error(f"_open_in_audacity: can't get path for {row_number=}")
            return

        try:
            if not self.ac:
                self.ac = AudacityController()
            self.ac.open(path)
        except ApplicationError as e:
            show_warning(self.musicmuster, "Audacity error", str(e))

    def _remove_comments(self) -> None:
        """
        Remove comments from selected rows
        """

        row_numbers = self.selected_model_row_numbers()
        if not row_numbers:
            return

        self.get_base_model().remove_comments(row_numbers)

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

        self.get_base_model().rescan_track(row_number)
        self.clear_selection()

    def resize_rows(self, playlist_id: Optional[int] = None) -> None:
        """
        If playlist_id is us, resize rows
        """

        log.debug(f"resize_rows({playlist_id=}) {self.playlist_id=}")

        if playlist_id and playlist_id != self.playlist_id:
            return

        # Suggestion from phind.com
        def resize_row(row, count=1):
            row_count = self.model().rowCount()
            for todo in range(count):
                if row < row_count:
                    self.resizeRowToContents(row)
                    row += 1
            if row < row_count:
                QTimer.singleShot(0, lambda: resize_row(row, count))

        # Start resizing from row 0, 10 rows at a time
        QTimer.singleShot(0, lambda: resize_row(0, Config.RESIZE_ROW_CHUNK_SIZE))

    def scroll_to_top(self, row_number: int) -> None:
        """
        Scroll to put passed row_number at the top of the displayed playlist.
        """

        if row_number is None:
            return

        row_index = self.model().index(row_number, 0)
        self.scrollTo(row_index, QAbstractItemView.ScrollHint.PositionAtTop)

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

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

    def source_model_selected_row_number(self) -> Optional[int]:
        """
        Return the model row number corresponding to the selected row or None
        """

        selected_index = self._selected_row_index()
        if selected_index is None:
            return None
        return self.model().mapToSource(selected_index).row()

    def selected_model_row_numbers(self) -> list[int]:
        """
        Return a list of model row numbers corresponding to the selected rows or
        an empty list.
        """

        selected_indexes = self._selected_row_indexes()
        if selected_indexes is None:
            return []

        return [self.model().mapToSource(a).row() for a in selected_indexes]

    def _selected_row_index(self) -> Optional[QModelIndex]:
        """
        Return the selected row index or None if none selected.
        """

        row_indexes = self._selected_row_indexes()

        if len(row_indexes) > 1:
            show_warning(
                self.musicmuster, "Multiple rows selected", "Select only one row"
            )
            return None
        elif not row_indexes:
            return None

        return row_indexes[0]

    def _selected_row_indexes(self) -> list[QModelIndex]:
        """
        Return a list of indexes of column 0 of selected rows
        """

        sm = self.selectionModel()
        if sm and sm.hasSelection():
            return sm.selectedRows()
        return []

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

        log.debug("_set_column_widths()")

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

        # Last column is set to stretch so ignore it here
        with db.Session() as session:
            for column_number in range(header.count() - 1):
                attr_name = f"playlist_col_{column_number}_width"
                record = Settings.get_setting(session, attr_name)
                if record.f_int is not None:
                    self.setColumnWidth(column_number, record.f_int)
                else:
                    self.setColumnWidth(column_number, Config.DEFAULT_COLUMN_WIDTH)

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

        model_row_number = self.source_model_selected_row_number()
        log.debug(f"set_row_as_next_track() {model_row_number=}")
        if model_row_number is None:
            return
        self.get_base_model().set_next_row(model_row_number)
        self.clearSelection()

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

        row and column are from the base model so we need to translate
        the row into this display row
        """

        if playlist_id != self.playlist_id:
            return

        base_model = self.get_base_model()

        cell_index = self.model().mapFromSource(base_model.createIndex(row, column))
        row = cell_index.row()
        column = cell_index.column()

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

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

    def tab_live(self) -> None:
        """
        Called when tab gets focus
        """

        # Update musicmuster
        self.musicmuster.current.playlist_id = self.playlist_id
        self.musicmuster.current.selected_row_numbers = self.get_selected_rows()
        self.musicmuster.current.base_model = self.get_base_model()
        self.musicmuster.current.proxy_model = self.model()

        self.resize_rows()

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

        self.track_sequence.set_next(None)
        self.clear_selection()
        self.signals.next_track_changed_signal.emit()
