"""PySide6 desktop application for cascade."""

from __future__ import annotations

import os
import sys
from concurrent.futures import Future, ThreadPoolExecutor
from fnmatch import fnmatch
from pathlib import Path

from PySide6.QtCore import Qt, QTimer, QUrl, Signal
from PySide6.QtGui import QCloseEvent, QColor, QDesktopServices, QPalette
from PySide6.QtWidgets import (
    QAbstractItemView,
    QApplication,
    QCheckBox,
    QComboBox,
    QFileDialog,
    QFrame,
    QHBoxLayout,
    QLabel,
    QLineEdit,
    QListWidget,
    QListWidgetItem,
    QMainWindow,
    QMenu,
    QMessageBox,
    QProgressBar,
    QPushButton,
    QScrollArea,
    QSizePolicy,
    QVBoxLayout,
    QWidget,
)

from cascade_fm.core.api import CascadeAPI
from cascade_fm.core.filetypes import classify_file_type, detect_mime_type, summarize_file_types
from cascade_fm.core.pipeline import Pipeline
from cascade_fm.core.system_open import open_in_default_app
from cascade_fm.core.temp_session import cleanup_temp_session, init_temp_session
from cascade_fm.core.types import PaneStatus
from cascade_fm.operations.registry import (
    get_operation_group_label,
    get_operation_info,
    list_operations_for_ui,
)

# ── Colors ────────────────────────────────────────────────────────────────────

_C_BG = QColor(42, 42, 42)
_C_BG_DARK = QColor(26, 26, 26)
_C_BG_PANE = QColor(34, 34, 34)
_C_BASE = QColor(30, 30, 30)
_C_TEXT = QColor(229, 229, 229)
_C_TEXT_DIM = QColor(136, 136, 136)
_C_ACCENT = QColor(48, 112, 160)
_C_SUCCESS = QColor(50, 170, 80)
_C_ERROR = QColor(210, 60, 50)
_C_WARNING = QColor(200, 160, 30)
_C_BUTTON = QColor(55, 55, 55)
_C_SIDEBAR = QColor(24, 24, 24)
_DEFAULT_RESTORE_MAX_AGE_SECONDS = 24 * 60 * 60

# ── Helpers ───────────────────────────────────────────────────────────────────


def _icon_for_entry(entry: Path) -> str:
    """Return an emoji icon for a path based on its detected MIME type."""
    file_type = classify_file_type(detect_mime_type(entry))
    if file_type == "directory":
        return "📁"
    if file_type == "image":
        return "🖼️"
    if file_type == "video":
        return "🎬"
    if file_type == "audio":
        return "🎵"
    if file_type in {"text", "document"}:
        return "📄"
    return "📦" if entry.suffix.lower() in {".zip", ".tar", ".gz", ".rar"} else "📄"


def _group_operations(operations: list[str]) -> dict[str, list[str]]:
    """Group operation names by their MIME-type category label."""
    grouped: dict[str, list[str]] = {}
    for op_name in operations:
        group = get_operation_group_label(op_name)
        grouped.setdefault(group, []).append(op_name)
    return dict(sorted(grouped.items()))


def _make_dark_palette() -> QPalette:
    """Build a dark QPalette for the Fusion style."""
    p = QPalette()
    p.setColor(QPalette.ColorRole.Window, _C_BG)
    p.setColor(QPalette.ColorRole.WindowText, _C_TEXT)
    p.setColor(QPalette.ColorRole.Base, _C_BASE)
    p.setColor(QPalette.ColorRole.AlternateBase, QColor(50, 50, 50))
    p.setColor(QPalette.ColorRole.Text, _C_TEXT)
    p.setColor(QPalette.ColorRole.BrightText, _C_TEXT)
    p.setColor(QPalette.ColorRole.Button, _C_BUTTON)
    p.setColor(QPalette.ColorRole.ButtonText, _C_TEXT)
    p.setColor(QPalette.ColorRole.Highlight, _C_ACCENT)
    p.setColor(QPalette.ColorRole.HighlightedText, QColor(255, 255, 255))
    p.setColor(QPalette.ColorRole.Link, QColor(100, 160, 220))
    p.setColor(QPalette.ColorRole.ToolTipBase, _C_BG_DARK)
    p.setColor(QPalette.ColorRole.ToolTipText, _C_TEXT)
    # Disabled state
    p.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.WindowText, _C_TEXT_DIM)
    p.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text, _C_TEXT_DIM)
    p.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText, _C_TEXT_DIM)
    return p


# ── OperationBar ──────────────────────────────────────────────────────────────


class OperationBar(QWidget):
    """Narrow vertical bar with operation buttons between panes."""

    def __init__(self, bar_index: int, on_operation_selected, parent=None) -> None:
        """Initialize operation bar.

        Args:
            bar_index: Position index of this bar in the pipeline.
            on_operation_selected: Callback(bar_index, operation_name).
        """
        super().__init__(parent)
        self.bar_index = bar_index
        self._on_operation_selected = on_operation_selected
        self._more_btn: QPushButton | None = None

        self.setFixedWidth(115)
        self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding)
        self.setAutoFillBackground(True)
        pal = self.palette()
        pal.setColor(QPalette.ColorRole.Window, QColor(32, 32, 32))
        self.setPalette(pal)

        self._outer = QVBoxLayout(self)
        self._outer.setContentsMargins(4, 8, 4, 8)
        self._outer.setSpacing(4)

        hdr = QLabel("Operation")
        hdr.setAlignment(Qt.AlignmentFlag.AlignCenter)
        hdr.setStyleSheet(f"color: {_C_TEXT_DIM.name()}; font-size: 10px; font-weight: bold;")
        self._outer.addWidget(hdr)

        # Scrollable area for buttons
        scroll = QScrollArea()
        scroll.setWidgetResizable(True)
        scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
        scroll.setFrameShape(QFrame.Shape.NoFrame)
        self._btn_widget = QWidget()
        self._btn_layout = QVBoxLayout(self._btn_widget)
        self._btn_layout.setContentsMargins(0, 0, 0, 0)
        self._btn_layout.setSpacing(3)
        scroll.setWidget(self._btn_widget)
        self._outer.addWidget(scroll)

    def update_operations(self, operation_names: list[str]) -> None:
        """Rebuild the operation button list."""
        while self._btn_layout.count():
            item = self._btn_layout.takeAt(0)
            if item is None:
                continue
            widget = item.widget()
            if widget is not None:
                widget.deleteLater()

        grouped = _group_operations(operation_names)
        for group_name, ops in grouped.items():
            lbl = QLabel(group_name)
            lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
            lbl.setWordWrap(True)
            lbl.setStyleSheet(f"color: {_C_TEXT_DIM.name()}; font-size: 10px;")
            self._btn_layout.addWidget(lbl)
            for op_name in ops:
                self._add_operation_button(op_name)

        if not operation_names:
            lbl = QLabel("No matching\noperations")
            lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
            lbl.setStyleSheet(f"color: {_C_TEXT_DIM.name()};")
            self._btn_layout.addWidget(lbl)

        self._btn_layout.addStretch()

    def _add_operation_button(self, op_name: str) -> None:
        """Add a single operation button to the button list."""
        display = op_name.replace("_", " ").title()
        btn = QPushButton(display)
        btn.setFixedHeight(40)
        btn.setStyleSheet(
            f"QPushButton {{ background-color: {_C_ACCENT.darker(120).name()}; "
            f"border: 1px solid {_C_ACCENT.name()}; border-radius: 3px; "
            f"color: {_C_TEXT.name()}; font-size: 10px; }}"
            f"QPushButton:hover {{ background-color: {_C_ACCENT.name()}; }}"
        )
        btn.clicked.connect(
            lambda _checked, op=op_name: self._on_operation_selected(self.bar_index, op)
        )
        self._btn_layout.addWidget(btn)

    def set_hidden_operations_button(self, hidden_operations: list[str]) -> None:
        """Show or remove the 'More…' overflow button."""
        if self._more_btn is not None:
            self._outer.removeWidget(self._more_btn)
            self._more_btn.deleteLater()
            self._more_btn = None

        if not hidden_operations:
            return

        btn = QPushButton("More…")
        btn.setFixedHeight(30)
        btn.clicked.connect(lambda: self._show_more_menu(hidden_operations, btn))
        self._more_btn = btn
        self._outer.addWidget(btn)

    def _show_more_menu(self, hidden_operations: list[str], anchor: QWidget) -> None:
        """Show a popup menu listing hidden (incompatible) operations."""
        menu = QMenu(self)
        for group_name, ops in _group_operations(hidden_operations).items():
            menu.addSection(group_name)
            for op_name in ops:
                display = op_name.replace("_", " ").title()
                action = menu.addAction(display)
                action.triggered.connect(
                    lambda _checked, op=op_name: self._on_operation_selected(self.bar_index, op)
                )
        menu.exec(anchor.mapToGlobal(anchor.rect().bottomLeft()))


# ── BrowserPane ───────────────────────────────────────────────────────────────


class BrowserPane(QFrame):
    """File-system browser pane (always the first pane in the pipeline UI)."""

    def __init__(self, on_selection_changed, parent=None) -> None:
        """Initialize browser pane.

        Args:
            on_selection_changed: Callback(list[Path]) called when selection changes.
        """
        super().__init__(parent)
        self._on_selection_changed = on_selection_changed
        self._current_path: Path = Path.home()
        self._entries: list[Path] = []
        self._show_hidden: bool = False
        self._history: list[Path] = []
        self._history_index: int = -1
        self._name_filter: str = ""
        self._type_filter: str = "all"

        self.setMinimumWidth(320)
        self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
        self.setAutoFillBackground(True)
        pal = self.palette()
        pal.setColor(QPalette.ColorRole.Window, _C_BG_PANE)
        self.setPalette(pal)

        layout = QVBoxLayout(self)
        layout.setContentsMargins(6, 6, 6, 6)
        layout.setSpacing(4)

        hdr = QLabel("Browse Files")
        hdr.setStyleSheet(f"color: {_C_TEXT.name()}; font-weight: bold; font-size: 13px;")
        layout.addWidget(hdr)

        sep = QFrame()
        sep.setFrameShape(QFrame.Shape.HLine)
        sep.setFixedHeight(2)
        sep.setStyleSheet(f"background-color: {_C_ACCENT.name()}; border: none;")
        layout.addWidget(sep)

        # Path bar
        path_row = QHBoxLayout()
        self._path_input = QLineEdit(str(Path.home()))
        self._path_input.returnPressed.connect(self._on_path_entered)
        path_row.addWidget(self._path_input)
        home_btn = QPushButton("🏠")
        home_btn.setFixedWidth(36)
        home_btn.setToolTip("Go to home directory")
        home_btn.clicked.connect(lambda: self.navigate(Path.home()))
        path_row.addWidget(home_btn)
        layout.addLayout(path_row)

        # Toolbar row
        toolbar = QHBoxLayout()
        self._back_btn = QPushButton("←")
        self._back_btn.setFixedWidth(36)
        self._back_btn.setToolTip("Back")
        self._back_btn.clicked.connect(self._go_back)
        toolbar.addWidget(self._back_btn)

        self._forward_btn = QPushButton("→")
        self._forward_btn.setFixedWidth(36)
        self._forward_btn.setToolTip("Forward")
        self._forward_btn.clicked.connect(self._go_forward)
        toolbar.addWidget(self._forward_btn)

        up_btn = QPushButton("↑")
        up_btn.setFixedWidth(36)
        up_btn.setToolTip("Up one folder")
        up_btn.clicked.connect(self._go_up)
        toolbar.addWidget(up_btn)

        select_all_btn = QPushButton("Select All")
        select_all_btn.clicked.connect(self._select_all)
        toolbar.addWidget(select_all_btn)

        toolbar.addStretch()
        layout.addLayout(toolbar)

        filter_row = QHBoxLayout()
        self._name_filter_input = QLineEdit()
        self._name_filter_input.setPlaceholderText("Filter name (glob, e.g. *.jpg)")
        self._name_filter_input.textChanged.connect(self._on_filter_changed)
        filter_row.addWidget(self._name_filter_input)

        self._type_filter_combo = QComboBox()
        self._type_filter_combo.setFixedWidth(180)
        self._type_filter_combo.addItem("Type: All", "all")
        self._type_filter_combo.addItem("Type: Directory", "directory")
        self._type_filter_combo.addItem("Type: Image", "image")
        self._type_filter_combo.addItem("Type: Video", "video")
        self._type_filter_combo.addItem("Type: Audio", "audio")
        self._type_filter_combo.addItem("Type: Text", "text")
        self._type_filter_combo.addItem("Type: Document", "document")
        self._type_filter_combo.addItem("Type: Other", "other")
        self._type_filter_combo.currentIndexChanged.connect(self._on_filter_changed)
        filter_row.addWidget(self._type_filter_combo)
        layout.addLayout(filter_row)

        # Status
        self._status = QLabel("Ready")
        self._status.setStyleSheet(f"color: {_C_TEXT_DIM.name()}; font-size: 11px;")
        layout.addWidget(self._status)

        # File list — native multi-select, double-click to navigate
        self._list = QListWidget()
        self._list.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
        self._list.setAlternatingRowColors(True)
        self._list.itemDoubleClicked.connect(self._on_double_clicked)
        self._list.itemSelectionChanged.connect(self._on_selection_changed_internal)
        layout.addWidget(self._list)

        self._count = QLabel("0 selected")
        self._count.setStyleSheet(f"color: {_C_TEXT_DIM.name()}; font-size: 10px;")
        layout.addWidget(self._count)

        self.navigate(Path.home(), add_to_history=True)

    def navigate(self, path: Path, add_to_history: bool = True) -> None:
        """Navigate to a directory, populating the file list.

        Args:
            path: Directory path to navigate to.
        """
        target = path.expanduser()
        if not target.exists() or not target.is_dir():
            self._set_status(f"Invalid path: {path}", _C_ERROR)
            return

        self._current_path = target
        self._path_input.setText(str(target))

        self._entries = sorted(
            target.iterdir(),
            key=lambda p: (not p.is_dir(), p.name.lower()),
        )
        if not self._show_hidden:
            self._entries = [entry for entry in self._entries if not entry.name.startswith(".")]
        self._entries = self._apply_filters(self._entries)

        if add_to_history:
            if self._history_index < len(self._history) - 1:
                self._history = self._history[: self._history_index + 1]
            if not self._history or self._history[-1] != target:
                self._history.append(target)
                self._history_index = len(self._history) - 1

        self._list.blockSignals(True)
        self._list.clear()
        for entry in self._entries:
            icon = _icon_for_entry(entry)
            name = f"{entry.name}/" if entry.is_dir() else entry.name
            item = QListWidgetItem(f"{icon}  {name}")
            item.setData(Qt.ItemDataRole.UserRole, entry)
            self._list.addItem(item)
        self._list.blockSignals(False)

        self._set_status("Ready", _C_TEXT_DIM)
        self._count.setText("0 selected")
        self._update_nav_buttons()

    def current_path(self) -> Path:
        """Return the currently displayed directory path."""
        return self._current_path

    def selected_files(self) -> list[Path]:
        """Return currently selected entries in the browser list."""
        return [item.data(Qt.ItemDataRole.UserRole) for item in self._list.selectedItems()]

    def select_files(self, files: list[Path]) -> None:
        """Select specific entries in the current list by absolute path."""
        wanted: set[str] = set()
        for path in files:
            wanted.add(str(path))
            try:
                wanted.add(str(path.resolve()))
            except OSError:
                pass

        self._list.blockSignals(True)
        self._list.clearSelection()
        for index in range(self._list.count()):
            item = self._list.item(index)
            path = item.data(Qt.ItemDataRole.UserRole)
            if not isinstance(path, Path):
                continue
            candidates = {str(path)}
            try:
                candidates.add(str(path.resolve()))
            except OSError:
                pass
            if candidates & wanted:
                item.setSelected(True)
        self._list.blockSignals(False)
        self._on_selection_changed_internal()

    def _on_path_entered(self) -> None:
        """Handle manual path entry."""
        path = Path(self._path_input.text()).expanduser()
        if path.exists() and path.is_dir():
            self.navigate(path, add_to_history=True)
        else:
            self._path_input.setText(str(self._current_path))
            self._set_status("Invalid path", _C_ERROR)

    def _select_all(self) -> None:
        """Select all entries in the current directory listing."""
        self._list.selectAll()

    def set_show_hidden(self, show_hidden: bool) -> None:
        """Set hidden-file visibility and refresh the current directory."""
        self._show_hidden = show_hidden
        self.navigate(self._current_path, add_to_history=False)

    def _on_filter_changed(self) -> None:
        """Handle filter input updates and refresh current listing."""
        self._name_filter = self._name_filter_input.text().strip()
        current_type = self._type_filter_combo.currentData()
        self._type_filter = current_type if isinstance(current_type, str) else "all"
        self.navigate(self._current_path, add_to_history=False)

    def _apply_filters(self, entries: list[Path]) -> list[Path]:
        """Apply name and extension filters to a list of entries."""
        filtered = entries
        if self._name_filter:
            pattern = self._name_filter
            filtered = [entry for entry in filtered if fnmatch(entry.name, pattern)]
        if self._type_filter != "all":
            filtered = [
                entry
                for entry in filtered
                if classify_file_type(detect_mime_type(entry)) == self._type_filter
            ]
        return filtered

    def _go_up(self) -> None:
        """Navigate to parent directory."""
        parent = self._current_path.parent
        if parent == self._current_path:
            return
        self.navigate(parent, add_to_history=True)

    def _go_back(self) -> None:
        """Navigate to previous directory in history."""
        if self._history_index <= 0:
            return
        self._history_index -= 1
        self.navigate(self._history[self._history_index], add_to_history=False)

    def _go_forward(self) -> None:
        """Navigate to next directory in history."""
        if self._history_index >= len(self._history) - 1:
            return
        self._history_index += 1
        self.navigate(self._history[self._history_index], add_to_history=False)

    def _update_nav_buttons(self) -> None:
        """Enable or disable back/forward navigation buttons."""
        self._back_btn.setEnabled(self._history_index > 0)
        self._forward_btn.setEnabled(self._history_index < len(self._history) - 1)

    def _on_double_clicked(self, item: QListWidgetItem) -> None:
        """Navigate into directory or open file on double-click."""
        path: Path = item.data(Qt.ItemDataRole.UserRole)
        if path.is_dir():
            self.navigate(path, add_to_history=True)
        else:
            self._open_file(path)

    def _open_file(self, path: Path) -> None:
        """Open a file in the system default application."""
        try:
            opened = QDesktopServices.openUrl(QUrl.fromLocalFile(str(path)))
            if not opened:
                open_in_default_app(path)
            self._set_status(f"Opened {path.name}", _C_SUCCESS)
        except Exception as error:
            self._set_status(f"✗ Open failed: {error}", _C_ERROR)

    def _on_selection_changed_internal(self) -> None:
        """Handle list selection change and propagate to pipeline."""
        selected: list[Path] = [
            item.data(Qt.ItemDataRole.UserRole) for item in self._list.selectedItems()
        ]
        self._count.setText(f"{len(selected)} selected")

        if selected:
            type_summary = summarize_file_types(selected)
            msg = f"{len(selected)} items selected"
            if type_summary:
                msg += f" ({type_summary})"
            self._set_status(msg, _C_SUCCESS)
        else:
            self._set_status("Ready", _C_TEXT_DIM)

        self._on_selection_changed(selected)

    def _set_status(self, text: str, color: QColor) -> None:
        """Update the status label with text and color."""
        self._status.setText(text)
        self._status.setStyleSheet(f"color: {color.name()}; font-size: 11px;")

    def set_status_text(self, text: str, color: QColor) -> None:
        """Public status setter for external callers."""
        self._set_status(text, color)


# ── OperationPane ─────────────────────────────────────────────────────────────


class OperationPane(QFrame):
    """Pane showing an operation's parameter input and output file list."""

    def __init__(
        self,
        pane_id: str,
        operation_name: str,
        on_selection_changed=None,
        parent=None,
    ) -> None:
        """Initialize operation pane.

        Args:
            pane_id: Unique pipeline pane identifier.
            operation_name: Human-readable name shown in the header.
        """
        super().__init__(parent)
        self.pane_id = pane_id
        self.param_input: QLineEdit | None = None
        self.save_conflict_policy_combo: QComboBox | None = None
        self.save_button: QPushButton | None = None
        self.transform_action_combo: QComboBox | None = None
        self.transform_value_input: QLineEdit | None = None
        self.archive_name_input: QLineEdit | None = None
        self.archive_format_combo: QComboBox | None = None
        self.unarchive_flatten_checkbox: QCheckBox | None = None
        self._show_hidden: bool = False
        self._all_files: list[Path] = []
        self._name_filter: str = ""
        self._type_filter: str = "all"
        self._on_selection_changed = on_selection_changed

        self.setMinimumWidth(320)
        self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
        self.setAutoFillBackground(True)
        pal = self.palette()
        pal.setColor(QPalette.ColorRole.Window, _C_BG_PANE)
        self.setPalette(pal)

        self._layout = QVBoxLayout(self)
        self._layout.setContentsMargins(6, 6, 6, 6)
        self._layout.setSpacing(4)

        hdr = QLabel(operation_name)
        hdr.setStyleSheet(f"color: {_C_TEXT.name()}; font-weight: bold; font-size: 13px;")
        self._layout.addWidget(hdr)

        sep = QFrame()
        sep.setFrameShape(QFrame.Shape.HLine)
        sep.setFixedHeight(2)
        sep.setStyleSheet(f"background-color: {_C_ACCENT.name()}; border: none;")
        self._layout.addWidget(sep)

        # Dialogue section (parameter widgets inserted here)
        self._dialogue = QVBoxLayout()
        self._dialogue.setContentsMargins(0, 0, 0, 0)
        self._dialogue.setSpacing(4)
        self._layout.addLayout(self._dialogue)

        # Status
        self._status = QLabel("Ready")
        self._status.setStyleSheet(f"color: {_C_TEXT_DIM.name()}; font-size: 11px;")
        self._layout.addWidget(self._status)

        self._busy_indicator = QProgressBar()
        self._busy_indicator.setRange(0, 0)
        self._busy_indicator.setTextVisible(True)
        self._busy_indicator.setFormat("Working…")
        self._busy_indicator.setFixedHeight(12)
        self._busy_indicator.setStyleSheet(
            "QProgressBar {"
            f" background-color: {_C_BG_DARK.name()};"
            f" border: 1px solid {_C_TEXT_DIM.name()};"
            " border-radius: 3px;"
            f" color: {_C_TEXT.name()};"
            " text-align: center;"
            " font-size: 10px;"
            "}"
            "QProgressBar::chunk {"
            f" background-color: {_C_ACCENT.name()};"
            " border-radius: 2px;"
            "}"
        )
        self._busy_indicator.setVisible(False)
        self._layout.addWidget(self._busy_indicator)

        filter_row = QHBoxLayout()
        self._name_filter_input = QLineEdit()
        self._name_filter_input.setPlaceholderText("Filter name (glob)")
        self._name_filter_input.textChanged.connect(self._on_filter_changed)
        filter_row.addWidget(self._name_filter_input)

        self._type_filter_combo = QComboBox()
        self._type_filter_combo.setFixedWidth(170)
        self._type_filter_combo.addItem("Type: All", "all")
        self._type_filter_combo.addItem("Type: Directory", "directory")
        self._type_filter_combo.addItem("Type: Image", "image")
        self._type_filter_combo.addItem("Type: Video", "video")
        self._type_filter_combo.addItem("Type: Audio", "audio")
        self._type_filter_combo.addItem("Type: Text", "text")
        self._type_filter_combo.addItem("Type: Document", "document")
        self._type_filter_combo.addItem("Type: Other", "other")
        self._type_filter_combo.currentIndexChanged.connect(self._on_filter_changed)
        filter_row.addWidget(self._type_filter_combo)
        self._layout.addLayout(filter_row)

        # Output file list
        self._file_list = QListWidget()
        self._file_list.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
        self._file_list.itemDoubleClicked.connect(self._on_double_clicked)
        self._file_list.itemSelectionChanged.connect(self._on_selection_changed_internal)
        self._layout.addWidget(self._file_list)

        # Count
        self._count = QLabel("0 files")
        self._count.setStyleSheet(f"color: {_C_TEXT_DIM.name()}; font-size: 10px;")
        self._layout.addWidget(self._count)

    def add_dialogue_widget(self, widget: QWidget) -> None:
        """Add a widget to the parameter/dialogue section."""
        self._dialogue.addWidget(widget)

    def update_status(self, status: PaneStatus, error: str | None = None) -> None:
        """Update the status label to reflect current pane state.

        Args:
            status: Current execution state of the pane.
            error: Optional error message shown in error state.
        """
        if status == PaneStatus.DONE:
            text, color = "✓ Complete", _C_SUCCESS
            self._busy_indicator.setVisible(False)
            self._busy_indicator.setRange(0, 0)
            self._busy_indicator.setFormat("Working…")
        elif status == PaneStatus.ERROR:
            text, color = (f"✗ Error: {error}" if error else "✗ Error"), _C_ERROR
            self._busy_indicator.setVisible(False)
            self._busy_indicator.setRange(0, 0)
            self._busy_indicator.setFormat("Working…")
        elif status == PaneStatus.EXECUTING:
            text, color = "⏳ Executing…", _C_ACCENT
            self._busy_indicator.setVisible(True)
            self._busy_indicator.setRange(0, 0)
            self._busy_indicator.setValue(0)
            self._busy_indicator.setFormat("Working…")
        else:
            text, color = str(status), _C_WARNING
            self._busy_indicator.setVisible(False)
            self._busy_indicator.setRange(0, 0)
            self._busy_indicator.setFormat("Working…")
        self._set_status(text, color)

    def update_progress(
        self,
        current: int | None,
        total: int | None,
        message: str | None,
    ) -> None:
        """Update execution progress UI for this pane."""
        self._busy_indicator.setVisible(True)
        if total is None or current is None or total <= 0:
            self._busy_indicator.setRange(0, 0)
            self._busy_indicator.setFormat("Working…")
            if message:
                self._set_status(f"⏳ {message}", _C_ACCENT)
            return

        self._busy_indicator.setRange(0, total)
        self._busy_indicator.setValue(max(0, min(current, total)))
        self._busy_indicator.setFormat(f"{current}/{total}")
        if message:
            self._set_status(f"⏳ {message} ({current}/{total})", _C_ACCENT)
        else:
            self._set_status(f"⏳ Executing… ({current}/{total})", _C_ACCENT)

    def update_files(self, files: list[Path]) -> None:
        """Repopulate the output file list.

        Args:
            files: Paths to display.
        """
        self._all_files = files
        self._render_files()

    def set_show_hidden(self, show_hidden: bool) -> None:
        """Set hidden-file visibility for this pane's file list."""
        self._show_hidden = show_hidden
        self._render_files()

    def _render_files(self) -> None:
        """Render file list honoring hidden-file visibility settings."""
        files_to_show = self._all_files
        if not self._show_hidden:
            files_to_show = [
                file_path for file_path in self._all_files if not file_path.name.startswith(".")
            ]
        files_to_show = self._apply_filters(files_to_show)

        self._file_list.clear()
        for fp in files_to_show:
            item = QListWidgetItem(f"{_icon_for_entry(fp)}  {fp.name}")
            item.setData(Qt.ItemDataRole.UserRole, fp)
            self._file_list.addItem(item)
        self._count.setText(f"{len(files_to_show)} files")

    def selected_files(self) -> list[Path]:
        """Return currently selected entries in this pane."""
        return [item.data(Qt.ItemDataRole.UserRole) for item in self._file_list.selectedItems()]

    def _on_filter_changed(self) -> None:
        """Handle operation-pane filter input changes."""
        self._name_filter = self._name_filter_input.text().strip()
        current_type = self._type_filter_combo.currentData()
        self._type_filter = current_type if isinstance(current_type, str) else "all"
        self._render_files()

    def _apply_filters(self, files: list[Path]) -> list[Path]:
        """Apply name and extension filters to pane files."""
        filtered = files
        if self._name_filter:
            filtered = [
                file_path for file_path in filtered if fnmatch(file_path.name, self._name_filter)
            ]
        if self._type_filter != "all":
            filtered = [
                file_path
                for file_path in filtered
                if classify_file_type(detect_mime_type(file_path)) == self._type_filter
            ]
        return filtered

    def _on_double_clicked(self, item: QListWidgetItem) -> None:
        """Open selected output entry from an operation pane."""
        path: Path | None = item.data(Qt.ItemDataRole.UserRole)
        if path is None:
            return
        self._open_path(path)

    def _on_selection_changed_internal(self) -> None:
        """Propagate operation-pane selection changes to the parent widget."""
        if self._on_selection_changed is None:
            return
        self._on_selection_changed(self.selected_files())

    def _open_path(self, path: Path) -> None:
        """Open a path in the system default application."""
        try:
            opened = QDesktopServices.openUrl(QUrl.fromLocalFile(str(path)))
            if not opened:
                open_in_default_app(path)
            self._set_status(f"Opened {path.name}", _C_SUCCESS)
        except Exception as error:
            self._set_status(f"✗ Open failed: {error}", _C_ERROR)

    def set_status_text(self, text: str, color: QColor) -> None:
        """Set status label text and color directly.

        Args:
            text: Status message to display.
            color: Display color for the message.
        """
        self._set_status(text, color)

    def _set_status(self, text: str, color: QColor) -> None:
        self._status.setText(text)
        self._status.setStyleSheet(f"color: {color.name()}; font-size: 11px;")


# ── PipelineWidget ────────────────────────────────────────────────────────────

# Parameter input configuration per operation: (label, default, placeholder)
_PARAM_CONFIG: dict[str, tuple[str, str, str]] = {
    "rename_pattern": ("Pattern:", "{name}", "e.g., {index}_{name}_{ext}"),
    "save_to": ("Target:", "", "Select target folder"),
}


class PipelineWidget(QWidget):
    """Horizontal container managing the browser + alternating pane/bar layout."""

    _pane_update_requested = Signal(str)
    _pipeline_action_failed = Signal(str)

    def __init__(self, api: CascadeAPI, pipeline: Pipeline, parent=None) -> None:
        """Initialize pipeline widget.

        Args:
            api: Application API instance.
            pipeline: Pipeline state manager.
        """
        super().__init__(parent)
        self.api = api
        self.pipeline = pipeline
        self._show_hidden: bool = False
        self._pipeline_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="cascade")

        self._layout = QHBoxLayout(self)
        self._layout.setContentsMargins(0, 0, 0, 0)
        self._layout.setSpacing(0)

        self.browser_pane: BrowserPane | None = None
        self.pane_widgets: dict[str, OperationPane] = {}
        self._pane_selected_files: dict[str, list[Path]] = {}
        self.operation_bars: list[OperationBar] = []
        self._is_restoring = False

        self._pane_update_requested.connect(self._update_pane_display)
        self._pipeline_action_failed.connect(self._handle_pipeline_action_failed)
        self.pipeline.on_pane_updated = self._pane_update_requested.emit
        self._create_browser_pane()

    # ── Pipeline callbacks ────────────────────────────────────────────────────

    def _submit_pipeline_action(self, action) -> None:
        """Run a pipeline mutation in the background worker."""
        future: Future[None] = self._pipeline_executor.submit(action)
        future.add_done_callback(self._on_pipeline_action_done)

    def _on_pipeline_action_done(self, future: Future[None]) -> None:
        """Forward background pipeline errors back to the UI thread."""
        error = future.exception()
        if error is None:
            return
        self._pipeline_action_failed.emit(str(error))

    def _handle_pipeline_action_failed(self, error: str) -> None:
        """Display pipeline worker errors in the browser status area."""
        if self.browser_pane is not None:
            self.browser_pane.set_status_text(f"Error: {error}", _C_ERROR)

    def _update_pane_display(self, pane_id: str) -> None:
        """Refresh a pane's status and file list after pipeline execution."""
        if pane_id not in self.pane_widgets:
            return
        pane_widget = self.pane_widgets[pane_id]
        try:
            pane = self.pipeline.get_pane(pane_id)
        except ValueError:
            return
        pane_widget.update_status(pane.status, pane.error)
        if pane.status == PaneStatus.EXECUTING:
            pane_widget.update_progress(
                pane.operation.progress_current,
                pane.operation.progress_total,
                pane.operation.progress_message,
            )
        pane_widget.update_files(pane.output_files)
        self._refresh_operation_bars()

    # ── Browser pane ──────────────────────────────────────────────────────────

    def _create_browser_pane(self) -> None:
        """Create the file browser as the first (fixed) pane."""
        pane = BrowserPane(on_selection_changed=self._on_browser_selection_changed)
        self.browser_pane = pane
        self._layout.addWidget(pane, 1)
        self._add_operation_bar()

    def _on_browser_selection_changed(self, selected: list[Path]) -> None:
        """Handle file browser selection changes."""
        self._refresh_operation_bars()
        self._submit_pipeline_action(lambda: self.pipeline.update_browser_files(selected))

    # ── Operation bars ────────────────────────────────────────────────────────

    def _add_operation_bar(self) -> None:
        """Append a new operation bar to the right end of the pipeline."""
        bar_index = len(self.operation_bars)
        bar = OperationBar(
            bar_index=bar_index,
            on_operation_selected=self._on_operation_selected,
        )
        bar.update_operations(self._ops_for_bar(bar_index))
        bar.set_hidden_operations_button(self._hidden_ops_for_bar(bar_index))
        self.operation_bars.append(bar)
        self._layout.addWidget(bar, 0)

    def _ops_for_bar(self, bar_index: int) -> list[str]:
        """Return compatible operations for a bar based on upstream output types."""
        if bar_index == 0 and self.browser_pane is not None:
            selected_files = self.browser_pane.selected_files()
            if not selected_files:
                return []
            return list_operations_for_ui(selected_files)

        if bar_index >= len(self.pipeline.panes):
            return []

        source_pane = self.pipeline.panes[bar_index]
        selected_files = self._pane_selected_files.get(source_pane.id, [])
        source_files = selected_files if selected_files else source_pane.output_files
        if not source_files:
            return []
        return list_operations_for_ui(source_files)

    def _hidden_ops_for_bar(self, bar_index: int) -> list[str]:
        """Return incompatible operations for a bar's 'More…' button."""
        return []

    def _refresh_operation_bars(self) -> None:
        """Refresh all operation bars' button lists."""
        for bar in self.operation_bars:
            bar.update_operations(self._ops_for_bar(bar.bar_index))
            bar.set_hidden_operations_button(self._hidden_ops_for_bar(bar.bar_index))

    # ── Operation selection ───────────────────────────────────────────────────

    def _on_operation_selected(self, bar_index: int, operation: str) -> None:
        """Handle user selecting an operation from a bar."""
        target_pane_index = bar_index + 1  # browser is pane_0
        has_downstream = len(self.pipeline.panes) > (target_pane_index + 1)
        if has_downstream and not self._confirm_switch_with_downstream_loss():
            return

        self._remove_panes_from(bar_index + 1)
        self._remove_bars_from(bar_index + 1)

        pane_id = self.pipeline.switch_operation_at_bar(bar_index, operation)

        operation_label = operation.replace("_", " ").title()
        try:
            operation_label = get_operation_info(operation)["label"]
        except Exception:
            pass

        op_pane = OperationPane(
            pane_id=pane_id,
            operation_name=operation_label,
            on_selection_changed=lambda selected, pid=pane_id: self._on_operation_pane_selection_changed(pid, selected),
        )
        op_pane.set_show_hidden(self._show_hidden)
        self._attach_param_input(op_pane, pane_id, operation)
        self.pane_widgets[pane_id] = op_pane
        self._layout.addWidget(op_pane, 1)

        if not self._is_restoring and self._should_auto_execute_operation(operation):
            QTimer.singleShot(0, lambda pid=pane_id: self._execute_pane(pid))

        self._add_operation_bar()
        self._refresh_operation_bars()

    def _on_operation_pane_selection_changed(self, pane_id: str, selected: list[Path]) -> None:
        """Track operation-pane selections so downstream operation lists follow selection."""
        self._pane_selected_files[pane_id] = selected
        self._refresh_operation_bars()

    def _confirm_switch_with_downstream_loss(self) -> bool:
        """Ask user confirmation before removing downstream pipeline steps."""
        result = QMessageBox.warning(
            self,
            "Replace operation?",
            "Switching this operation will remove all steps to the right. Continue?",
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
            QMessageBox.StandardButton.No,
        )
        return result == QMessageBox.StandardButton.Yes

    def _remove_panes_from(self, target_pane_index: int) -> None:
        """Remove all operation pane widgets starting from a pipeline index."""
        panes_to_remove: list[str] = []
        for pane_id in self.pane_widgets:
            try:
                pane_index = int(pane_id.split("_", maxsplit=1)[1])
            except (IndexError, ValueError):
                continue
            if pane_index >= target_pane_index:
                panes_to_remove.append(pane_id)

        for pane_id in panes_to_remove:
            if pane_id in self.pane_widgets:
                widget = self.pane_widgets.pop(pane_id)
                self._pane_selected_files.pop(pane_id, None)
                self._layout.removeWidget(widget)
                widget.deleteLater()

    def _remove_bars_from(self, bar_index: int) -> None:
        """Remove all operation bars starting from a given index."""
        for bar in self.operation_bars[bar_index:]:
            self._layout.removeWidget(bar)
            bar.deleteLater()
        self.operation_bars = self.operation_bars[:bar_index]

    def _should_auto_execute_operation(self, operation_name: str) -> bool:
        """Return whether a newly added operation should execute immediately."""
        return operation_name not in {"rename_pattern"}

    # ── Parameter input ───────────────────────────────────────────────────────

    def _attach_param_input(self, pane: OperationPane, pane_id: str, operation: str) -> None:
        """Wire an operation-specific text input into the pane dialogue section."""
        config = _PARAM_CONFIG.get(operation)
        if config is None and operation not in {"image_transform", "create_archive", "unarchive"}:
            return

        if operation == "image_transform":
            self._attach_image_transform_controls(pane, pane_id)
            return

        if operation == "create_archive":
            self._attach_create_archive_controls(pane, pane_id)
            return

        if operation == "unarchive":
            self._attach_unarchive_controls(pane, pane_id)
            return

        assert config is not None
        label_text, default_text, hint_text = config

        row = QWidget()
        row_layout = QHBoxLayout(row)
        row_layout.setContentsMargins(0, 0, 0, 0)
        lbl = QLabel(label_text)
        lbl.setFixedWidth(78)
        lbl.setStyleSheet(f"color: {_C_TEXT.name()};")
        row_layout.addWidget(lbl)

        inp = QLineEdit(default_text)
        inp.setPlaceholderText(hint_text)
        row_layout.addWidget(inp)

        pane.add_dialogue_widget(row)
        pane.param_input = inp

        if operation == "save_to":
            if self.browser_pane is not None:
                inp.setText(str(self.browser_pane.current_path()))

            pick_btn = QPushButton("…")
            pick_btn.setFixedWidth(34)
            pick_btn.setToolTip("Select target folder")
            row_layout.addWidget(pick_btn)
            pick_btn.clicked.connect(lambda: self._select_target_directory(inp))

            policy_row = QWidget()
            policy_layout = QHBoxLayout(policy_row)
            policy_layout.setContentsMargins(0, 0, 0, 0)
            policy_label = QLabel("On conflict:")
            policy_label.setFixedWidth(78)
            policy_label.setStyleSheet(f"color: {_C_TEXT.name()};")
            policy_layout.addWidget(policy_label)

            policy_combo = QComboBox()
            policy_combo.addItem("overwrite", "overwrite")
            policy_combo.addItem("skip", "skip")
            policy_combo.addItem("fail", "fail")
            policy_combo.setCurrentIndex(1)
            policy_layout.addWidget(policy_combo)
            pane.add_dialogue_widget(policy_row)
            pane.save_conflict_policy_combo = policy_combo

            save_btn = QPushButton("Save")
            save_btn.setStyleSheet(
                f"QPushButton {{ background-color: {_C_SUCCESS.darker(150).name()}; "
                f"border: 1px solid {_C_SUCCESS.darker(120).name()}; border-radius: 3px; "
                f"color: {_C_TEXT.name()}; font-weight: bold; padding: 6px 10px; }}"
                f"QPushButton:hover {{ background-color: {_C_SUCCESS.darker(120).name()}; }}"
            )
            save_btn.clicked.connect(lambda: self._execute_pane(pane_id))
            pane.add_dialogue_widget(save_btn)
            pane.save_button = save_btn
            return

        inp.returnPressed.connect(lambda: self._execute_pane(pane_id))
        inp.editingFinished.connect(lambda: self._execute_pane(pane_id))

    def _attach_image_transform_controls(self, pane: OperationPane, pane_id: str) -> None:
        """Attach action + value controls for unified image transform operation."""
        action_row = QWidget()
        action_layout = QHBoxLayout(action_row)
        action_layout.setContentsMargins(0, 0, 0, 0)

        action_lbl = QLabel("Action:")
        action_lbl.setFixedWidth(78)
        action_lbl.setStyleSheet(f"color: {_C_TEXT.name()};")
        action_layout.addWidget(action_lbl)

        action_combo = QComboBox()
        for action in ["resize", "convert", "rotate", "compress", "strip_exif"]:
            action_combo.addItem(action, action)
        action_layout.addWidget(action_combo)
        pane.add_dialogue_widget(action_row)

        value_row = QWidget()
        value_layout = QHBoxLayout(value_row)
        value_layout.setContentsMargins(0, 0, 0, 0)

        value_lbl = QLabel("Value:")
        value_lbl.setFixedWidth(78)
        value_lbl.setStyleSheet(f"color: {_C_TEXT.name()};")
        value_layout.addWidget(value_lbl)

        value_input = QLineEdit("1920")
        value_layout.addWidget(value_input)
        pane.add_dialogue_widget(value_row)
        pane.param_input = value_input

        pane.transform_action_combo = action_combo
        pane.transform_value_input = value_input

        def _on_action_changed() -> None:
            action = str(action_combo.currentData())
            defaults = {
                "resize": ("1920", "width or widthxheight"),
                "convert": ("PNG", "PNG, JPEG, WEBP"),
                "rotate": ("90", "90, 180, 270"),
                "compress": ("80", "1-100"),
                "strip_exif": ("", "no value needed"),
            }
            default_value, placeholder = defaults[action]
            value_input.setPlaceholderText(placeholder)
            value_input.setText(default_value)

        _on_action_changed()
        action_combo.currentIndexChanged.connect(_on_action_changed)
        action_combo.currentIndexChanged.connect(lambda: self._execute_pane(pane_id))
        value_input.returnPressed.connect(lambda: self._execute_pane(pane_id))
        value_input.editingFinished.connect(lambda: self._execute_pane(pane_id))

    def _attach_unarchive_controls(self, pane: OperationPane, pane_id: str) -> None:
        """Attach controls for unarchive operation options."""
        checkbox = QCheckBox("Flatten single root folder")
        checkbox.setChecked(True)
        checkbox.setStyleSheet(f"color: {_C_TEXT.name()};")
        checkbox.toggled.connect(lambda: self._execute_pane(pane_id))
        pane.add_dialogue_widget(checkbox)
        pane.unarchive_flatten_checkbox = checkbox

        hint = QLabel("If an archive contains one top-level folder, show its contents directly.")
        hint.setWordWrap(True)
        hint.setStyleSheet(f"color: {_C_TEXT_DIM.name()}; font-size: 10px;")
        pane.add_dialogue_widget(hint)

    def _attach_create_archive_controls(self, pane: OperationPane, pane_id: str) -> None:
        """Attach controls for create_archive operation options."""
        name_row = QWidget()
        name_layout = QHBoxLayout(name_row)
        name_layout.setContentsMargins(0, 0, 0, 0)

        name_label = QLabel("Name:")
        name_label.setFixedWidth(78)
        name_label.setStyleSheet(f"color: {_C_TEXT.name()};")
        name_layout.addWidget(name_label)

        name_input = QLineEdit("archive")
        name_input.setPlaceholderText("archive name")
        name_layout.addWidget(name_input)
        pane.add_dialogue_widget(name_row)

        format_row = QWidget()
        format_layout = QHBoxLayout(format_row)
        format_layout.setContentsMargins(0, 0, 0, 0)

        format_label = QLabel("Format:")
        format_label.setFixedWidth(78)
        format_label.setStyleSheet(f"color: {_C_TEXT.name()};")
        format_layout.addWidget(format_label)

        format_combo = QComboBox()
        format_combo.addItem("zip", "zip")
        format_combo.addItem("tar", "tar")
        format_combo.addItem("tar.gz", "gztar")
        format_combo.addItem("tar.bz2", "bztar")
        format_combo.addItem("tar.xz", "xztar")
        format_layout.addWidget(format_combo)
        pane.add_dialogue_widget(format_row)

        pane.archive_name_input = name_input
        pane.archive_format_combo = format_combo
        name_input.returnPressed.connect(lambda: self._execute_pane(pane_id))
        name_input.editingFinished.connect(lambda: self._execute_pane(pane_id))
        format_combo.currentIndexChanged.connect(lambda: self._execute_pane(pane_id))

    def _select_target_directory(self, input_widget: QLineEdit) -> None:
        """Open directory picker and populate target folder field."""
        current = input_widget.text().strip()
        start_dir = current if current else str(Path.home())
        selected = QFileDialog.getExistingDirectory(self, "Select target folder", start_dir)
        if not selected:
            return

        input_widget.setText(selected)

    # ── Execution ─────────────────────────────────────────────────────────────

    def _execute_pane(self, pane_id: str) -> None:
        """Extract UI params and trigger operation execution via pipeline."""
        if pane_id not in self.pane_widgets:
            return
        params = self._extract_pane_params(pane_id)
        if params is None:
            return
        try:
            pane = self.pipeline.get_pane(pane_id)
        except ValueError:
            return

        selected_override: list[Path] | None = None
        previous_pane = self.pipeline.get_previous_pane(pane_id)
        if previous_pane is not None:
            selected = self._pane_selected_files.get(previous_pane.id, [])
            if selected:
                selected_override = list(selected)

        self.pane_widgets[pane_id].update_status(PaneStatus.EXECUTING)

        def _apply_execution() -> None:
            if selected_override is not None:
                pane.operation.set_input_files(selected_override)
            self.pipeline.set_pane_params(pane.id, params)

        self._submit_pipeline_action(_apply_execution)

    def _extract_pane_params(self, pane_id: str) -> dict | None:
        """Build a params dict from the pane's UI input widget."""
        pane_widget = self.pane_widgets.get(pane_id)
        if pane_widget is None:
            return None

        try:
            pane = self.pipeline.get_pane(pane_id)
        except ValueError:
            return None

        if pane.operation.name == "unarchive":
            return self._extract_unarchive_params(pane_widget)

        if not hasattr(pane_widget, "param_input") or pane_widget.param_input is None:
            return {}

        val = pane_widget.param_input.text().strip()

        if pane.operation.name == "save_to":
            return self._extract_save_to_params(pane_widget, pane_id, val)

        if pane.operation.name == "image_transform":
            return self._extract_image_transform_params(pane_widget)

        if pane.operation.name == "create_archive":
            return self._extract_create_archive_params(pane_widget)

        return _parse_param_value(pane.operation.name, val)

    def _extract_unarchive_params(self, pane_widget: OperationPane) -> dict:
        """Extract unarchive-specific params from the pane widget."""
        flatten_single_root = True
        checkbox = getattr(pane_widget, "unarchive_flatten_checkbox", None)
        if checkbox is not None:
            flatten_single_root = bool(checkbox.isChecked())
        return {"flatten_single_root": flatten_single_root}

    def _extract_save_to_params(
        self,
        pane_widget: OperationPane,
        pane_id: str,
        target_dir: str,
    ) -> dict:
        """Extract save_to-specific params from the pane widget and upstream pane."""
        conflict_policy = "skip"
        policy_combo = getattr(pane_widget, "save_conflict_policy_combo", None)
        if policy_combo is not None:
            current = policy_combo.currentData()
            if isinstance(current, str):
                conflict_policy = current
        if not target_dir:
            return {}

        params: dict[str, object] = {
            "target_dir": target_dir,
            "conflict_policy": conflict_policy,
        }

        previous_pane = self.pipeline.get_previous_pane(pane_id)
        if previous_pane and previous_pane.operation.name == "rename_pattern":
            params["planned_source_files"] = [
                str(path) for path in previous_pane.operation.input_files
            ]
            params["planned_output_names"] = [str(path.name) for path in previous_pane.output_files]

        return params

    def _extract_image_transform_params(self, pane_widget: OperationPane) -> dict | None:
        """Extract image_transform-specific params from the pane widget."""
        action_combo = getattr(pane_widget, "transform_action_combo", None)
        value_input = getattr(pane_widget, "transform_value_input", None)
        if action_combo is None or value_input is None:
            return None

        action = str(action_combo.currentData())
        value = value_input.text().strip()
        return _parse_image_transform_params(action, value)

    def _extract_create_archive_params(self, pane_widget: OperationPane) -> dict | None:
        """Extract create_archive-specific params from the pane widget."""
        name_input = getattr(pane_widget, "archive_name_input", None)
        format_combo = getattr(pane_widget, "archive_format_combo", None)
        if name_input is None or format_combo is None:
            return None

        archive_name = name_input.text().strip() or "archive"
        archive_format_data = format_combo.currentData()
        archive_format = archive_format_data if isinstance(archive_format_data, str) else "zip"
        return {
            "archive_name": archive_name,
            "format": archive_format,
        }

    def set_show_hidden(self, show_hidden: bool) -> None:
        """Set hidden-file visibility across browser and all operation panes."""
        self._show_hidden = show_hidden
        if self.browser_pane is not None:
            self.browser_pane.set_show_hidden(show_hidden)
        for pane_widget in self.pane_widgets.values():
            pane_widget.set_show_hidden(show_hidden)

    def cancel_workflow(self) -> None:
        """Remove all configured operation steps and keep only browser state."""
        selected_files: list[Path] = []
        if self.browser_pane is not None:
            selected_files = self.browser_pane.selected_files()

        for pane_id in list(self.pane_widgets.keys()):
            widget = self.pane_widgets.pop(pane_id)
            self._pane_selected_files.pop(pane_id, None)
            self._layout.removeWidget(widget)
            widget.deleteLater()

        self._remove_bars_from(0)
        self.pipeline.panes = []
        self.pipeline.update_browser_files(selected_files)
        self._add_operation_bar()
        self._refresh_operation_bars()

    def snapshot_pipeline(self) -> dict[str, object]:
        """Serialize current operation pipeline for workflow/session persistence."""
        panes_payload: list[dict[str, object]] = []
        for pane in self.pipeline.panes:
            operation_name = pane.operation.name
            if operation_name == "browse_files":
                continue
            panes_payload.append(
                {
                    "id": pane.id,
                    "operation": operation_name,
                    "params": dict(pane.operation.params),
                }
            )

        browser_state = self._snapshot_browser_state()

        return {
            "id": "session",
            "browser": browser_state,
            "panes": panes_payload,
        }

    def _snapshot_browser_state(self) -> dict[str, object]:
        """Serialize browser context using pipeline state as primary source for selection."""
        if self.browser_pane is None:
            return {}

        selected_files: list[Path] = []
        if self.pipeline.panes and self.pipeline.panes[0].operation.name == "browse_files":
            selected_files = list(self.pipeline.panes[0].operation.output_files)
        if not selected_files:
            selected_files = self.browser_pane.selected_files()

        return {
            "path": str(self.browser_pane.current_path()),
            "selected_files": [str(path) for path in selected_files],
        }

    def restore_pipeline(self, snapshot: dict[str, object]) -> bool:
        """Restore operation panes from a serialized workflow/session snapshot."""
        raw_panes = snapshot.get("panes")
        if not isinstance(raw_panes, list):
            return False

        self.cancel_workflow()

        pending_browser_selection = self._restore_browser_snapshot(snapshot)
        restored_any = self._restore_operation_panes(raw_panes)

        if pending_browser_selection and self.browser_pane is not None:
            self.browser_pane.select_files(pending_browser_selection)
            self._submit_pipeline_action(
                lambda files=list(pending_browser_selection): self.pipeline.update_browser_files(files)
            )

        return restored_any

    def _restore_browser_snapshot(self, snapshot: dict[str, object]) -> list[Path]:
        """Restore browser path context and return pending file selection to re-apply."""
        browser_payload = snapshot.get("browser")
        if not isinstance(browser_payload, dict) or self.browser_pane is None:
            return []

        path_value = browser_payload.get("path")
        if isinstance(path_value, str) and path_value:
            self.browser_pane.navigate(Path(path_value), add_to_history=True)

        selected = browser_payload.get("selected_files")
        if not isinstance(selected, list):
            return []

        return [Path(value) for value in selected if isinstance(value, str)]

    def _restore_operation_panes(self, raw_panes: list[object]) -> bool:
        """Restore operation panes and params from serialized payload list."""
        restored_any = False
        target_bar_index = 0
        self._is_restoring = True
        try:
            for pane_payload in raw_panes:
                if not isinstance(pane_payload, dict):
                    continue

                operation_name = pane_payload.get("operation")
                if not isinstance(operation_name, str) or not operation_name:
                    continue

                self._on_operation_selected(target_bar_index, operation_name)
                pane_id = f"pane_{target_bar_index + 1}"
                pane_widget = self.pane_widgets.get(pane_id)

                params = pane_payload.get("params")
                if isinstance(params, dict):
                    if pane_widget is not None:
                        self._apply_params_to_pane_widgets(pane_widget, operation_name, params)
                    self._submit_pipeline_action(
                        lambda pid=pane_id, payload=dict(params): self.pipeline.set_pane_params(
                            pid, payload
                        )
                    )

                restored_any = True
                target_bar_index += 1
        finally:
            self._is_restoring = False

        return restored_any

    def _apply_params_to_pane_widgets(
        self,
        pane_widget: OperationPane,
        operation_name: str,
        params: dict[str, object],
    ) -> None:
        """Apply restored operation params into pane input widgets."""
        if operation_name == "rename_pattern":
            self._apply_rename_params(pane_widget, params)
            return

        if operation_name == "save_to":
            self._apply_save_to_params(pane_widget, params)
            return

        if operation_name == "image_transform":
            self._apply_image_transform_params(pane_widget, params)
            return

        if operation_name == "create_archive":
            self._apply_create_archive_params(pane_widget, params)
            return

        if operation_name == "unarchive":
            self._apply_unarchive_params(pane_widget, params)

    def _apply_rename_params(self, pane_widget: OperationPane, params: dict[str, object]) -> None:
        """Apply restore payload for rename pattern input."""
        input_widget = pane_widget.param_input
        pattern = params.get("pattern")
        if input_widget is not None and isinstance(pattern, str):
            input_widget.setText(pattern)

    def _apply_save_to_params(self, pane_widget: OperationPane, params: dict[str, object]) -> None:
        """Apply restore payload for save_to inputs."""
        input_widget = pane_widget.param_input
        target_dir = params.get("target_dir")
        if input_widget is not None and isinstance(target_dir, str):
            input_widget.setText(target_dir)

        policy_combo = pane_widget.save_conflict_policy_combo
        conflict_policy = params.get("conflict_policy")
        if policy_combo is not None and isinstance(conflict_policy, str):
            index = policy_combo.findData(conflict_policy)
            if index >= 0:
                policy_combo.setCurrentIndex(index)

    def _apply_image_transform_params(
        self,
        pane_widget: OperationPane,
        params: dict[str, object],
    ) -> None:
        """Apply restore payload for image_transform controls."""
        action_combo = pane_widget.transform_action_combo
        value_input = pane_widget.transform_value_input
        action = params.get("action")

        if action_combo is not None and isinstance(action, str):
            action_combo.blockSignals(True)
            index = action_combo.findData(action)
            if index >= 0:
                action_combo.setCurrentIndex(index)
            action_combo.blockSignals(False)

        if value_input is None or not isinstance(action, str):
            return

        value = self._image_transform_value_from_params(action, params)
        if value is not None:
            value_input.setText(value)

    def _image_transform_value_from_params(
        self,
        action: str,
        params: dict[str, object],
    ) -> str | None:
        """Build image-transform value field text from restored params."""
        if action == "resize":
            width = params.get("width")
            height = params.get("height")
            if isinstance(width, int) and isinstance(height, int):
                return f"{width}x{height}"
            if isinstance(width, int):
                return str(width)
            return None

        if action == "convert":
            image_format = params.get("format")
            return image_format if isinstance(image_format, str) else None

        if action == "rotate":
            angle = params.get("angle")
            return str(angle) if isinstance(angle, int) else None

        if action == "compress":
            quality = params.get("quality")
            return str(quality) if isinstance(quality, int) else None

        if action == "strip_exif":
            return ""

        return None

    def _apply_create_archive_params(
        self,
        pane_widget: OperationPane,
        params: dict[str, object],
    ) -> None:
        """Apply restore payload for create_archive controls."""
        name_input = pane_widget.archive_name_input
        archive_name = params.get("archive_name")
        if name_input is not None and isinstance(archive_name, str):
            name_input.setText(archive_name)

        format_combo = pane_widget.archive_format_combo
        archive_format = params.get("format")
        if format_combo is not None and isinstance(archive_format, str):
            index = format_combo.findData(archive_format)
            if index >= 0:
                format_combo.setCurrentIndex(index)

    def _apply_unarchive_params(self, pane_widget: OperationPane, params: dict[str, object]) -> None:
        """Apply restore payload for unarchive controls."""
        checkbox = pane_widget.unarchive_flatten_checkbox
        flatten = params.get("flatten_single_root")
        if checkbox is not None and isinstance(flatten, bool):
            checkbox.setChecked(flatten)


def _parse_param_value(operation_name: str, val: str) -> dict:
    """Parse a raw text input value into an operation params dict.

    Args:
        operation_name: Name of the operation whose param is being parsed.
        val: Raw string from the UI input field.

    Returns:
        Dictionary of parsed parameter values, or empty dict if input is blank.
    """
    if operation_name == "rename_pattern":
        return {"pattern": val} if val else {"pattern": "{name}"}
    return {}


def _parse_image_transform_params(action: str, value: str) -> dict:
    """Parse params for image_transform action/value controls."""
    if action == "resize":
        if not value:
            return {"action": "resize", "width": 1920}
        if "x" in value.lower():
            left, right = value.lower().split("x", maxsplit=1)
            if left.strip().isdigit() and right.strip().isdigit():
                return {
                    "action": "resize",
                    "width": int(left.strip()),
                    "height": int(right.strip()),
                }
            return {"action": "resize"}
        return (
            {"action": "resize", "width": int(value)} if value.isdigit() else {"action": "resize"}
        )

    if action == "convert":
        return (
            {"action": "convert", "format": value.upper()}
            if value
            else {"action": "convert", "format": "PNG"}
        )

    if action == "rotate":
        return (
            {"action": "rotate", "angle": int(value)} if value.isdigit() else {"action": "rotate"}
        )

    if action == "compress":
        return (
            {"action": "compress", "quality": int(value)}
            if value.isdigit()
            else {"action": "compress"}
        )

    if action == "strip_exif":
        return {"action": "strip_exif"}

    return {"action": action}


# ── Main window ───────────────────────────────────────────────────────────────


class CascadeWindow(QMainWindow):
    """Main application window."""

    def __init__(self) -> None:
        """Initialize the main window with pipeline widget."""
        super().__init__()
        self.setWindowTitle("cascade — Pipeline File Manager")
        self.resize(1300, 750)
        self.setMinimumSize(800, 500)

        self.api = CascadeAPI()
        self._restore_max_age_seconds = self._read_restore_max_age_seconds()
        pipeline = Pipeline()

        central = QWidget()
        self.setCentralWidget(central)
        main_layout = QHBoxLayout(central)
        main_layout.setContentsMargins(0, 0, 0, 0)
        main_layout.setSpacing(0)

        # Narrow sidebar placeholder
        sidebar = QWidget()
        sidebar.setFixedWidth(48)
        sidebar.setAutoFillBackground(True)
        pal = sidebar.palette()
        pal.setColor(QPalette.ColorRole.Window, _C_SIDEBAR)
        sidebar.setPalette(pal)
        sidebar_layout = QVBoxLayout(sidebar)
        sidebar_layout.setContentsMargins(5, 5, 5, 5)
        menu_btn = QPushButton("≡")
        menu_btn.setFixedHeight(36)
        menu_btn.setEnabled(False)
        sidebar_layout.addWidget(menu_btn)

        hidden_btn = QPushButton("👁")
        hidden_btn.setFixedHeight(36)
        hidden_btn.setCheckable(True)
        hidden_btn.setToolTip("Show hidden files")
        sidebar_layout.addWidget(hidden_btn)

        cancel_btn = QPushButton("✕")
        cancel_btn.setFixedHeight(36)
        cancel_btn.setToolTip("Cancel workflow")
        sidebar_layout.addWidget(cancel_btn)

        sidebar_layout.addStretch()
        main_layout.addWidget(sidebar)

        # Horizontal-scrolling pipeline area
        scroll = QScrollArea()
        scroll.setWidgetResizable(True)
        scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
        scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
        scroll.setFrameShape(QFrame.Shape.NoFrame)

        self.pipeline_widget = PipelineWidget(api=self.api, pipeline=pipeline)
        self.pipeline_widget.setSizePolicy(
            QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Expanding
        )
        scroll.setWidget(self.pipeline_widget)
        main_layout.addWidget(scroll)

        hidden_btn.toggled.connect(self._on_toggle_hidden_files)
        cancel_btn.clicked.connect(self._on_cancel_workflow)

        self._maybe_restore_last_session()

    def _on_toggle_hidden_files(self, checked: bool) -> None:
        """Handle sidebar hidden-files toggle."""
        self.pipeline_widget.set_show_hidden(checked)

    def _on_cancel_workflow(self) -> None:
        """Handle sidebar cancel workflow action."""
        self.pipeline_widget.cancel_workflow()
        self.api.delete_workflow("__last_session__")

    def _maybe_restore_last_session(self) -> None:
        """Prompt user to restore last saved session if available."""
        age_seconds = self.api.get_workflow_age_seconds("__last_session__")
        if age_seconds is None:
            return
        if age_seconds > self._restore_max_age_seconds:
            browser_pane = self.pipeline_widget.browser_pane
            if browser_pane is not None:
                hours = age_seconds / 3600
                browser_pane.set_status_text(
                    f"Skipped old session snapshot ({hours:.1f}h old)",
                    _C_TEXT_DIM,
                )
            return

        loaded = self.api.load_workflow("__last_session__")
        panes = loaded.get("panes")
        if not isinstance(panes, list) or not panes:
            return

        result = QMessageBox.question(
            self,
            "Restore session?",
            "A previous session was found. Restore it now?",
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
            QMessageBox.StandardButton.Yes,
        )
        if result != QMessageBox.StandardButton.Yes:
            self.api.delete_workflow("__last_session__")
            return

        self.pipeline_widget.restore_pipeline(loaded)

    def closeEvent(self, event: QCloseEvent) -> None:
        """Persist last session snapshot when the main window closes."""
        snapshot = self.pipeline_widget.snapshot_pipeline()
        self.api.save_workflow("__last_session__", snapshot)
        super().closeEvent(event)

    @staticmethod
    def _read_restore_max_age_seconds() -> float:
        """Read max restore age from env var, falling back to default on invalid values."""
        raw_value = os.getenv("CASCADE_RESTORE_MAX_AGE_SECONDS", "")
        if not raw_value:
            return float(_DEFAULT_RESTORE_MAX_AGE_SECONDS)
        try:
            parsed = float(raw_value)
        except ValueError:
            return float(_DEFAULT_RESTORE_MAX_AGE_SECONDS)
        return max(0.0, parsed)


# ── Entry point ───────────────────────────────────────────────────────────────


def launch() -> int:
    """Launch the cascade PySide6 desktop application.

    Returns:
        Application exit code.
    """
    app_instance = QApplication.instance()
    app = app_instance if isinstance(app_instance, QApplication) else QApplication(sys.argv)
    app.setStyle("Fusion")
    app.setPalette(_make_dark_palette())

    init_temp_session()
    app.aboutToQuit.connect(cleanup_temp_session)

    window = CascadeWindow()
    window.show()
    return app.exec()
