package ui

import (
	"context"
	"fmt"
	"io"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"time"

	"github.com/atotto/clipboard"
	"github.com/charmbracelet/bubbles/key"
	"github.com/charmbracelet/bubbles/textinput"
	"github.com/charmbracelet/bubbles/viewport"
	tea "github.com/charmbracelet/bubbletea"
	"github.com/charmbracelet/lipgloss"
	"github.com/charmbracelet/x/ansi"

	"vcom/internal/config"
	vfs "vcom/internal/fs"
	"vcom/internal/theme"
)

const version = "v0.1.9"

type modalKind int

const (
	modalNone modalKind = iota
	modalMkdir
	modalRename
	modalConfirm
	modalCopyProgress
	modalNotice
	modalHelp
	modalArchiveType
	modalArchiveProgress
)

type fileOpKind int

const (
	opCopy fileOpKind = iota
	opMove
	opDelete
	opMkdir
	opRename
	opEdit
	opView
	opArchive
)

type pendingOperation struct {
	kind            fileOpKind
	sourcePaths     []string
	targetDir       string
	overwrite       bool
	existingTargets int
	stats           vfs.TransferStats
}

type modalState struct {
	kind    modalKind
	title   string
	body    string
	note    string
	input   textinput.Model
	pending *pendingOperation
}

type previewMsg struct {
	entryPath string
	preview   vfs.Preview
}

type dirSizeMsg struct {
	path string
	size int64
	err  error
}

type opMsg struct {
	kind       fileOpKind
	sourcePath string
	targetPath string
	err        error
}

type copyPlanMsg struct {
	kind            fileOpKind
	sourcePaths     []string
	targetDir       string
	overwrite       bool
	existingTargets int
	stats           vfs.TransferStats
	err             error
}

type copyProgressMsg struct {
	jobID    int
	progress vfs.CopyProgress
}

type deletePlanMsg struct {
	sourcePaths []string
	stats       vfs.TransferStats
	err         error
}

type archivePlanMsg struct {
	sourcePaths []string
	targetDir   string
	stats       vfs.TransferStats
	err         error
}

type archiveProgressMsg struct {
	jobID    int
	progress vfs.CopyProgress
}

type archiveDoneMsg struct {
	jobID       int
	sourcePaths []string
	targetPath  string
	err         error
}

type copyDoneMsg struct {
	jobID       int
	kind        fileOpKind
	sourcePaths []string
	targetDir   string
	err         error
}

type dismissNoticeMsg struct{}
type dismissYankFlashMsg struct{}
type externalOpenMsg struct {
	path string
	err  error
}

type copyJobState struct {
	id          int
	kind        fileOpKind
	sourcePaths []string
	targetDir   string
	progress    vfs.CopyProgress
	overwrite   bool
	background  bool
	cancel      context.CancelFunc
	startedAt   time.Time
}

type archiveJobState struct {
	id          int
	sourcePaths []string
	targetPath  string
	progress    vfs.CopyProgress
	background  bool
	cancel      context.CancelFunc
	startedAt   time.Time
}

type mouseClickState struct {
	pane  PaneID
	index int
	at    time.Time
}

type hoverState struct {
	pane  PaneID
	index int
	ok    bool
}

type Model struct {
	cfg        config.Config
	configPath string
	palette    theme.Palette
	keys       KeyMap
	nerdIcons  bool
	overlay    *imageOverlayManager

	width  int
	height int

	left            BrowserPane
	right           BrowserPane
	active          PaneID
	infoMode        bool
	selectMode      bool
	cursorMode      bool
	cursorLine      int
	cursorCol       int
	visualMode      bool
	visualAnchor    int
	visualAnchorCol int
	viewMode        bool
	viewPrevInfo    bool

	previewModel viewport.Model
	previewData  vfs.Preview

	modal  modalState
	status string
	busy   bool

	lastClick     mouseClickState
	hover         hoverState
	pendingY      bool
	yankFlashLine int

	copyJob      *copyJobState
	nextCopyJob  int
	copyProgress chan tea.Msg
	copyPath     string

	archiveJob      *archiveJobState
	nextArchiveJob  int
	archiveProgress chan tea.Msg
	archiveFormat   string
}

func NewModel(cfg config.Config, configPath string) (Model, error) {
	palette, err := theme.Resolve(cfg.UI.Theme)
	if err != nil {
		return Model{}, err
	}

	cwd, err := os.Getwd()
	if err != nil {
		return Model{}, err
	}

	leftPath, err := resolveStartPath(cfg.Startup.LeftPath, cwd)
	if err != nil {
		return Model{}, err
	}
	rightPath, err := resolveStartPath(cfg.Startup.RightPath, cwd)
	if err != nil {
		return Model{}, err
	}

	model := Model{
		cfg:             cfg,
		configPath:      configPath,
		palette:         palette,
		keys:            DefaultKeyMap(),
		overlay:         newImageOverlayManager(),
		left:            BrowserPane{ID: PaneLeft, Path: leftPath},
		right:           BrowserPane{ID: PaneRight, Path: rightPath},
		active:          PaneLeft,
		status:          "Ready",
		copyProgress:    make(chan tea.Msg, 256),
		archiveProgress: make(chan tea.Msg, 256),
	}
	model.nerdIcons, model.status = resolveIconMode(cfg.UI.IconMode)
	if model.status == "" {
		model.status = "Ready"
	}

	model.previewModel = viewport.New(0, 0)
	if err := model.reloadPane(PaneLeft, ""); err != nil {
		return Model{}, err
	}
	if err := model.reloadPane(PaneRight, ""); err != nil {
		return Model{}, err
	}
	return model, nil
}

func (m Model) Init() tea.Cmd {
	return m.loadPreviewCmd()
}

func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case tea.WindowSizeMsg:
		m.width = msg.Width
		m.height = msg.Height
		m.resizePreview()
		m.syncPreviewContent()
		return m, nil

	case previewMsg:
		if selected, ok := m.activePane().Selected(); ok && selected.Path == msg.entryPath {
			m.applyPreview(msg.preview)
		}
		if m.selectMode && !m.viewMode && msg.preview.Kind != vfs.PreviewKindText {
			m.selectMode = false
			return m, enableMouseCmd()
		}
		if (m.cursorMode || m.visualMode) && msg.preview.Kind != vfs.PreviewKindText {
			m.cursorMode = false
			m.visualMode = false
			m.status = "Text cursor mode: off"
		}
		return m, nil

	case dirSizeMsg:
		m.busy = false
		if msg.err != nil {
			m.status = fmt.Sprintf("Dir size failed: %v", msg.err)
			return m, nil
		}
		m.applyDirSize(msg.path, msg.size)
		m.status = fmt.Sprintf("Directory size calculated: %s", vfs.HumanSize(msg.size))
		return m, m.loadPreviewCmd()

	case opMsg:
		m.busy = false
		if msg.err != nil {
			m.status = msg.err.Error()
			return m, nil
		}

		m.modal = modalState{}
		switch msg.kind {
		case opCopy:
			m.status = fmt.Sprintf("Copied to %s", msg.targetPath)
		case opMove:
			m.status = fmt.Sprintf("Moved to %s", msg.targetPath)
		case opDelete:
			m.status = "Deleted"
			m.activePane().ClearMarks()
		case opMkdir:
			m.status = fmt.Sprintf("Created %s", msg.targetPath)
		case opRename:
			m.status = fmt.Sprintf("Renamed to %s", filepath.Base(msg.targetPath))
		case opEdit:
			m.status = "Editor closed"
			return m, tea.Batch(m.loadPreviewCmd(), enableMouseCmd())
		case opView:
			m.status = "Viewer closed"
			return m, enableMouseCmd()
		}

		leftSelection := selectedName(&m.left)
		rightSelection := selectedName(&m.right)
		if msg.kind == opRename && msg.targetPath != "" {
			renamed := filepath.Base(msg.targetPath)
			if m.active == PaneLeft {
				leftSelection = renamed
			} else {
				rightSelection = renamed
			}
		}
		_ = m.reloadPane(PaneLeft, leftSelection)
		_ = m.reloadPane(PaneRight, rightSelection)
		return m, m.loadPreviewCmd()

	case copyPlanMsg:
		m.busy = false
		if msg.err != nil {
			m.status = msg.err.Error()
			return m, nil
		}

		verb := operationVerb(msg.kind)
		title := fmt.Sprintf("%s selected entry?", strings.Title(verb))
		body := strings.Join([]string{
			fmt.Sprintf("Items: %d", len(msg.sourcePaths)),
			fmt.Sprintf("Files: %d", msg.stats.FilesTotal),
			fmt.Sprintf("Size:  %s", formatSize(msg.stats.BytesTotal, true)),
		}, "\n")
		note := "confirm-actions"
		m.openConfirmModal(title, body, note, pendingOperation{
			kind:            msg.kind,
			sourcePaths:     append([]string(nil), msg.sourcePaths...),
			targetDir:       msg.targetDir,
			overwrite:       msg.overwrite,
			existingTargets: msg.existingTargets,
			stats:           msg.stats,
		})
		return m, nil

	case deletePlanMsg:
		m.busy = false
		if msg.err != nil {
			m.status = msg.err.Error()
			return m, nil
		}

		title := "Delete selected entr" + pluralSuffix(len(msg.sourcePaths), "y", "ies") + "?"
		bodyLines := []string{
			fmt.Sprintf("Items: %d", len(msg.sourcePaths)),
			fmt.Sprintf("Files: %d", msg.stats.FilesTotal),
			fmt.Sprintf("Size:  %s", formatSize(msg.stats.BytesTotal, true)),
		}
		m.openConfirmModal(
			title,
			strings.Join(bodyLines, "\n"),
			"confirm-actions",
			pendingOperation{
				kind:        opDelete,
				sourcePaths: append([]string(nil), msg.sourcePaths...),
				stats:       msg.stats,
			},
		)
		return m, nil

	case archivePlanMsg:
		m.busy = false
		if msg.err != nil {
			m.status = msg.err.Error()
			return m, nil
		}

		m.archiveFormat = "zip"
		bodyLines := []string{
			fmt.Sprintf("Items: %d", len(msg.sourcePaths)),
			fmt.Sprintf("Files: %d", msg.stats.FilesTotal),
			fmt.Sprintf("Size:  %s", formatSize(msg.stats.BytesTotal, true)),
		}
		m.modal = modalState{
			kind:  modalArchiveType,
			title: "Archive selected files?",
			body:  strings.Join(bodyLines, "\n"),
			note: fmt.Sprintf(
				"Format: %s  (f to change)\nEnter / y to confirm, Esc / n to cancel",
				m.archiveFormat,
			),
			pending: &pendingOperation{
				kind:        opArchive,
				sourcePaths: append([]string(nil), msg.sourcePaths...),
				targetDir:   msg.targetDir,
				stats:       msg.stats,
			},
		}
		return m, nil

	case archiveProgressMsg:
		if m.archiveJob == nil || msg.jobID != m.archiveJob.id {
			return m, nil
		}
		m.archiveJob.progress = msg.progress
		if m.archiveJob.background {
			m.status = formatArchiveStatus(msg.progress)
		}
		return m, waitArchiveProgressCmd(m.archiveProgress)

	case archiveDoneMsg:
		if m.archiveJob == nil || msg.jobID != m.archiveJob.id {
			return m, nil
		}

		m.busy = false
		if msg.err != nil {
			activeSelection := selectedName(m.activePane())
			_ = m.reloadPane(PaneLeft, activeSelection)
			_ = m.reloadPane(PaneRight, activeSelection)
			if msg.err == context.Canceled {
				m.status = "Archiving cancelled"
			} else {
				m.status = fmt.Sprintf("Archiving failed: %v", msg.err)
			}
			m.archiveJob = nil
			if m.modal.kind == modalArchiveProgress {
				m.modal = modalState{}
			}
			return m, m.loadPreviewCmd()
		}

		m.status = fmt.Sprintf("Archived %d entr%s to %s", len(msg.sourcePaths), pluralSuffix(len(msg.sourcePaths), "y", "ies"), msg.targetPath)
		activeSelection := selectedName(m.activePane())
		_ = m.reloadPane(PaneLeft, activeSelection)
		_ = m.reloadPane(PaneRight, activeSelection)
		background := m.archiveJob.background
		sourceCount := len(m.archiveJob.sourcePaths)
		m.archiveJob = nil
		m.activePane().ClearMarks()

		cmd := m.loadPreviewCmd()
		if m.modal.kind == modalArchiveProgress {
			m.modal = modalState{}
		}
		if background {
			doneBody := fmt.Sprintf("%d entr%s archived successfully.", sourceCount, pluralSuffix(sourceCount, "y", "ies"))
			if sourceCount == 1 && len(msg.sourcePaths) == 1 {
				doneBody = filepath.Base(msg.sourcePaths[0]) + " archived successfully."
			}
			m.modal = modalState{
				kind:  modalNotice,
				title: "Archive complete",
				body:  doneBody,
				note:  "Press Esc to close",
			}
		}
		return m, cmd

	case copyProgressMsg:
		if m.copyJob == nil || msg.jobID != m.copyJob.id {
			return m, nil
		}
		m.copyJob.progress = msg.progress
		if m.copyJob.background {
			m.status = formatCopyStatus(m.copyJob.kind, msg.progress)
		}
		return m, waitCopyProgressCmd(m.copyProgress)

	case copyDoneMsg:
		if m.copyJob == nil || msg.jobID != m.copyJob.id {
			return m, nil
		}

		m.busy = false
		if msg.err != nil {
			activeSelection := selectedName(m.activePane())
			_ = m.reloadPane(PaneLeft, activeSelection)
			_ = m.reloadPane(PaneRight, activeSelection)
			if msg.err == context.Canceled {
				m.status = strings.Title(operationVerb(msg.kind)) + " cancelled"
			} else {
				m.status = fmt.Sprintf("%s failed: %v", strings.Title(operationVerb(msg.kind)), msg.err)
			}
			m.copyJob = nil
			if m.modal.kind == modalCopyProgress {
				m.modal = modalState{}
			}
			return m, m.loadPreviewCmd()
		}

		m.status = fmt.Sprintf("%s %d entr%s to %s", operationDoneLabel(msg.kind), len(msg.sourcePaths), pluralSuffix(len(msg.sourcePaths), "y", "ies"), msg.targetDir)
		activeSelection := selectedName(m.activePane())
		_ = m.reloadPane(PaneLeft, activeSelection)
		_ = m.reloadPane(PaneRight, activeSelection)
		background := m.copyJob.background
		kind := m.copyJob.kind
		sourceCount := len(m.copyJob.sourcePaths)
		m.copyJob = nil
		m.activePane().ClearMarks()

		cmd := m.loadPreviewCmd()
		if m.modal.kind == modalCopyProgress {
			m.modal = modalState{}
		}
		if background {
			doneWord := "copied"
			if kind == opMove {
				doneWord = "moved"
			}
			doneBody := fmt.Sprintf("%d entr%s %s successfully.", sourceCount, pluralSuffix(sourceCount, "y", "ies"), doneWord)
			if sourceCount == 1 && len(msg.sourcePaths) == 1 {
				doneBody = filepath.Base(msg.sourcePaths[0]) + " " + doneWord + " successfully."
			}
			m.modal = modalState{
				kind:  modalNotice,
				title: strings.Title(operationVerb(kind)) + " complete",
				body:  doneBody,
			}
			cmd = tea.Batch(cmd, dismissNoticeCmd(time.Second))
		}
		return m, cmd

	case dismissNoticeMsg:
		if m.modal.kind == modalNotice {
			m.modal = modalState{}
		}
		return m, nil

	case dismissYankFlashMsg:
		m.yankFlashLine = -1
		m.syncPreviewContent()
		return m, nil

	case externalOpenMsg:
		if msg.err != nil {
			m.status = fmt.Sprintf("Open failed: %v", msg.err)
			return m, nil
		}
		m.status = fmt.Sprintf("Opened %s", filepath.Base(msg.path))
		return m, nil

	case tea.KeyMsg:
		if msg.String() != "y" {
			m.pendingY = false
		}
		if m.modal.kind != modalNone {
			return m.handleModalKey(msg)
		}
		if m.viewMode {
			if m.previewData.Kind == vfs.PreviewKindText && m.infoMode && !m.selectMode {
				switch {
				case key.Matches(msg, m.keys.Visual):
					return m.toggleVisualMode()
				case msg.String() == "y":
					return m.yankVisualSelection()
				}
			}
			switch {
			case key.Matches(msg, m.keys.Cancel):
				if m.visualMode {
					return m.exitVisualMode("Visual mode: off")
				}
				return m.exitViewMode()
			case key.Matches(msg, m.keys.View), msg.String() == "q":
				return m.exitViewMode()
			case key.Matches(msg, m.keys.Up):
				if m.visualMode {
					m.moveTextCursorLine(-1)
				} else {
					m.previewModel.LineUp(1)
				}
				return m, nil
			case msg.String() == "left":
				if m.visualMode {
					m.moveTextCursorCol(-1)
				}
				return m, nil
			case msg.String() == "h":
				if m.visualMode {
					m.moveTextCursorCol(-1)
				}
				return m, nil
			case key.Matches(msg, m.keys.Down):
				if m.visualMode {
					m.moveTextCursorLine(1)
				} else {
					m.previewModel.LineDown(1)
				}
				return m, nil
			case msg.String() == "right":
				if m.visualMode {
					m.moveTextCursorCol(1)
				}
				return m, nil
			case msg.String() == "l":
				if m.visualMode {
					m.moveTextCursorCol(1)
				}
				return m, nil
			case key.Matches(msg, m.keys.PageUp):
				if m.visualMode {
					m.moveTextCursorLine(-max(m.previewModel.Height-2, 1))
				} else {
					m.previewModel.LineUp(max(m.previewModel.Height-2, 1))
				}
				return m, nil
			case key.Matches(msg, m.keys.PageDown):
				if m.visualMode {
					m.moveTextCursorLine(max(m.previewModel.Height-2, 1))
				} else {
					m.previewModel.LineDown(max(m.previewModel.Height-2, 1))
				}
				return m, nil
			default:
				return m, nil
			}
		}

		if (m.cursorMode || m.visualMode) && m.previewData.Kind == vfs.PreviewKindText {
			switch {
			case key.Matches(msg, m.keys.Caret):
				return m.toggleCaretMode()
			case key.Matches(msg, m.keys.Visual):
				if m.visualMode {
					return m.exitVisualMode("Visual mode: off")
				}
				if m.cursorMode {
					return m.toggleVisualMode()
				}
				return m, nil
			case key.Matches(msg, m.keys.Cancel), msg.String() == "q":
				if m.visualMode {
					return m.exitVisualMode("Visual mode: off")
				}
				return m.exitCaretMode("Caret mode: off")
			case msg.String() == "y":
				if !m.visualMode {
					if m.pendingY {
						m.pendingY = false
						return m.yankCursorLine()
					}
					m.pendingY = true
					m.status = "Press y again to copy current line"
					return m, nil
				}
				return m.yankVisualSelection()
			case key.Matches(msg, m.keys.Up):
				m.moveTextCursorLine(-1)
				return m, nil
			case msg.String() == "left":
				m.moveTextCursorCol(-1)
				return m, nil
			case msg.String() == "h":
				m.moveTextCursorCol(-1)
				return m, nil
			case key.Matches(msg, m.keys.Down):
				m.moveTextCursorLine(1)
				return m, nil
			case msg.String() == "right":
				m.moveTextCursorCol(1)
				return m, nil
			case msg.String() == "l":
				m.moveTextCursorCol(1)
				return m, nil
			case key.Matches(msg, m.keys.PageUp):
				m.moveTextCursorLine(-max(m.previewModel.Height-2, 1))
				return m, nil
			case key.Matches(msg, m.keys.PageDown):
				m.moveTextCursorLine(max(m.previewModel.Height-2, 1))
				return m, nil
			case msg.String() == "w":
				m.moveTextCursorWordForward()
				return m, nil
			case msg.String() == "b":
				m.moveTextCursorWordBackward()
				return m, nil
			default:
				return m, nil
			}
		}

		// Copy file path when info mode is open
		if m.infoMode && m.copyPath != "" {
			switch msg.String() {
			case "y", "Y", "ctrl+c":
				if err := clipboard.WriteAll(m.copyPath); err != nil {
					m.status = fmt.Sprintf("Copy path error: %v", err)
				} else {
					m.status = fmt.Sprintf("Path copied: %s", m.copyPath)
				}
				return m, nil
			}
		}

		switch {
		case key.Matches(msg, m.keys.Quit):
			m.cleanupArchiveMounts()
			m.cleanupImageOverlay()
			return m, tea.Quit
		case key.Matches(msg, m.keys.Help):
			m.openHelpModal()
			return m, nil
		case key.Matches(msg, m.keys.Rename):
			m.openRenameModal()
			return m, nil
		case key.Matches(msg, m.keys.Cancel), msg.String() == "q":
			if m.infoMode {
				m.infoMode = false
				m.selectMode = false
				m.cursorMode = false
				m.visualMode = false
				m.copyPath = ""
				m.status = "Info pane closed"
				return m, nil
			}
			if len(m.activePane().MarkedEntries()) > 0 {
				m.activePane().ClearMarks()
				m.status = "Selection cleared"
				return m, m.loadPreviewCmd()
			}
			return m, nil
		case key.Matches(msg, m.keys.View):
			return m.handleView()
		case key.Matches(msg, m.keys.Caret):
			return m.toggleCaretMode()
		case key.Matches(msg, m.keys.Visual):
			if m.cursorMode {
				return m.toggleVisualMode()
			}
			return m, nil
		case key.Matches(msg, m.keys.Edit):
			return m.handleEdit()
		case key.Matches(msg, m.keys.Archive):
			return m.handleArchive()
		case key.Matches(msg, m.keys.Info):
			return m.toggleInfo()
		case key.Matches(msg, m.keys.SelectText):
			return m.toggleSelectMode()
		case key.Matches(msg, m.keys.ToggleHidden):
			return m.toggleHidden()
		case key.Matches(msg, m.keys.CycleTheme):
			return m.cycleTheme()
		case key.Matches(msg, m.keys.CycleSort):
			return m.cycleSort()
		case key.Matches(msg, m.keys.Switch):
			m.left.ClearMarks()
			m.right.ClearMarks()
			if m.active == PaneLeft {
				m.active = PaneRight
			} else {
				m.active = PaneLeft
			}
			m.status = fmt.Sprintf("Active pane: %s", strings.ToUpper(string(m.active)))
			return m, m.loadPreviewCmd()
		case key.Matches(msg, m.keys.Up):
			m.moveCursor(-1)
			return m, m.loadPreviewCmd()
		case key.Matches(msg, m.keys.Down):
			m.moveCursor(1)
			return m, m.loadPreviewCmd()
		case key.Matches(msg, m.keys.SelectUp):
			m.selectMoveCursor(-1)
			return m, m.loadPreviewCmd()
		case key.Matches(msg, m.keys.SelectDown):
			m.selectMoveCursor(1)
			return m, m.loadPreviewCmd()
		case key.Matches(msg, m.keys.PageUp):
			m.moveCursor(-max(m.bodyHeight()-6, 5))
			return m, m.loadPreviewCmd()
		case key.Matches(msg, m.keys.PageDown):
			m.moveCursor(max(m.bodyHeight()-6, 5))
			return m, m.loadPreviewCmd()
		case key.Matches(msg, m.keys.Open):
			return m.handleOpenSelected()
		case key.Matches(msg, m.keys.Back):
			if err := m.goParent(); err != nil {
				m.status = err.Error()
			}
			return m, m.loadPreviewCmd()
		case key.Matches(msg, m.keys.Refresh):
			return m.refreshAllPanes("Refreshed")
		case key.Matches(msg, m.keys.DirSize):
			return m.handleDirSize()
		case key.Matches(msg, m.keys.Copy):
			return m.handleTransfer(opCopy)
		case key.Matches(msg, m.keys.Move):
			return m.handleTransfer(opMove)
		case key.Matches(msg, m.keys.Mkdir):
			m.openMkdirModal()
			return m, nil
		case key.Matches(msg, m.keys.Delete):
			return m.handleDelete()
		}

	case tea.MouseMsg:
		return m.handleMouse(msg)
	}

	return m, nil
}

func (m Model) View() string {
	if m.width < 72 || m.height < 18 {
		return lipgloss.NewStyle().
			Foreground(m.palette.Warning).
			Padding(1, 2).
			Render("Terminal is too small for vcom. Resize the window.")
	}

	leftWidth, previewWidth, rightWidth := m.layoutWidths()
	bodyHeight := m.bodyHeight()
	gap := lipgloss.NewStyle().
		Width(m.cfg.UI.PaneGap).
		Height(bodyHeight).
		Background(m.palette.Panel).
		Render("")

	var panels string
	if m.viewMode && m.previewData.Kind == vfs.PreviewKindImage {
		panels = lipgloss.NewStyle().
			Width(m.width).
			Height(bodyHeight).
			Background(m.palette.Background).
			Render("")
	} else if m.viewMode && m.previewData.Kind == vfs.PreviewKindText {
		panels = renderSelectionPane(m.previewData, &m.previewModel, m.palette, m.width, bodyHeight)
	} else if m.viewMode {
		panels = renderPreviewPane(m.previewData, &m.previewModel, m.cfg, m.palette, m.width, bodyHeight, m.nerdIcons)
	} else if m.selectMode && m.infoMode {
		panels = renderSelectionPane(m.previewData, &m.previewModel, m.palette, m.width, bodyHeight)
	} else if m.infoMode {
		if m.active == PaneLeft {
			panels = lipgloss.JoinHorizontal(
				lipgloss.Top,
				renderPane(m.left, m.cfg, m.palette, leftWidth, bodyHeight, true, m.hoverIndexFor(PaneLeft), m.nerdIcons),
				gap,
				renderPreviewPane(m.previewData, &m.previewModel, m.cfg, m.palette, previewWidth, bodyHeight, m.nerdIcons),
			)
		} else {
			panels = lipgloss.JoinHorizontal(
				lipgloss.Top,
				renderPreviewPane(m.previewData, &m.previewModel, m.cfg, m.palette, previewWidth, bodyHeight, m.nerdIcons),
				gap,
				renderPane(m.right, m.cfg, m.palette, rightWidth, bodyHeight, true, m.hoverIndexFor(PaneRight), m.nerdIcons),
			)
		}
	} else {
		panels = lipgloss.JoinHorizontal(
			lipgloss.Top,
			renderPane(m.left, m.cfg, m.palette, leftWidth, bodyHeight, m.active == PaneLeft, m.hoverIndexFor(PaneLeft), m.nerdIcons),
			gap,
			renderPane(m.right, m.cfg, m.palette, rightWidth, bodyHeight, m.active == PaneRight, m.hoverIndexFor(PaneRight), m.nerdIcons),
		)
	}

	parts := make([]string, 0, 3)
	parts = append(parts, panels)
	if m.cfg.UI.ShowFooter && !m.viewMode {
		parts = append(parts, renderFooter(m))
	}

	view := lipgloss.NewStyle().
		Width(m.width).
		Height(m.height).
		Background(m.palette.Background).
		Foreground(m.palette.Text).
		Render(lipgloss.JoinVertical(lipgloss.Left, parts...))
	if m.modal.kind != modalNone {
		if m.overlay != nil {
			m.overlay.hide()
		}
		modalWidth := min(72, m.width-8)
		if m.modal.kind == modalHelp {
			modalWidth = min(96, m.width-8)
		}
		view = overlayCenter(view, renderModal(m, m.palette, modalWidth), m.width)
		return view
	}
	m.syncImageOverlay(leftWidth, previewWidth, bodyHeight)
	return view
}

func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
	switch m.modal.kind {
	case modalMkdir, modalRename:
		switch {
		case msg.String() == "esc":
			m.modal = modalState{}
			m.status = "Cancelled"
			return m, nil
		case key.Matches(msg, m.keys.Confirm):
			value := strings.TrimSpace(m.modal.input.Value())
			if value == "" {
				if m.modal.kind == modalMkdir {
					m.status = "Directory name must not be empty"
				} else {
					m.status = "Name must not be empty"
				}
				return m, nil
			}
			m.busy = true
			if m.modal.kind == modalMkdir {
				return m, mkdirCmd(m.activePane().Path, value)
			}
			selected, ok := m.activePane().Selected()
			if !ok || selected.IsParent {
				m.busy = false
				m.modal = modalState{}
				m.status = "No entry selected"
				return m, nil
			}
			return m, renameCmd(selected.Path, value)
		}

		var cmd tea.Cmd
		m.modal.input, cmd = m.modal.input.Update(msg)
		return m, cmd

	case modalConfirm:
		switch {
		case isModalCloseKey(msg, m.keys):
			m.modal = modalState{}
			m.status = "Cancelled"
			return m, nil
		case key.Matches(msg, m.keys.Confirm):
			if m.modal.pending == nil {
				m.modal = modalState{}
				m.status = "Nothing to confirm"
				return m, nil
			}
			pending := *m.modal.pending
			m.modal = modalState{}
			if pending.kind == opCopy || pending.kind == opMove {
				if m.copyJob != nil {
					m.status = "Transfer is already running"
					return m, nil
				}
				m.busy = true
				return m, m.startCopyJob(pending.kind, pending.sourcePaths, pending.targetDir, pending.overwrite, pending.stats)
			}
			m.busy = true
			return m, pending.cmd()
		}

	case modalArchiveType:
		switch {
		case isModalCloseKey(msg, m.keys):
			m.modal = modalState{}
			m.status = "Cancelled"
			return m, nil
		case key.Matches(msg, m.keys.Confirm):
			if m.modal.pending == nil {
				m.modal = modalState{}
				m.status = "Nothing to confirm"
				return m, nil
			}
			pending := *m.modal.pending
			m.modal = modalState{}
			if m.archiveJob != nil {
				m.status = "Archive is already running"
				return m, nil
			}
			m.busy = true
			return m, m.startArchiveJob(pending.sourcePaths, pending.targetDir, m.archiveFormat, pending.stats)
		case msg.String() == "f":
			switch m.archiveFormat {
			case "zip":
				m.archiveFormat = "tar"
			case "tar":
				m.archiveFormat = "tar.gz"
			default:
				m.archiveFormat = "zip"
			}
			m.modal.note = fmt.Sprintf(
				"Format: %s  (f to change)\nEnter / y to confirm, Esc / n to cancel",
				m.archiveFormat,
			)
			return m, nil
		}
		return m, nil

	case modalArchiveProgress:
		if key.Matches(msg, m.keys.Background) {
			if m.archiveJob == nil {
				m.modal = modalState{}
				return m, nil
			}
			m.archiveJob.background = true
			m.modal = modalState{}
			m.status = "Archive continues in background"
			return m, nil
		}
		if key.Matches(msg, m.keys.ProgressCancel) {
			if m.archiveJob == nil {
				m.modal = modalState{}
				return m, nil
			}
			if m.archiveJob.cancel != nil {
				m.archiveJob.cancel()
			}
			m.status = "Archiving cancelling..."
			return m, nil
		}
		return m, nil

	case modalCopyProgress:
		if key.Matches(msg, m.keys.Background) {
			if m.copyJob == nil {
				m.modal = modalState{}
				return m, nil
			}
			m.copyJob.background = true
			m.modal = modalState{}
			m.status = "Transfer continues in background"
			return m, nil
		}
		if key.Matches(msg, m.keys.ProgressCancel) {
			if m.copyJob == nil {
				m.modal = modalState{}
				return m, nil
			}
			if m.copyJob.cancel != nil {
				m.copyJob.cancel()
			}
			m.status = strings.Title(operationVerb(m.copyJob.kind)) + " cancelling..."
			return m, nil
		}
		return m, nil

	case modalNotice:
		if key.Matches(msg, m.keys.Confirm) || isModalCloseKey(msg, m.keys) {
			m.modal = modalState{}
		}
		return m, nil

	case modalHelp:
		if isModalCloseKey(msg, m.keys) || key.Matches(msg, m.keys.Confirm) || key.Matches(msg, m.keys.Help) {
			m.modal = modalState{}
			m.status = "Help closed"
		}
		return m, nil
	}

	return m, nil
}

func isModalCloseKey(msg tea.KeyMsg, keys KeyMap) bool {
	return key.Matches(msg, keys.Cancel) || msg.String() == "q"
}

func (m *Model) reloadPane(id PaneID, preserve string) error {
	pane := m.paneByID(id)
	entries, err := vfs.ListDir(pane.Path, vfs.ListOptions{
		ShowHidden:  m.cfg.Browser.ShowHidden,
		DirsFirst:   m.cfg.Browser.DirsFirst,
		SortBy:      m.cfg.Browser.Sort.By,
		SortReverse: m.cfg.Browser.Sort.Reverse,
	})
	if err != nil {
		return err
	}
	pane.SetEntries(entries, strings.ToLower(preserve))
	return nil
}

func (m *Model) refreshAllPanes(status string) (tea.Model, tea.Cmd) {
	leftSelected := selectedName(&m.left)
	rightSelected := selectedName(&m.right)
	if err := m.reloadPane(PaneLeft, leftSelected); err != nil {
		m.status = err.Error()
		return m, nil
	}
	if err := m.reloadPane(PaneRight, rightSelected); err != nil {
		m.status = err.Error()
		return m, nil
	}
	m.status = status
	return m, m.loadPreviewCmd()
}

func (m *Model) moveCursor(delta int) {
	pane := m.activePane()
	pane.Move(delta, max(m.bodyHeight()-4, 1))
	m.hover = hoverState{}
}

func (m *Model) selectMoveCursor(delta int) {
	pane := m.activePane()
	if selected, ok := pane.Selected(); ok && !selected.IsParent {
		pane.ToggleMarked(selected.Path)
	}
	pane.Move(delta, max(m.bodyHeight()-4, 1))
	m.hover = hoverState{}
}

func (m *Model) operationSources() []string {
	pane := m.activePane()
	marked := pane.MarkedEntries()
	if len(marked) > 0 {
		paths := make([]string, 0, len(marked))
		for _, entry := range marked {
			if entry.IsParent {
				continue
			}
			paths = append(paths, entry.Path)
		}
		return paths
	}
	selected, ok := pane.Selected()
	if !ok || selected.IsParent {
		return nil
	}
	return []string{selected.Path}
}

func (m *Model) enterSelected() error {
	m.hover = hoverState{}
	pane := m.activePane()
	selected, ok := pane.Selected()
	if !ok {
		return nil
	}
	if !selected.IsDir {
		m.status = "File is shown in the middle pane. Use F3 for pager or F4 for editor."
		return nil
	}
	// When inside an archive mount and selecting "..", use archive-aware
	// navigation (goParent) instead of blindly setting pane.Path to the
	// parent directory (which would be /tmp for a temp-mounted archive).
	if selected.IsParent {
		if _, archiveMounted := pane.CurrentArchive(); archiveMounted {
			return m.goParent()
		}
	}
	currentName := selected.Name
	pane.Path = selected.Path
	if err := m.reloadPane(pane.ID, currentName); err != nil {
		return err
	}
	m.status = fmt.Sprintf("Entered %s", pane.Path)
	return nil
}

func (m *Model) handleOpenSelected() (tea.Model, tea.Cmd) {
	selected, ok := m.activePane().Selected()
	if !ok {
		return m, nil
	}

	if selected.IsDir {
		if err := m.enterSelected(); err != nil {
			m.status = err.Error()
			return m, nil
		}
		return m, m.loadPreviewCmd()
	}

	if isArchiveEntry(selected) {
		if err := m.enterArchive(selected); err != nil {
			m.status = err.Error()
			return m, nil
		}
		return m, m.loadPreviewCmd()
	}

	if isEditableEntry(selected) {
		return m.handleEdit()
	}
	return m.handleOpenExternal()
}

func (m *Model) goParent() error {
	m.hover = hoverState{}
	pane := m.activePane()

	if mount, ok := pane.CurrentArchive(); ok {
		root := filepath.Clean(mount.RootPath)
		current := filepath.Clean(pane.Path)
		if current == root {
			// At archive root — pop archive and return to the directory containing it
			if _, popped := pane.PopArchive(); popped {
				_ = os.RemoveAll(mount.TempDir)
			}
			pane.Path = mount.ParentPath
			if err := m.reloadPane(pane.ID, filepath.Base(mount.SourcePath)); err != nil {
				return err
			}
			m.status = fmt.Sprintf("Closed archive %s", filepath.Base(mount.SourcePath))
			return nil
		}
		// Inside archive subdirectory — go up one level within the archive
		parent := filepath.Dir(current)
		pane.Path = parent
		if err := m.reloadPane(pane.ID, filepath.Base(current)); err != nil {
			return err
		}
		m.status = fmt.Sprintf("Moved to %s", parent)
		return nil
	}

	parent := filepath.Dir(pane.Path)
	if parent == pane.Path {
		return nil
	}
	currentName := filepath.Base(pane.Path)
	pane.Path = parent
	if err := m.reloadPane(pane.ID, currentName); err != nil {
		return err
	}
	m.status = fmt.Sprintf("Moved to %s", parent)
	return nil
}

func (m Model) loadPreviewCmd() tea.Cmd {
	selected, ok := m.activePane().Selected()
	if !ok {
		return func() tea.Msg {
			return previewMsg{
				entryPath: "",
				preview: vfs.Preview{
					Kind:  vfs.PreviewKindEmpty,
					Title: "Nothing selected",
					Body:  "No entry selected.",
				},
			}
		}
	}

	options := vfs.PreviewOptions{
		ShowHidden:            m.cfg.Browser.ShowHidden,
		DirsFirst:             m.cfg.Browser.DirsFirst,
		SortBy:                m.cfg.Browser.Sort.By,
		SortReverse:           m.cfg.Browser.Sort.Reverse,
		MaxPreviewBytes:       m.cfg.Preview.MaxPreviewBytes,
		DirectoryPreviewLimit: m.cfg.Preview.DirectoryPreviewLimit,
		HumanReadableSize:     m.cfg.Browser.HumanReadableSize,
		ThemeName:             m.cfg.UI.Theme,
		UseNerdIcons:          m.nerdIcons,
	}

	return func() tea.Msg {
		return previewMsg{
			entryPath: selected.Path,
			preview:   vfs.BuildPreview(selected, options),
		}
	}
}

func (m *Model) handleDirSize() (tea.Model, tea.Cmd) {
	if !m.cfg.Behavior.CalculateDirSizeOnSpace {
		m.status = "Directory size on Space is disabled in config"
		return m, nil
	}
	selected, ok := m.activePane().Selected()
	if !ok || !selected.IsDir || selected.IsParent {
		m.status = "Select a directory first"
		return m, nil
	}
	if selected.DirSizeKnown {
		m.status = fmt.Sprintf("Directory size: %s", formatSize(selected.Size, m.cfg.Browser.HumanReadableSize))
		return m, nil
	}

	m.busy = true
	m.status = fmt.Sprintf("Calculating directory size for %s", selected.DisplayName())
	return m, dirSizeCmd(selected.Path)
}

func (m *Model) handleTransfer(kind fileOpKind) (tea.Model, tea.Cmd) {
	if m.activePane().InArchive() && kind != opCopy {
		m.status = "Archive mode is read-only; only copy is allowed"
		return m, nil
	}

	sources := m.operationSources()
	if len(sources) == 0 {
		m.status = fmt.Sprintf("Nothing to %s", operationVerb(kind))
		return m, nil
	}

	targetDir := m.passivePane().Path
	existingTargets := 0
	for _, sourcePath := range sources {
		targetPath := filepath.Join(targetDir, filepath.Base(sourcePath))
		exists, err := vfs.PathExists(targetPath)
		if err != nil {
			m.status = err.Error()
			return m, nil
		}
		if exists {
			existingTargets++
		}
	}

	if kind == opCopy || kind == opMove {
		if m.copyJob != nil {
			m.status = "Transfer is already running"
			return m, nil
		}

		overwrite := existingTargets > 0
		if existingTargets > 0 && !m.cfg.Behavior.ConfirmOverwrite {
			overwrite = true
		}
		m.busy = true
		m.status = fmt.Sprintf("Calculating %s size", operationVerb(kind))
		return m, copyPlanCmd(kind, sources, targetDir, overwrite, existingTargets)
	}
	return m, nil
}

func (m *Model) handleArchive() (tea.Model, tea.Cmd) {
	sources := m.operationSources()
	if len(sources) == 0 {
		m.status = "Nothing to archive"
		return m, nil
	}

	if m.archiveJob != nil {
		m.status = "Archive is already running"
		return m, nil
	}

	m.busy = true
	m.status = "Calculating archive size"
	return m, archivePlanCmd(sources, m.passivePane().Path)
}

func (m *Model) handleDelete() (tea.Model, tea.Cmd) {
	if m.activePane().InArchive() {
		m.status = "Archive mode is read-only; delete is disabled"
		return m, nil
	}

	sources := m.operationSources()
	if len(sources) == 0 {
		m.status = "Nothing to delete"
		return m, nil
	}
	if !m.cfg.Behavior.ConfirmDelete {
		m.busy = true
		m.status = fmt.Sprintf("Deleting %d entr%s", len(sources), pluralSuffix(len(sources), "y", "ies"))
		return m, deletePathsCmd(sources)
	}

	m.busy = true
	m.status = "Calculating delete size"
	return m, deletePlanCmd(sources)
}

func (m *Model) handleView() (tea.Model, tea.Cmd) {
	selected, ok := m.activePane().Selected()
	if !ok || selected.IsParent || selected.IsDir {
		m.status = "Select a file to view"
		return m, nil
	}
	if m.viewMode {
		return m.exitViewMode()
	}

	m.viewPrevInfo = m.infoMode
	m.infoMode = true
	m.selectMode = m.previewData.Kind == vfs.PreviewKindText
	m.visualMode = false
	m.viewMode = true
	m.resizePreview()
	m.syncPreviewContent()
	m.status = "View mode: F3/Esc/q to close"
	if m.selectMode {
		return m, tea.Batch(m.loadPreviewCmd(), disableMouseCmd())
	}
	return m, tea.Batch(m.loadPreviewCmd(), enableMouseCmd())
}

func (m *Model) exitViewMode() (tea.Model, tea.Cmd) {
	if !m.viewMode {
		return m, nil
	}
	m.viewMode = false
	m.selectMode = false
	m.visualMode = false
	m.infoMode = m.viewPrevInfo
	m.resizePreview()
	m.syncPreviewContent()
	m.status = "View mode: off"
	return m, tea.Batch(m.loadPreviewCmd(), enableMouseCmd())
}

func (m *Model) handleOpenExternal() (tea.Model, tea.Cmd) {
	selected, ok := m.activePane().Selected()
	if !ok || selected.IsParent || selected.IsDir {
		m.status = "Select a file to open"
		return m, nil
	}

	command, name, err := externalCommand("", []string{"xdg-open", "open"}, selected.Path)
	if err != nil {
		m.status = "No system opener found (tried xdg-open/open)"
		return m, nil
	}

	m.cleanupImageOverlay()
	m.status = fmt.Sprintf("Opening %s with %s", selected.DisplayName(), name)
	return m, startExternalOpenCmd(command, selected.Path)
}

func (m *Model) handleEdit() (tea.Model, tea.Cmd) {
	selected, ok := m.activePane().Selected()
	if !ok || selected.IsParent || selected.IsDir {
		m.status = "Select a file to edit"
		return m, nil
	}

	command, name, err := externalCommandFromEnv([]string{"VISUAL", "EDITOR"}, []string{"nvim", "vim", "vi", "nano"}, selected.Path)
	if err != nil {
		m.status = "Set $VISUAL/$EDITOR or install nvim/vim/vi/nano to enable F4 editing"
		return m, nil
	}

	m.cleanupImageOverlay()
	m.status = fmt.Sprintf("Opening %s with %s", selected.DisplayName(), name)
	return m, tea.ExecProcess(command, func(err error) tea.Msg {
		return opMsg{kind: opEdit, sourcePath: selected.Path, err: err}
	})
}

func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
	if m.viewMode {
		switch {
		case msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonWheelUp:
			m.previewModel.LineUp(3)
			return m, nil
		case msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonWheelDown:
			m.previewModel.LineDown(3)
			return m, nil
		default:
			return m, nil
		}
	}

	switch {
	case msg.Action == tea.MouseActionMotion:
		paneID, index, ok := m.mouseTarget(msg.X, msg.Y)
		if ok {
			m.hover = hoverState{pane: paneID, index: index, ok: true}
		} else {
			m.hover = hoverState{}
		}
		return m, nil
	case msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonWheelUp:
		if m.infoMode && m.mouseOverPreview(msg.X, msg.Y) {
			m.previewModel.LineUp(3)
			return m, nil
		}
		m.moveCursor(-1)
		return m, m.loadPreviewCmd()
	case msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonWheelDown:
		if m.infoMode && m.mouseOverPreview(msg.X, msg.Y) {
			m.previewModel.LineDown(3)
			return m, nil
		}
		m.moveCursor(1)
		return m, m.loadPreviewCmd()
	case msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft:
		if m.copyPath != "" && m.mouseOverPathLine(msg.X, msg.Y) {
			if err := clipboard.WriteAll(m.copyPath); err != nil {
				m.status = fmt.Sprintf("Copy path error: %v", err)
			} else {
				m.status = fmt.Sprintf("Path copied: %s", m.copyPath)
			}
			return m, nil
		}

		paneID, index, ok := m.mouseTarget(msg.X, msg.Y)
		if !ok {
			return m, nil
		}
		m.hover = hoverState{pane: paneID, index: index, ok: true}
		if paneID != m.active {
			m.left.ClearMarks()
			m.right.ClearMarks()
		}
		m.active = paneID
		pane := m.paneByID(paneID)
		if index >= 0 && index < len(pane.Entries) {
			pane.Cursor = index
			pane.EnsureVisible(max(m.bodyHeight()-4, 1))
		}

		now := time.Now()
		doubleClick := m.lastClick.pane == paneID && m.lastClick.index == index && now.Sub(m.lastClick.at) <= 450*time.Millisecond
		m.lastClick = mouseClickState{pane: paneID, index: index, at: now}
		if doubleClick {
			return m.handleOpenSelected()
		}
		m.status = fmt.Sprintf("Selected %s pane", strings.ToUpper(string(paneID)))
		return m, m.loadPreviewCmd()
	case msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonRight:
		if m.infoMode && m.mouseOverPreview(msg.X, msg.Y) {
			m.infoMode = false
			m.cursorMode = false
			m.visualMode = false
			m.resizePreview()
			m.syncPreviewContent()
			m.copyPath = ""
			m.status = "Info mode: off"
			return m, nil
		}

		paneID, index, ok := m.mouseTarget(msg.X, msg.Y)
		if !ok {
			return m, nil
		}

		if m.infoMode && paneID == m.active && index == m.activePane().Cursor {
			m.infoMode = false
			m.cursorMode = false
			m.visualMode = false
			m.hover = hoverState{pane: paneID, index: index, ok: true}
			m.copyPath = ""
			m.status = "Info mode: off"
			return m, nil
		}

		m.hover = hoverState{pane: paneID, index: index, ok: true}
		if paneID != m.active {
			m.left.ClearMarks()
			m.right.ClearMarks()
		}
		m.active = paneID
		pane := m.paneByID(paneID)
		if index >= 0 && index < len(pane.Entries) {
			pane.Cursor = index
			pane.EnsureVisible(max(m.bodyHeight()-4, 1))
		}
		m.infoMode = true
		m.resizePreview()
		m.syncPreviewContent()
		m.status = fmt.Sprintf("Info mode: %s selection", strings.ToUpper(string(paneID)))
		return m, m.loadPreviewCmd()
	default:
		return m, nil
	}
}

func (m *Model) toggleInfo() (tea.Model, tea.Cmd) {
	m.infoMode = !m.infoMode
	m.resizePreview()
	m.syncPreviewContent()
	if m.infoMode {
		m.status = fmt.Sprintf("Info mode: %s selection", strings.ToUpper(string(m.active)))
		return m, m.loadPreviewCmd()
	}
	wasSelect := m.selectMode
	if m.selectMode {
		m.selectMode = false
	}
	if m.cursorMode {
		m.cursorMode = false
	}
	if m.visualMode {
		m.visualMode = false
	}
	if wasSelect {
		return m, enableMouseCmd()
	}
	m.status = "Info mode: off"
	return m, nil
}

func (m *Model) toggleSelectMode() (tea.Model, tea.Cmd) {
	if m.viewMode {
		m.status = "Use v/y in F3 view for keyboard selection"
		return m, nil
	}
	if m.selectMode {
		m.selectMode = false
		m.status = "Text selection mode: off"
		return m, enableMouseCmd()
	}
	if !m.infoMode || m.previewData.Kind != vfs.PreviewKindText {
		m.status = "Text selection mode works only for text preview in info pane"
		return m, nil
	}
	m.selectMode = true
	m.status = "Text selection mode: on"
	return m, disableMouseCmd()
}

func (m *Model) toggleCaretMode() (tea.Model, tea.Cmd) {
	if m.viewMode {
		m.status = "F3 uses plain text mouse selection"
		return m, nil
	}
	if m.selectMode {
		m.status = "Disable Ctrl+T mouse selection first"
		return m, nil
	}
	if !m.infoMode || m.previewData.Kind != vfs.PreviewKindText {
		m.status = "Caret mode works only for text preview in info pane"
		return m, nil
	}
	if m.cursorMode {
		return m.exitCaretMode("Caret mode: off")
	}

	lineCount := len(m.previewPlainLines())
	if lineCount == 0 {
		m.status = "Nothing to navigate"
		return m, nil
	}
	m.cursorMode = true
	m.cursorLine = clamp(m.previewModel.YOffset, 0, lineCount-1)
	m.cursorCol = clamp(m.cursorCol, 0, m.lineRuneCount(m.cursorLine))
	m.visualMode = false
	m.ensureTextCursorVisible()
	m.syncPreviewContent()
	m.status = "Caret mode: h/j/k/l move, v select, Esc exit"
	return m, nil
}

func (m *Model) exitCaretMode(status string) (tea.Model, tea.Cmd) {
	if !m.cursorMode {
		m.status = status
		return m, nil
	}
	m.cursorMode = false
	m.visualMode = false
	m.syncPreviewContent()
	m.status = status
	return m, nil
}

func (m *Model) toggleVisualMode() (tea.Model, tea.Cmd) {
	if m.viewMode {
		m.status = "F3 uses plain text mouse selection; visual mode is for info pane"
		return m, nil
	}
	if m.selectMode {
		m.status = "Disable Ctrl+T mouse selection first"
		return m, nil
	}
	if !m.infoMode || m.previewData.Kind != vfs.PreviewKindText {
		m.status = "Visual mode works only for text preview"
		return m, nil
	}
	if m.visualMode {
		return m.exitVisualMode("Visual mode: off")
	}

	lineCount := len(m.previewPlainLines())
	if lineCount == 0 {
		m.status = "Nothing to select"
		return m, nil
	}
	start := clamp(m.previewModel.YOffset, 0, lineCount-1)
	if m.cursorMode {
		start = clamp(m.cursorLine, 0, lineCount-1)
	} else {
		m.cursorMode = true
		m.cursorLine = start
		m.cursorCol = 0
	}
	m.visualMode = true
	m.visualAnchor = start
	m.visualAnchorCol = m.cursorCol
	m.ensureTextCursorVisible()
	m.syncPreviewContent()
	m.status = "Visual mode: h/j/k/l move, y copy, Esc exit"
	return m, nil
}

func (m *Model) exitVisualMode(status string) (tea.Model, tea.Cmd) {
	if !m.visualMode {
		m.status = status
		return m, nil
	}
	m.visualMode = false
	m.syncPreviewContent()
	m.status = status
	return m, nil
}

func (m *Model) toggleHidden() (tea.Model, tea.Cmd) {
	m.cfg.Browser.ShowHidden = !m.cfg.Browser.ShowHidden
	return m.refreshAllPanes(fmt.Sprintf("Show hidden: %t", m.cfg.Browser.ShowHidden))
}

func (m *Model) cycleTheme() (tea.Model, tea.Cmd) {
	next := theme.Next(m.cfg.UI.Theme)
	palette, err := theme.Resolve(next)
	if err != nil {
		m.status = err.Error()
		return m, nil
	}
	m.cfg.UI.Theme = next
	m.palette = palette
	savedPath, saveErr := config.Save(m.cfg, m.configPath)
	if saveErr != nil {
		m.status = fmt.Sprintf("Theme: %s (save failed: %v)", next, saveErr)
		return m, nil
	}
	m.configPath = savedPath
	m.status = fmt.Sprintf("Theme: %s (saved)", next)
	return m, nil
}

func copyTextToClipboard(text string) error {
	if err := clipboard.WriteAll(text); err == nil {
		return nil
	}
	_, err := fmt.Fprint(os.Stderr, ansi.SetSystemClipboard(text))
	return err
}

func (m *Model) yankVisualSelection() (tea.Model, tea.Cmd) {
	if !m.visualMode || m.previewData.Kind != vfs.PreviewKindText {
		m.status = "Visual mode is not active"
		return m, nil
	}

	lines := m.previewPlainLines()
	if len(lines) == 0 {
		return m.exitVisualMode("Nothing to copy")
	}
	startLine, startCol, endLine, endCol := m.visualSelectionBounds()
	startLine = clamp(startLine, 0, len(lines)-1)
	endLine = clamp(endLine, 0, len(lines)-1)

	parts := make([]string, 0, endLine-startLine+1)
	for line := startLine; line <= endLine; line++ {
		raw := lines[line]
		lineStart := 0
		lineEnd := len([]rune(raw))
		if line == startLine {
			lineStart = clamp(startCol, 0, lineEnd)
		}
		if line == endLine {
			lineEnd = clamp(endCol, lineStart, len([]rune(raw)))
		}
		parts = append(parts, sliceRunes(raw, lineStart, lineEnd))
	}
	text := strings.Join(parts, "\n")
	if err := copyTextToClipboard(text); err != nil {
		m.status = fmt.Sprintf("Copy failed: %v", err)
		return m, nil
	}
	return m.exitVisualMode("Copied selection")
}

func (m *Model) yankCursorLine() (tea.Model, tea.Cmd) {
	if !m.cursorMode || m.previewData.Kind != vfs.PreviewKindText {
		m.status = "Caret mode is not active"
		return m, nil
	}
	lines := m.previewPlainLines()
	if len(lines) == 0 {
		m.status = "Nothing to copy"
		return m, nil
	}
	line := clamp(m.cursorLine, 0, len(lines)-1)
	if err := copyTextToClipboard(lines[line]); err != nil {
		m.status = fmt.Sprintf("Copy failed: %v", err)
		return m, nil
	}
	m.yankFlashLine = line
	m.syncPreviewContent()
	m.status = "Copied current line"
	return m, dismissYankFlashCmd(140 * time.Millisecond)
}

func (m *Model) cycleSort() (tea.Model, tea.Cmd) {
	order := []string{"name", "modified", "size", "created", "extension"}
	current := strings.ToLower(strings.TrimSpace(m.cfg.Browser.Sort.By))
	next := order[0]
	for idx, value := range order {
		if value == current {
			next = order[(idx+1)%len(order)]
			break
		}
	}
	m.cfg.Browser.Sort.By = next
	return m.refreshAllPanes(fmt.Sprintf("Sort: %s", next))
}

func (m *Model) openMkdirModal() {
	if m.activePane().InArchive() {
		m.status = "Archive mode is read-only; create directory is disabled"
		return
	}

	input := textinput.New()
	input.Placeholder = "new-directory"
	input.Focus()
	input.CharLimit = 128
	input.Width = 42

	m.modal = modalState{
		kind:  modalMkdir,
		title: "Create directory",
		body:  fmt.Sprintf("Active pane: %s", m.activePane().Path),
		note:  "Enter to confirm, Esc to cancel",
		input: input,
	}
}

func (m *Model) openRenameModal() {
	if m.activePane().InArchive() {
		m.status = "Archive mode is read-only; rename is disabled"
		return
	}

	selected, ok := m.activePane().Selected()
	if !ok || selected.IsParent {
		m.status = "Select an entry to rename"
		return
	}

	input := textinput.New()
	input.SetValue(selected.Name)
	input.Focus()
	input.CharLimit = 255
	input.Width = 42

	m.modal = modalState{
		kind:  modalRename,
		title: "Rename entry",
		body:  fmt.Sprintf("Path: %s", selected.Path),
		note:  "Enter to confirm, Esc to cancel",
		input: input,
	}
}

func (m *Model) openConfirmModal(title, body, note string, pending pendingOperation) {
	m.modal = modalState{
		kind:    modalConfirm,
		title:   title,
		body:    body,
		note:    note,
		pending: &pending,
	}
}

func (m *Model) openHelpModal() {
	sections := []string{
		"Navigation",
		"  j / Down       move down",
		"  k / Up         move up",
		"  Shift+Down/J   extend selection down",
		"  Shift+Up/K     extend selection up",
		"  PgUp / b       page up",
		"  Enter / Right  open selected entry",
		"  Backspace/Left  go to parent directory",
		"  Tab / h / l    switch active pane",
		"  F2 / r         rename selected entry",
		"  Ctrl+r         refresh both panes",
		"",
		"View and Panels",
		"  F9 / o         toggle preview/info pane",
		"  F3             plain text view or fullscreen image viewer",
		"  i              show text caret in preview pane",
		"  v              start visual selection from caret",
		"  F3 / Esc / q   close view mode",
		"  yy             copy current line in caret mode",
		"  y              copy visual selection to clipboard",
		"  h / l          move caret left/right",
		"  w / b          move caret by word",
		"  q / Esc        close caret/info mode",
		"  Ctrl+t         mouse selection mode in text preview pane",
		"  Space          calculate selected directory size",
		"  s              cycle sort mode",
		"  .              toggle hidden files",
		"  t              cycle theme",
		"",
		"Dialogs and Transfers",
		"  Enter / y      confirm action",
		"  Esc / n        cancel action",
		"  b              run copy/move in background (progress dialog)",
		"  c              cancel active copy/move transfer",
		"  F5/F6/F8       apply to marked entries when selection exists",
		"",
		"Mouse",
		"  Left click     select entry and activate pane",
		"  Double click   open selected entry",
		"  Right click    toggle preview/info mode for clicked entry",
		"  Wheel          scroll list or preview area",
		"",
		"F-key actions are shown in the footer.",
	}

	m.modal = modalState{
		kind:  modalHelp,
		title: "Keyboard and Mouse Help",
		body:  strings.Join(sections, "\n"),
		note:  version + "  —  F1/? or Esc to close",
	}
	m.status = "Help opened"
}

func (m *Model) applyDirSize(path string, size int64) {
	for _, pane := range []*BrowserPane{&m.left, &m.right} {
		for idx := range pane.Entries {
			if pane.Entries[idx].Path == path {
				pane.Entries[idx].Size = size
				pane.Entries[idx].DirSizeKnown = true
			}
		}
	}
}

func (m *Model) applyPreview(preview vfs.Preview) {
	m.previewData = preview
	m.syncPreviewContent()
	if m.infoMode {
		m.copyPath = preview.Metadata.Path
	}
}

func (m *Model) syncPreviewContent() {
	content := m.previewData.Body
	if (m.cursorMode || m.visualMode) && m.previewData.Kind == vfs.PreviewKindText {
		content = m.renderTextCursorContent()
	}
	if m.cfg.Preview.WrapText && m.previewModel.Width > 0 {
		content = lipgloss.NewStyle().Width(m.previewModel.Width).Render(content)
	}
	m.previewModel.SetContent(content)
}

func (m Model) previewPlainLines() []string {
	content := m.previewData.PlainBody
	if content == "" {
		content = m.previewData.Body
	}
	content = strings.ReplaceAll(content, "\r\n", "\n")
	return strings.Split(content, "\n")
}

func (m Model) previewRenderedLines() []string {
	content := m.previewData.Body
	if content == "" {
		content = m.previewData.PlainBody
	}
	content = strings.ReplaceAll(content, "\r\n", "\n")
	return strings.Split(content, "\n")
}

func (m Model) lineRuneCount(line int) int {
	lines := m.previewPlainLines()
	if line < 0 || line >= len(lines) {
		return 0
	}
	return len([]rune(lines[line]))
}

func sliceRunes(text string, start int, end int) string {
	runes := []rune(text)
	start = clamp(start, 0, len(runes))
	end = clamp(end, start, len(runes))
	return string(runes[start:end])
}

func isWordRune(r rune) bool {
	return r == '_' || r == '-' || r == '.' ||
		(r >= '0' && r <= '9') ||
		(r >= 'a' && r <= 'z') ||
		(r >= 'A' && r <= 'Z') ||
		(r >= 'А' && r <= 'я') ||
		(r >= 'Ё' && r <= 'ё')
}

func normalizeSelection(startLine, startCol, endLine, endCol int) (int, int, int, int) {
	if startLine == endLine && startCol == endCol {
		return startLine, startCol, endLine, endCol + 1
	}
	if startLine < endLine || (startLine == endLine && startCol <= endCol) {
		return startLine, startCol, endLine, endCol + 1
	}
	return endLine, endCol, startLine, startCol + 1
}

func (m Model) visualSelectionBounds() (int, int, int, int) {
	return normalizeSelection(m.visualAnchor, m.visualAnchorCol, m.cursorLine, m.cursorCol)
}

func (m *Model) renderTextCursorContent() string {
	lines := append([]string(nil), m.previewRenderedLines()...)
	plainLines := m.previewPlainLines()
	if len(lines) == 0 {
		return ""
	}
	startLine, startCol, endLine, endCol := m.visualSelectionBounds()
	hasSelection := false
	if m.visualMode {
		startLine = clamp(startLine, 0, len(lines)-1)
		endLine = clamp(endLine, 0, len(lines)-1)
		hasSelection = startLine != endLine || startCol != endCol
	}

	selected := lipgloss.NewStyle().
		Background(m.palette.Marked).
		Foreground(m.palette.Text)
	flashed := lipgloss.NewStyle().
		Background(m.palette.Accent).
		Foreground(m.palette.Background).
		Bold(true)
	cursor := lipgloss.NewStyle().
		Background(m.palette.Warning).
		Foreground(m.palette.Background).
		Bold(true)
	gutterBase := lipgloss.NewStyle().
		Width(2).
		Foreground(m.palette.Muted)
	gutterAnchor := lipgloss.NewStyle().
		Width(2).
		Foreground(m.palette.Info).
		Bold(true)
	gutterCursor := lipgloss.NewStyle().
		Width(2).
		Foreground(m.palette.Accent).
		Bold(true)
	gutterBoth := lipgloss.NewStyle().
		Width(2).
		Foreground(m.palette.Warning).
		Bold(true)

	for idx := range lines {
		marker := "  "
		switch {
		case m.visualMode && idx == m.visualAnchor && idx == m.cursorLine:
			marker = gutterBoth.Render("◆ ")
		case m.visualMode && idx == m.visualAnchor:
			marker = gutterAnchor.Render("│ ")
		case idx == m.cursorLine:
			marker = gutterCursor.Render("▶ ")
		default:
			marker = gutterBase.Render("  ")
		}

		line := lines[idx]
		plain := ""
		if idx < len(plainLines) {
			plain = plainLines[idx]
		}
		lineLen := len([]rune(plain))
		cursorCol := clamp(m.cursorCol, 0, lineLen)

		if hasSelection && idx >= startLine && idx <= endLine {
			segStart := 0
			segEnd := lineLen
			if idx == startLine {
				segStart = clamp(startCol, 0, lineLen)
			}
			if idx == endLine {
				segEnd = clamp(endCol, segStart, lineLen)
			}
			left := ansi.Cut(line, 0, segStart)
			mid := ansi.Cut(line, segStart, segEnd)
			right := ansi.Cut(line, segEnd, lineLen)
			if mid != "" {
				line = left + selected.Render(mid) + right
			}
		}

		if idx == m.cursorLine {
			left := ansi.Cut(line, 0, cursorCol)
			mid := ansi.Cut(line, cursorCol, min(cursorCol+1, max(lineLen, cursorCol+1)))
			right := ansi.Cut(line, min(cursorCol+1, lineLen), lineLen)
			if cursorCol >= lineLen {
				mid = cursor.Render(" ")
				right = ""
			} else {
				mid = cursor.Render(mid)
			}
			line = left + mid + right
		}
		if idx == m.yankFlashLine {
			lines[idx] = flashed.Render(marker + line)
			continue
		}
		lines[idx] = marker + line
	}
	result := strings.Join(lines, "\n")

	// Replace full ANSI resets with background-preserving resets.
	// lipgloss.Render() appends \x1b[0m which resets the panel background
	// set by the outer renderPreviewContent wrapper. Instead, reset only
	// foreground and text attributes, then restore the panel background
	// so that gutter markers, cursor highlights, and selection highlights
	// don't break the panel background for subsequent text on the line.
	panelBG := lipgloss.NewStyle().Background(m.palette.Panel).Render("")
	bgCode := strings.TrimSuffix(panelBG, "\x1b[0m")
	inner := bgCode[2 : len(bgCode)-1]
	safeReset := "\x1b[39;22;23;24;59;" + inner + "m"
	result = strings.ReplaceAll(result, "\x1b[0m", safeReset)
	return result
}

func (m *Model) moveTextCursorWordForward() {
	if !m.cursorMode {
		return
	}
	lines := m.previewPlainLines()
	if len(lines) == 0 {
		return
	}

	line := clamp(m.cursorLine, 0, len(lines)-1)
	col := clamp(m.cursorCol, 0, len([]rune(lines[line])))
	for {
		runes := []rune(lines[line])
		for col < len(runes) && isWordRune(runes[col]) {
			col++
		}
		for col < len(runes) && !isWordRune(runes[col]) {
			col++
		}
		if col < len(runes) {
			m.cursorLine = line
			m.cursorCol = col
			m.ensureTextCursorVisible()
			m.syncPreviewContent()
			return
		}
		if line >= len(lines)-1 {
			m.cursorLine = line
			m.cursorCol = len(runes)
			m.ensureTextCursorVisible()
			m.syncPreviewContent()
			return
		}
		line++
		col = 0
	}
}

func (m *Model) moveTextCursorWordBackward() {
	if !m.cursorMode {
		return
	}
	lines := m.previewPlainLines()
	if len(lines) == 0 {
		return
	}

	line := clamp(m.cursorLine, 0, len(lines)-1)
	col := clamp(m.cursorCol, 0, len([]rune(lines[line])))
	for {
		runes := []rune(lines[line])
		if col > len(runes) {
			col = len(runes)
		}

		// Start from the character immediately before the cursor.
		if col == 0 {
			if line == 0 {
				m.cursorLine = 0
				m.cursorCol = 0
				m.ensureTextCursorVisible()
				m.syncPreviewContent()
				return
			}
			line--
			col = len([]rune(lines[line]))
			continue
		}

		col--
		for {
			runes = []rune(lines[line])
			for col >= 0 && !isWordRune(runes[col]) {
				col--
			}
			if col >= 0 {
				break
			}
			if line == 0 {
				m.cursorLine = 0
				m.cursorCol = 0
				m.ensureTextCursorVisible()
				m.syncPreviewContent()
				return
			}
			line--
			runes = []rune(lines[line])
			col = len(runes) - 1
		}

		for col > 0 && isWordRune(runes[col-1]) {
			col--
		}
		m.cursorLine = line
		m.cursorCol = col
		m.ensureTextCursorVisible()
		m.syncPreviewContent()
		return
	}
}

func (m *Model) activePane() *BrowserPane {
	if m.active == PaneLeft {
		return &m.left
	}
	return &m.right
}

func (m *Model) passivePane() *BrowserPane {
	if m.active == PaneLeft {
		return &m.right
	}
	return &m.left
}

func (m *Model) paneByID(id PaneID) *BrowserPane {
	if id == PaneLeft {
		return &m.left
	}
	return &m.right
}

func (m *Model) layoutWidths() (int, int, int) {
	total := m.width
	gaps := m.cfg.UI.PaneGap
	usable := max(total-gaps, 60)
	left := usable / 2
	right := usable - left

	if m.active == PaneLeft {
		if m.infoMode {
			return left, right, 0
		}
		return left, 0, right
	}
	if m.infoMode {
		return 0, left, right
	}
	return left, 0, right
}

func (m *Model) bodyHeight() int {
	height := m.height
	if m.cfg.UI.ShowFooter && !m.viewMode {
		height--
	}
	return max(height, 8)
}

func (m *Model) resizePreview() {
	_, previewWidth, _ := m.layoutWidths()
	metaHeight := 0
	if m.cfg.Preview.ShowMetadata {
		metaHeight = 7
	}
	innerWidth := max(previewWidth-2, 1)
	innerHeight := max(m.bodyHeight()-2, 1)
	m.previewModel.Width = max(innerWidth-2, 10)
	m.previewModel.Height = max(innerHeight-metaHeight-3, 3)
}

func (m *Model) moveTextCursorLine(delta int) {
	lines := m.previewPlainLines()
	if len(lines) == 0 {
		return
	}
	if !m.cursorMode {
		return
	}
	m.cursorLine = clamp(m.cursorLine+delta, 0, len(lines)-1)
	m.cursorCol = clamp(m.cursorCol, 0, m.lineRuneCount(m.cursorLine))
	m.ensureTextCursorVisible()
	m.syncPreviewContent()
}

func (m *Model) moveTextCursorCol(delta int) {
	if !m.cursorMode {
		return
	}
	m.cursorCol = clamp(m.cursorCol+delta, 0, m.lineRuneCount(m.cursorLine))
	m.ensureTextCursorVisible()
	m.syncPreviewContent()
}

func (m *Model) ensureTextCursorVisible() {
	if !m.cursorMode {
		return
	}
	visible := max(m.previewModel.Height, 1)
	if m.cursorLine < m.previewModel.YOffset {
		m.previewModel.SetYOffset(m.cursorLine)
		return
	}
	bottom := m.previewModel.YOffset + visible - 1
	if m.cursorLine > bottom {
		m.previewModel.SetYOffset(m.cursorLine - visible + 1)
	}
}

func renderPreviewPane(preview vfs.Preview, viewportModel *viewport.Model, cfg config.Config, palette theme.Palette, width int, height int, useNerdfont bool) string {
	innerWidth := max(width-2, 1)
	innerHeight := max(height-2, 1)
	contentWidth := max(innerWidth-2, 1)

	box := lipgloss.NewStyle().
		Width(innerWidth).
		Height(innerHeight).
		Background(palette.Panel).
		Foreground(palette.Text).
		BorderStyle(borderStyle(cfg.UI.Border)).
		BorderForeground(palette.BorderActive).
		BorderBackground(palette.Panel)

	title := lipgloss.NewStyle().
		Width(contentWidth).
		Padding(0, 1).
		Background(palette.Info).
		Foreground(palette.Background).
		Bold(true).
		Render("PREVIEW " + previewIcon(preview) + " " + preview.Title)

	parts := []string{title}
	usedHeight := lipgloss.Height(title)
	if cfg.Preview.ShowMetadata {
		metaView := renderMetadata(preview.Metadata, palette, innerWidth, useNerdfont)
		parts = append(parts, metaView)
		usedHeight += lipgloss.Height(metaView)
	}
	contentHeight := max(innerHeight-usedHeight, 3)
	viewportModel.Width = max(innerWidth-2, 10)
	viewportModel.Height = max(contentHeight-3, 1)

	// Directory previews: borrow the column layout from the browser pane
	// (renderPaneRows + renderColumnsHeader at the same innerWidth),
	// but non-interactive (no cursor, no selection).
	if preview.Kind == vfs.PreviewKindDirectory && len(preview.Entries) > 0 {
		dirPane := BrowserPane{Entries: preview.Entries}
		headerRow := renderColumnsHeader(cfg, innerWidth, palette, palette.Panel, useNerdfont)
		rows := renderPaneRows(dirPane, cfg, palette, innerWidth, contentHeight, false, -1, palette.Panel, useNerdfont)
		parts = append(parts, lipgloss.JoinVertical(lipgloss.Left, headerRow, rows))
	} else {
		parts = append(parts, renderPreviewContent(viewportModel, palette, innerWidth, contentHeight))
	}

	content := lipgloss.NewStyle().
		Width(innerWidth).
		MaxHeight(innerHeight).
		Background(palette.Panel).
		Render(lipgloss.JoinVertical(lipgloss.Left, parts...))
	return box.Render(content)
}

func renderSelectionPane(preview vfs.Preview, viewportModel *viewport.Model, palette theme.Palette, width int, height int) string {
	content := preview.PlainBody
	if strings.TrimSpace(content) == "" {
		content = preview.Body
	}
	viewportModel.Width = max(width, 1)
	viewportModel.Height = max(height, 1)
	viewportModel.SetContent(content)

	return lipgloss.NewStyle().
		Width(width).
		Height(height).
		Background(palette.Panel).
		Foreground(palette.Text).
		Render(viewportModel.View())
}

func renderMetadata(meta vfs.Metadata, palette theme.Palette, width int, useNerdfont bool) string {
	outerWidth := max(width-2, 1)
	innerWidth := max(outerWidth-2, 1)
	leftRows := []string{
		fmt.Sprintf("kind: %s", fallback(meta.Kind, "n/a")),
		fmt.Sprintf("size: %s", metaSize(meta)),
		fmt.Sprintf("created: %s", fallback(meta.CreatedAt, "n/a")),
	}
	rightRows := []string{
		fmt.Sprintf("modified: %s", fallback(meta.ModifiedAt, "n/a")),
		fmt.Sprintf("mode: %s", fallback(meta.Permissions, "n/a")),
	}
	if meta.ImageFormat != "" {
		rightRows = append(rightRows, fmt.Sprintf("image: %s %s", meta.ImageFormat, meta.ImageSize))
	}

	leftWidth := max(innerWidth/2, 18)
	if leftWidth > innerWidth {
		leftWidth = innerWidth
	}
	rightWidth := max(innerWidth-leftWidth, 0)
	columnHeight := max(len(leftRows), len(rightRows))
	left := lipgloss.NewStyle().
		Width(leftWidth).
		Height(columnHeight).
		Background(palette.PanelElevated).
		Foreground(palette.Muted).
		Render(strings.Join(leftRows, "\n"))
	right := lipgloss.NewStyle().
		Width(rightWidth).
		Height(columnHeight).
		Background(palette.PanelElevated).
		Foreground(palette.Text).
		Render(strings.Join(rightRows, "\n"))

	copyIcon := "📋"
	if useNerdfont {
		copyIcon = "󰆏"
	}
	iconWidth := lipgloss.Width(copyIcon)
	pathAvailable := max(innerWidth-6-iconWidth-3, 10) // "path: "=6, icon, spacing
	pathLine := lipgloss.NewStyle().
		Width(innerWidth).
		Background(palette.PanelElevated).
		Foreground(palette.Text).
		Render(fmt.Sprintf("path: %s %s", truncateMiddle(meta.Path, pathAvailable), copyIcon))

	return lipgloss.NewStyle().
		Width(outerWidth).
		Padding(0, 1).
		Background(palette.PanelElevated).
		BorderStyle(lipgloss.NormalBorder()).
		BorderBottom(true).
		BorderForeground(palette.Border).
		BorderBackground(palette.PanelElevated).
		Render(lipgloss.JoinVertical(
			lipgloss.Left,
			lipgloss.JoinHorizontal(lipgloss.Top, left, right),
			"",
			pathLine,
		))
}

func renderTitleBar(m Model) string {
	left := lipgloss.NewStyle().
		Foreground(m.palette.Background).
		Background(m.palette.Accent).
		Bold(true).
		Padding(0, 1).
		Render(strings.ToUpper(m.cfg.UI.AppTitle))

	centerParts := []string{
		fmt.Sprintf("theme:%s", m.cfg.UI.Theme),
		fmt.Sprintf("hidden:%t", m.cfg.Browser.ShowHidden),
		fmt.Sprintf("sort:%s", m.cfg.Browser.Sort.By),
		fmt.Sprintf("info:%t", m.infoMode),
	}
	center := lipgloss.NewStyle().
		Foreground(m.palette.Text).
		Background(m.palette.Panel).
		Padding(0, 1).
		Render(strings.Join(centerParts, "  "))

	configLabel := "cfg:default"
	if m.configPath != "" {
		configLabel = "cfg:" + filepath.Base(m.configPath)
	}
	right := lipgloss.NewStyle().
		Foreground(m.palette.Muted).
		Background(m.palette.Panel).
		Padding(0, 1).
		Render(configLabel)

	fillWidth := max(m.width-lipgloss.Width(left)-lipgloss.Width(center)-lipgloss.Width(right), 0)
	fill := lipgloss.NewStyle().
		Width(fillWidth).
		Background(m.palette.Panel).
		Render("")

	return left + center + fill + right
}

func renderStatus(m Model) string {
	active := m.activePane()
	selected, _ := active.Selected()
	summary := fmt.Sprintf(
		"%s | %s | items:%d | selected:%s",
		strings.ToUpper(string(m.active)),
		compactPath(active.Path, m.cfg.UI.PathDisplay),
		len(active.Entries),
		fallback(selected.DisplayName(), "n/a"),
	)

	return lipgloss.NewStyle().
		Width(m.width).
		Padding(0, 1).
		Background(m.palette.StatusBar).
		Foreground(m.palette.Text).
		Render(summary + "  ::  " + m.status)
}

func renderFooter(m Model) string {
	parts := make([]string, 0, 8)
	sep := lipgloss.NewStyle().Background(m.palette.Footer).Render("   ")
	prefix := lipgloss.NewStyle().Background(m.palette.Footer).Render(" ")
	for _, binding := range m.keys.ShortHelp() {
		help := binding.Help()
		if help.Key == "" || help.Desc == "" {
			continue
		}
		keyView := lipgloss.NewStyle().
			Background(m.palette.Footer).
			Foreground(m.palette.FooterKey).
			Bold(true).
			Render(help.Key)
		descView := lipgloss.NewStyle().
			Background(m.palette.Footer).
			Foreground(m.palette.Text).
			Render(" " + help.Desc)
		parts = append(parts, keyView+descView)
	}
	line := strings.Join(parts, sep)
	if m.selectMode {
		modeLabel := lipgloss.NewStyle().
			Background(m.palette.Footer).
			Foreground(m.palette.Info).
			Bold(true).
			Render("SELECT TEXT MODE")
		if line != "" {
			line += sep
		}
		line += modeLabel
	}
	if m.visualMode {
		modeLabel := lipgloss.NewStyle().
			Background(m.palette.Footer).
			Foreground(m.palette.Marked).
			Bold(true).
			Render("VISUAL MODE")
		if line != "" {
			line += sep
		}
		line += modeLabel
	}
	line = prefix + line
	line = ansi.Truncate(line, m.width, "")
	fill := m.width - ansi.StringWidth(line)
	if fill > 0 {
		line += lipgloss.NewStyle().
			Background(m.palette.Footer).
			Render(strings.Repeat(" ", fill))
	}
	return line
}

func renderModal(m Model, palette theme.Palette, width int) string {
	if m.modal.kind == modalCopyProgress && m.copyJob != nil {
		return renderCopyProgressModal(*m.copyJob, palette, width)
	}
	if m.modal.kind == modalArchiveProgress && m.archiveJob != nil {
		return renderArchiveProgressModal(*m.archiveJob, palette, width)
	}
	if m.modal.kind == modalHelp {
		return renderHelpModal(m.modal, palette, width)
	}

	modal := m.modal
	outerWidth := max(width, 8)
	contentWidth := max(outerWidth-6, 1)
	titleStyle := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Bold(true).Foreground(palette.Accent)
	noteStyle := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Foreground(palette.Muted)
	spacer := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Render(" ")

	box := lipgloss.NewStyle().
		Width(contentWidth).
		Padding(1, 2).
		Background(palette.Panel).
		Foreground(palette.Text).
		BorderStyle(lipgloss.DoubleBorder()).
		BorderForeground(palette.BorderActive).
		BorderBackground(palette.Panel)

	lines := []string{titleStyle.Render(modal.title), spacer}

	for _, raw := range strings.Split(modal.body, "\n") {
		lines = append(lines, renderModalBodyLine(raw, contentWidth, palette))
	}

	if modal.kind == modalMkdir || modal.kind == modalRename {
		lines = append(lines, spacer, lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Render(modal.input.View()))
	}
	if modal.note != "" {
		lines = append(lines, spacer)
		if modal.note == "confirm-actions" {
			lines = append(lines, renderModalNoteLine("Enter to confirm, Esc to cancel", contentWidth, palette, noteStyle))
		} else {
			for _, raw := range strings.Split(modal.note, "\n") {
				lines = append(lines, renderModalNoteLine(raw, contentWidth, palette, noteStyle))
			}
		}
	}

	return box.Render(strings.Join(lines, "\n"))
}

func renderModalBodyLine(raw string, width int, palette theme.Palette) string {
	base := lipgloss.NewStyle().
		Width(width).
		Background(palette.Panel).
		Foreground(palette.Text)
	if strings.TrimSpace(raw) == "" {
		return base.Render("")
	}

	if idx := strings.Index(raw, ":"); idx > 0 {
		keyText := strings.TrimSpace(raw[:idx+1])
		valueText := strings.TrimLeft(raw[idx+1:], " ")
		keyWidth := min(max(idx+2, 8), width)
		valueWidth := max(width-keyWidth, 0)

		keyStyle := lipgloss.NewStyle().
			Width(keyWidth).
			Background(palette.Panel).
			Foreground(palette.FooterKey).
			Bold(true)
		valueStyle := lipgloss.NewStyle().
			Width(valueWidth).
			Background(palette.Panel).
			Foreground(palette.Text)
		return base.Render(keyStyle.Render(keyText) + valueStyle.Render(valueText))
	}

	if strings.HasPrefix(strings.TrimSpace(raw), "Existing targets") {
		return lipgloss.NewStyle().
			Width(width).
			Background(palette.Panel).
			Foreground(palette.Warning).
			Render(strings.TrimSpace(raw))
	}

	return base.Render(raw)
}

func renderModalNoteLine(raw string, width int, palette theme.Palette, fallback lipgloss.Style) string {
	trimmed := strings.TrimSpace(raw)
	if trimmed == "" {
		return fallback.Render("")
	}

	if highlighted, ok := renderModalHintTokens(raw, width, palette, palette.Muted); ok {
		return highlighted
	}

	for _, sep := range []string{" to ", " (", ","} {
		if idx := strings.Index(raw, sep); idx > 0 {
			keyLabel := strings.TrimSpace(raw[:idx])
			action := strings.TrimLeft(raw[idx:], " ")
			keyWidth := min(max(lipgloss.Width(keyLabel)+2, 10), width)
			actionWidth := max(width-keyWidth, 0)
			keyStyle := lipgloss.NewStyle().
				Width(keyWidth).
				Background(palette.Panel).
				Foreground(palette.FooterKey).
				Bold(true)
			actionStyle := lipgloss.NewStyle().
				Width(actionWidth).
				Background(palette.Panel).
				Foreground(palette.Muted)
			return keyStyle.Render(keyLabel) + actionStyle.Render(action)
		}
	}

	return fallback.Render(raw)
}

func renderModalHintTokens(raw string, width int, palette theme.Palette, baseColor lipgloss.Color) (string, bool) {
	type tokenStyle struct {
		token string
		color lipgloss.Color
	}
	tokens := []tokenStyle{
		{token: "Background", color: palette.Info},
		{token: "Cancel", color: palette.CancelButton},
		{token: "Enter", color: palette.ConfirmButton},
		{token: "Esc", color: palette.CancelButton},
	}
	contains := false
	for _, entry := range tokens {
		if strings.Contains(raw, entry.token) {
			contains = true
			break
		}
	}
	if !contains {
		return "", false
	}

	var line strings.Builder
	rest := raw
	for len(rest) > 0 {
		nextIdx := -1
		nextToken := ""
		nextColor := baseColor
		for _, entry := range tokens {
			idx := strings.Index(rest, entry.token)
			if idx >= 0 && (nextIdx == -1 || idx < nextIdx) {
				nextIdx = idx
				nextToken = entry.token
				nextColor = entry.color
			}
		}
		if nextIdx == -1 {
			line.WriteString(lipgloss.NewStyle().
				Background(palette.Panel).
				Foreground(baseColor).
				Render(rest))
			break
		}
		if nextIdx > 0 {
			line.WriteString(lipgloss.NewStyle().
				Background(palette.Panel).
				Foreground(baseColor).
				Render(rest[:nextIdx]))
		}
		line.WriteString(lipgloss.NewStyle().
			Background(palette.Panel).
			Foreground(nextColor).
			Bold(true).
			Render(nextToken))
		rest = rest[nextIdx+len(nextToken):]
	}

	return lipgloss.NewStyle().
		Width(width).
		Background(palette.Panel).
		Render(line.String()), true
}

func renderConfirmActions(width int, palette theme.Palette) string {
	const minButtonWidth = 10
	const maxButtonWidth = 14
	const gapWidth = 4
	labelWidth := max(lipgloss.Width("Enter / y"), lipgloss.Width("Esc / n"))
	buttonWidth := min(max(labelWidth+2, minButtonWidth), maxButtonWidth)
	buttonWidth = min(buttonWidth, max((width-gapWidth)/2, labelWidth))

	confirm := lipgloss.NewStyle().
		Width(buttonWidth).
		Align(lipgloss.Center).
		Background(palette.ConfirmButton).
		Foreground(palette.Background).
		Bold(true).
		Render("Enter / y")

	cancel := lipgloss.NewStyle().
		Width(buttonWidth).
		Align(lipgloss.Center).
		Background(palette.CancelButton).
		Foreground(palette.Background).
		Bold(true).
		Render("Esc / n")

	gap := lipgloss.NewStyle().
		Width(gapWidth).
		Background(palette.Panel).
		Render("")
	enterBias := lipgloss.NewStyle().
		Width(9).
		Background(palette.Panel).
		Render("")
	cancelTail := lipgloss.NewStyle().
		Width(5).
		Background(palette.Panel).
		Render("")
	group := lipgloss.JoinHorizontal(lipgloss.Top, confirm, enterBias, gap, cancel, cancelTail)
	row := lipgloss.PlaceHorizontal(
		width,
		lipgloss.Center,
		group,
		lipgloss.WithWhitespaceBackground(palette.Panel),
	)
	return lipgloss.NewStyle().
		Width(width).
		Background(palette.Panel).
		Render(row)
}

func renderHelpModal(modal modalState, palette theme.Palette, width int) string {
	outerWidth := max(width, 8)
	contentWidth := max(outerWidth-6, 1)
	titleStyle := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Bold(true).Foreground(palette.Accent)
	lineStyle := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Foreground(palette.Text)
	mutedLineStyle := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Foreground(palette.Muted)
	noteStyle := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Foreground(palette.FooterKey).Bold(true)
	spacer := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Render(" ")

	keyColStyle := lipgloss.NewStyle().Width(24).Background(palette.Panel).Foreground(palette.FooterKey).Bold(true)
	descColStyle := lipgloss.NewStyle().Background(palette.Panel).Foreground(palette.Text)

	box := lipgloss.NewStyle().
		Width(contentWidth).
		Padding(1, 2).
		Background(palette.Panel).
		Foreground(palette.Text).
		BorderStyle(lipgloss.DoubleBorder()).
		BorderForeground(palette.BorderActive).
		BorderBackground(palette.Panel)

	lines := []string{titleStyle.Render(modal.title), spacer}
	for _, raw := range strings.Split(modal.body, "\n") {
		trimmed := strings.TrimSpace(raw)
		if trimmed == "" {
			lines = append(lines, spacer)
			continue
		}

		if strings.HasPrefix(raw, "  ") {
			keyLabel, action := splitHelpItem(raw)
			if action == "" {
				lines = append(lines, lineStyle.Render(trimmed))
				continue
			}
			row := keyColStyle.Render(keyLabel) + descColStyle.Render(action)
			lines = append(lines, lineStyle.Render(row))
			continue
		}

		if strings.HasSuffix(trimmed, ".") {
			lines = append(lines, mutedLineStyle.Render(trimmed))
			continue
		}

		sectionColor := sectionColorForHeader(trimmed, palette)
		header := lipgloss.NewStyle().
			Width(contentWidth).
			Background(palette.Panel).
			Foreground(sectionColor).
			Bold(true).
			Render(trimmed)
		lines = append(lines, header)
	}

	if modal.note != "" {
		lines = append(lines, spacer)
		if highlighted, ok := renderModalHintTokens(modal.note, contentWidth, palette, palette.FooterKey); ok {
			lines = append(lines, highlighted)
		} else {
			lines = append(lines, noteStyle.Render(modal.note))
		}
	}

	return box.Render(strings.Join(lines, "\n"))
}

func splitHelpItem(raw string) (string, string) {
	value := strings.TrimSpace(raw)
	for idx := 0; idx < len(value)-1; idx++ {
		if value[idx] == ' ' && value[idx+1] == ' ' {
			keyLabel := strings.TrimSpace(value[:idx])
			action := strings.TrimSpace(value[idx:])
			if keyLabel != "" && action != "" {
				return keyLabel, action
			}
		}
	}
	return value, ""
}

func sectionColorForHeader(header string, palette theme.Palette) lipgloss.Color {
	switch header {
	case "Navigation":
		return palette.HelpNav
	case "View and Panels":
		return palette.HelpPanels
	case "Dialogs and Transfers":
		return palette.HelpDialogs
	case "Mouse":
		return palette.HelpMouse
	default:
		return palette.Accent
	}
}

func renderCopyProgressModal(job copyJobState, palette theme.Palette, width int) string {
	outerWidth := max(width, 8)
	contentWidth := max(outerWidth-6, 1)
	titleStyle := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Bold(true).Foreground(palette.Accent)
	mutedStyle := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Foreground(palette.Muted)
	spacer := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Render(" ")

	box := lipgloss.NewStyle().
		Width(contentWidth).
		Padding(1, 2).
		Background(palette.Panel).
		Foreground(palette.Text).
		BorderStyle(lipgloss.DoubleBorder()).
		BorderForeground(palette.BorderActive).
		BorderBackground(palette.Panel)

	progress := job.progress
	ratio := 0.0
	if progress.BytesTotal > 0 {
		ratio = float64(progress.BytesDone) / float64(progress.BytesTotal)
	}

	lines := []string{
		titleStyle.Render(progressTitle(job.kind)),
		spacer,
		renderProgressBarLine(ratio, contentWidth, palette),
		spacer,
		renderProgressPercentLine(ratio, contentWidth, palette),
		renderProgressStatLine("Stage:", progressStageLabel(progress, job.kind), contentWidth, palette),
		spacer,
		renderProgressStatLine("Files:", fmt.Sprintf("%d / %d", progress.FilesDone, progress.FilesTotal), contentWidth, palette),
		renderProgressStatLine("Size:", fmt.Sprintf("%s / %s", formatSize(progress.BytesDone, true), formatSize(progress.BytesTotal, true)), contentWidth, palette),
		renderProgressStatLine("Speed:", transferSpeed(progress.BytesDone, job.startedAt), contentWidth, palette),
		spacer,
		renderModalNoteLine("Background / b, Cancel / c", contentWidth, palette, mutedStyle),
	}
	if job.background {
		lines = append(lines, mutedStyle.Render("Transfer continues in background"))
	}

	return box.Render(strings.Join(lines, "\n"))
}

func renderArchiveProgressModal(job archiveJobState, palette theme.Palette, width int) string {
	outerWidth := max(width, 8)
	contentWidth := max(outerWidth-6, 1)
	titleStyle := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Bold(true).Foreground(palette.Accent)
	mutedStyle := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Foreground(palette.Muted)
	spacer := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Render(" ")

	box := lipgloss.NewStyle().
		Width(contentWidth).
		Padding(1, 2).
		Background(palette.Panel).
		Foreground(palette.Text).
		BorderStyle(lipgloss.DoubleBorder()).
		BorderForeground(palette.BorderActive).
		BorderBackground(palette.Panel)

	progress := job.progress
	ratio := 0.0
	if progress.BytesTotal > 0 {
		ratio = float64(progress.BytesDone) / float64(progress.BytesTotal)
	}

	stage := progress.Stage
	if stage == "" {
		stage = "Archiving data"
	}

	lines := []string{
		titleStyle.Render("Archiving"),
		spacer,
		renderProgressBarLine(ratio, contentWidth, palette),
		spacer,
		renderProgressPercentLine(ratio, contentWidth, palette),
		renderProgressStatLine("Stage:", stage, contentWidth, palette),
		spacer,
		renderProgressStatLine("Files:", fmt.Sprintf("%d / %d", progress.FilesDone, progress.FilesTotal), contentWidth, palette),
		renderProgressStatLine("Size:", fmt.Sprintf("%s / %s", formatSize(progress.BytesDone, true), formatSize(progress.BytesTotal, true)), contentWidth, palette),
		renderProgressStatLine("Speed:", transferSpeed(progress.BytesDone, job.startedAt), contentWidth, palette),
		spacer,
		renderModalNoteLine("Background / b, Cancel / c", contentWidth, palette, mutedStyle),
	}
	if job.background {
		lines = append(lines, mutedStyle.Render("Archive continues in background"))
	}

	return box.Render(strings.Join(lines, "\n"))
}

func renderProgressBar(ratio float64, width int, palette theme.Palette) string {
	if width < 10 {
		width = 10
	}
	if ratio < 0 {
		ratio = 0
	}
	if ratio > 1 {
		ratio = 1
	}
	filled := int(float64(width) * ratio)
	if filled > width {
		filled = width
	}
	if filled < 0 {
		filled = 0
	}

	bar := lipgloss.NewStyle().Foreground(palette.ProgressFill).Render(strings.Repeat("█", filled))
	rest := lipgloss.NewStyle().Foreground(palette.ProgressEmpty).Render(strings.Repeat("░", width-filled))
	return bar + rest
}

func renderProgressBarLine(ratio float64, width int, palette theme.Palette) string {
	sidePad := max(width/8, 6)
	barWidth := max(width-(sidePad*2), 10)
	rightPad := max(width-sidePad-barWidth, 0)
	left := lipgloss.NewStyle().
		Width(sidePad).
		Background(palette.Panel).
		Render("")
	bar := lipgloss.NewStyle().
		Width(barWidth).
		Background(palette.Panel).
		Render(renderProgressBar(ratio, barWidth, palette))
	right := lipgloss.NewStyle().
		Width(rightPad).
		Background(palette.Panel).
		Render("")
	return lipgloss.NewStyle().
		Width(width).
		Background(palette.Panel).
		Render(left + bar + right)
}

func renderProgressStatLine(label string, value string, width int, palette theme.Palette) string {
	keyWidth := min(max(lipgloss.Width(label)+1, 8), width)
	valueWidth := max(width-keyWidth, 0)
	keyStyle := lipgloss.NewStyle().
		Width(keyWidth).
		Background(palette.Panel).
		Foreground(palette.FooterKey).
		Bold(true)
	valueStyle := lipgloss.NewStyle().
		Width(valueWidth).
		Background(palette.Panel).
		Foreground(palette.Text)
	return keyStyle.Render(label) + valueStyle.Render(value)
}

func renderProgressActions(width int, palette theme.Palette) string {
	const minButtonWidth = 10
	const maxButtonWidth = 14
	const gapWidth = 4
	labelWidth := max(lipgloss.Width("Background / b"), lipgloss.Width("Cancel / c"))
	buttonWidth := min(max(labelWidth+2, minButtonWidth), maxButtonWidth)
	buttonWidth = min(buttonWidth, max((width-gapWidth)/2, labelWidth))

	backgroundBtn := lipgloss.NewStyle().
		Width(buttonWidth).
		Align(lipgloss.Center).
		Background(palette.Info).
		Foreground(palette.Background).
		Bold(true).
		Render("Background / b")

	cancelBtn := lipgloss.NewStyle().
		Width(buttonWidth).
		Align(lipgloss.Center).
		Background(palette.CancelButton).
		Foreground(palette.Background).
		Bold(true).
		Render("Cancel / c")

	gap := lipgloss.NewStyle().
		Width(gapWidth).
		Background(palette.Panel).
		Render("")
	enterBias := lipgloss.NewStyle().
		Width(9).
		Background(palette.Panel).
		Render("")
	cancelTail := lipgloss.NewStyle().
		Width(5).
		Background(palette.Panel).
		Render("")
	group := lipgloss.JoinHorizontal(lipgloss.Top, backgroundBtn, enterBias, gap, cancelBtn, cancelTail)
	row := lipgloss.PlaceHorizontal(
		width,
		lipgloss.Center,
		group,
		lipgloss.WithWhitespaceBackground(palette.Panel),
	)
	return lipgloss.NewStyle().
		Width(width).
		Background(palette.Panel).
		Render(row)
}

func renderProgressPercentLine(ratio float64, width int, palette theme.Palette) string {
	percent := lipgloss.NewStyle().
		Width(width).
		Background(palette.Panel).
		Foreground(palette.Info).
		Bold(true).
		Render(fmt.Sprintf("%.0f%%", ratio*100))
	return percent
}

func transferSpeed(bytesDone int64, startedAt time.Time) string {
	if startedAt.IsZero() || bytesDone <= 0 {
		return "calculating..."
	}
	elapsed := time.Since(startedAt)
	if elapsed <= 0 {
		return "calculating..."
	}
	perSecond := int64(float64(bytesDone) / elapsed.Seconds())
	if perSecond <= 0 {
		return "calculating..."
	}
	return fmt.Sprintf("%s/s", vfs.HumanSize(perSecond))
}

func progressStageLabel(progress vfs.CopyProgress, kind fileOpKind) string {
	if strings.TrimSpace(progress.Stage) != "" {
		if progress.Stage == "Transferring data" && progress.BytesTotal > 0 && progress.BytesDone >= progress.BytesTotal {
			if progress.FilesDone < progress.FilesTotal {
				return "Finalizing file"
			}
			if kind == opMove {
				return "Preparing move finalization"
			}
			return "Finalizing transfer"
		}
		return progress.Stage
	}
	if kind == opMove {
		return "Preparing move"
	}
	return "Transferring data"
}

func overlayCenter(base string, overlay string, width int) string {
	if width <= 0 {
		return base
	}

	baseLines := strings.Split(base, "\n")
	overlayLines := strings.Split(overlay, "\n")
	if len(baseLines) == 0 || len(overlayLines) == 0 {
		return base
	}

	overlayWidth := 0
	for _, line := range overlayLines {
		overlayWidth = max(overlayWidth, ansi.StringWidth(line))
	}
	startY := max((len(baseLines)-len(overlayLines))/2, 0)
	startX := max((width-overlayWidth)/2, 0)
	endX := startX + overlayWidth

	for idx, line := range overlayLines {
		targetY := startY + idx
		if targetY >= len(baseLines) {
			break
		}
		baseLine := baseLines[targetY]
		left := ansi.Cut(baseLine, 0, startX)
		right := ansi.Cut(baseLine, endX, width)
		baseLines[targetY] = left + line + right
	}

	return strings.Join(baseLines, "\n")
}

func renderPreviewContent(viewportModel *viewport.Model, palette theme.Palette, width int, height int) string {
	outerWidth := max(width-2, 1)
	innerWidth := max(outerWidth-2, 1)
	innerHeight := max(height, 1)

	body := lipgloss.NewStyle().
		Width(innerWidth).
		Height(max(innerHeight, 1)).
		Padding(0, 1).
		Background(palette.Panel).
		Render(viewportModel.View())

	return lipgloss.NewStyle().
		Width(outerWidth).
		Height(innerHeight).
		Background(palette.Panel).
		BorderStyle(lipgloss.NormalBorder()).
		BorderTop(true).
		BorderForeground(palette.Border).
		BorderBackground(palette.Panel).
		Render(body)
}

func previewIcon(preview vfs.Preview) string {
	switch preview.Kind {
	case vfs.PreviewKindDirectory:
		return ""
	case vfs.PreviewKindImage:
		return "󰋩"
	case vfs.PreviewKindText:
		return "󰈙"
	case vfs.PreviewKindBinary:
		return "󰈔"
	case vfs.PreviewKindError:
		return ""
	default:
		return "󰇙"
	}
}

func (p pendingOperation) cmd() tea.Cmd {
	switch p.kind {
	case opMove:
		return nil
	case opDelete:
		return deletePathsCmd(p.sourcePaths)
	default:
		return nil
	}
}

func operationCmd(kind fileOpKind, sourcePath, targetDir string, overwrite bool) tea.Cmd {
	switch kind {
	case opMove:
		return moveCmd(sourcePath, targetDir, overwrite)
	default:
		return nil
	}
}

func dirSizeCmd(path string) tea.Cmd {
	return func() tea.Msg {
		size, err := vfs.DirectorySize(path)
		return dirSizeMsg{path: path, size: size, err: err}
	}
}

func copyPlanCmd(kind fileOpKind, sourcePaths []string, targetDir string, overwrite bool, existingTargets int) tea.Cmd {
	return func() tea.Msg {
		stats := vfs.TransferStats{}
		var err error
		for _, sourcePath := range sourcePaths {
			part, statErr := vfs.CopyStats(sourcePath)
			if statErr != nil {
				err = statErr
				break
			}
			stats.FilesTotal += part.FilesTotal
			stats.BytesTotal += part.BytesTotal
		}
		return copyPlanMsg{
			kind:            kind,
			sourcePaths:     append([]string(nil), sourcePaths...),
			targetDir:       targetDir,
			overwrite:       overwrite,
			existingTargets: existingTargets,
			stats:           stats,
			err:             err,
		}
	}
}

func (m *Model) enterArchive(selected vfs.Entry) error {
	pane := m.activePane()
	tempDir, err := vfs.ExtractArchiveToTemp(selected.Path)
	if err != nil {
		return err
	}
	pane.PushArchive(ArchiveMount{
		SourcePath: selected.Path,
		ParentPath: pane.Path,
		RootPath:   tempDir,
		TempDir:    tempDir,
	})
	pane.Path = tempDir
	pane.Cursor = 0
	pane.Offset = 0
	if err := m.reloadPane(pane.ID, ""); err != nil {
		_ = os.RemoveAll(tempDir)
		_, _ = pane.PopArchive()
		return err
	}
	m.status = fmt.Sprintf("Opened archive %s", selected.DisplayName())
	return nil
}

func (m *Model) cleanupArchiveMounts() {
	for _, pane := range []*BrowserPane{&m.left, &m.right} {
		for _, mount := range pane.ClearArchives() {
			_ = os.RemoveAll(mount.TempDir)
		}
	}
}

func deletePlanCmd(sourcePaths []string) tea.Cmd {
	return func() tea.Msg {
		stats := vfs.TransferStats{}
		var err error
		for _, sourcePath := range sourcePaths {
			part, statErr := vfs.CopyStats(sourcePath)
			if statErr != nil {
				err = statErr
				break
			}
			stats.FilesTotal += part.FilesTotal
			stats.BytesTotal += part.BytesTotal
		}
		return deletePlanMsg{
			sourcePaths: append([]string(nil), sourcePaths...),
			stats:       stats,
			err:         err,
		}
	}
}

func archivePlanCmd(sourcePaths []string, targetDir string) tea.Cmd {
	return func() tea.Msg {
		stats := vfs.TransferStats{}
		var err error
		for _, sourcePath := range sourcePaths {
			part, statErr := vfs.CopyStats(sourcePath)
			if statErr != nil {
				err = statErr
				break
			}
			stats.FilesTotal += part.FilesTotal
			stats.BytesTotal += part.BytesTotal
		}
		return archivePlanMsg{
			sourcePaths: append([]string(nil), sourcePaths...),
			targetDir:   targetDir,
			stats:       stats,
			err:         err,
		}
	}
}

func waitCopyProgressCmd(ch <-chan tea.Msg) tea.Cmd {
	return func() tea.Msg {
		return <-ch
	}
}

func waitArchiveProgressCmd(ch <-chan tea.Msg) tea.Cmd {
	return func() tea.Msg {
		return <-ch
	}
}

func dismissNoticeCmd(delay time.Duration) tea.Cmd {
	return tea.Tick(delay, func(time.Time) tea.Msg {
		return dismissNoticeMsg{}
	})
}

func dismissYankFlashCmd(delay time.Duration) tea.Cmd {
	return tea.Tick(delay, func(time.Time) tea.Msg {
		return dismissYankFlashMsg{}
	})
}

func (m *Model) startCopyJob(kind fileOpKind, sourcePaths []string, targetDir string, overwrite bool, stats vfs.TransferStats) tea.Cmd {
	m.nextCopyJob++
	jobID := m.nextCopyJob
	ctx, cancel := context.WithCancel(context.Background())
	m.copyJob = &copyJobState{
		id:          jobID,
		kind:        kind,
		sourcePaths: append([]string(nil), sourcePaths...),
		targetDir:   targetDir,
		overwrite:   overwrite,
		progress: vfs.CopyProgress{
			FilesDone:   0,
			FilesTotal:  stats.FilesTotal,
			BytesDone:   0,
			BytesTotal:  stats.BytesTotal,
			CurrentPath: sourcePaths[0],
		},
		cancel:    cancel,
		startedAt: time.Now(),
	}
	m.modal = modalState{kind: modalCopyProgress}
	m.status = strings.Title(operationVerb(kind)) + " started"

	return tea.Batch(
		func() tea.Msg {
			go func() {
				doneFiles := 0
				var doneBytes int64
				for _, sourcePath := range sourcePaths {
					entryStats, statErr := vfs.CopyStats(sourcePath)
					if statErr != nil {
						m.copyProgress <- copyDoneMsg{
							jobID:       jobID,
							kind:        kind,
							sourcePaths: append([]string(nil), sourcePaths...),
							targetDir:   targetDir,
							err:         statErr,
						}
						return
					}
					progressFn := func(progress vfs.CopyProgress) {
						m.copyProgress <- copyProgressMsg{
							jobID: jobID,
							progress: vfs.CopyProgress{
								FilesDone:   doneFiles + progress.FilesDone,
								FilesTotal:  stats.FilesTotal,
								BytesDone:   doneBytes + progress.BytesDone,
								BytesTotal:  stats.BytesTotal,
								CurrentPath: progress.CurrentPath,
								Stage:       progress.Stage,
							},
						}
					}
					switch kind {
					case opMove:
						_, statErr = vfs.MovePathWithProgressContext(ctx, sourcePath, targetDir, overwrite, entryStats, progressFn)
					default:
						_, statErr = vfs.CopyPathWithProgressContext(ctx, sourcePath, targetDir, overwrite, entryStats, progressFn)
					}
					if statErr != nil {
						m.copyProgress <- copyDoneMsg{
							jobID:       jobID,
							kind:        kind,
							sourcePaths: append([]string(nil), sourcePaths...),
							targetDir:   targetDir,
							err:         statErr,
						}
						return
					}
					doneFiles += entryStats.FilesTotal
					doneBytes += entryStats.BytesTotal
				}
				m.copyProgress <- copyDoneMsg{
					jobID:       jobID,
					kind:        kind,
					sourcePaths: append([]string(nil), sourcePaths...),
					targetDir:   targetDir,
				}
			}()
			return nil
		},
		waitCopyProgressCmd(m.copyProgress),
	)
}

func (m *Model) startArchiveJob(sourcePaths []string, targetDir string, format string, stats vfs.TransferStats) tea.Cmd {
	m.nextArchiveJob++
	jobID := m.nextArchiveJob
	ctx, cancel := context.WithCancel(context.Background())

	archiveName := vfs.ArchiveName(sourcePaths, format)
	archivePath := filepath.Join(targetDir, archiveName)

	m.archiveJob = &archiveJobState{
		id:          jobID,
		sourcePaths: append([]string(nil), sourcePaths...),
		targetPath:  archivePath,
		progress: vfs.CopyProgress{
			FilesDone:   0,
			FilesTotal:  stats.FilesTotal,
			BytesDone:   0,
			BytesTotal:  stats.BytesTotal,
			CurrentPath: sourcePaths[0],
		},
		cancel:    cancel,
		startedAt: time.Now(),
	}
	m.modal = modalState{kind: modalArchiveProgress}
	m.status = "Archiving started"

	return tea.Batch(
		func() tea.Msg {
			go func() {
				emitProgress := func(p vfs.CopyProgress) {
					m.archiveProgress <- archiveProgressMsg{
						jobID:    jobID,
						progress: p,
					}
				}
				err := vfs.CreateArchive(ctx, sourcePaths, archivePath, emitProgress)
				if err != nil {
					m.archiveProgress <- archiveDoneMsg{
						jobID:       jobID,
						sourcePaths: append([]string(nil), sourcePaths...),
						targetPath:  archivePath,
						err:         err,
					}
					return
				}
				m.archiveProgress <- archiveDoneMsg{
					jobID:       jobID,
					sourcePaths: append([]string(nil), sourcePaths...),
					targetPath:  archivePath,
				}
			}()
			return nil
		},
		waitArchiveProgressCmd(m.archiveProgress),
	)
}

func moveCmd(sourcePath, targetDir string, overwrite bool) tea.Cmd {
	return func() tea.Msg {
		targetPath, err := vfs.MovePath(sourcePath, targetDir, overwrite)
		return opMsg{kind: opMove, sourcePath: sourcePath, targetPath: targetPath, err: err}
	}
}

func deletePathsCmd(paths []string) tea.Cmd {
	return func() tea.Msg {
		for _, path := range paths {
			if err := vfs.DeletePath(path); err != nil {
				return opMsg{kind: opDelete, sourcePath: path, err: err}
			}
		}
		return opMsg{kind: opDelete}
	}
}

func mkdirCmd(parent, name string) tea.Cmd {
	return func() tea.Msg {
		targetPath, err := vfs.MakeDir(parent, name)
		return opMsg{kind: opMkdir, targetPath: targetPath, err: err}
	}
}

func renameCmd(sourcePath, newName string) tea.Cmd {
	return func() tea.Msg {
		targetPath, err := vfs.RenamePath(sourcePath, newName)
		return opMsg{kind: opRename, sourcePath: sourcePath, targetPath: targetPath, err: err}
	}
}

func selectedName(pane *BrowserPane) string {
	selected, ok := pane.Selected()
	if !ok {
		return ""
	}
	return selected.Name
}

func metaSize(meta vfs.Metadata) string {
	if !meta.SizeKnown {
		return "press Space"
	}
	return vfs.HumanSize(meta.Size)
}

func fallback(value string, defaultValue string) string {
	if strings.TrimSpace(value) == "" {
		return defaultValue
	}
	return value
}

func formatSize(size int64, human bool) string {
	if human {
		return vfs.HumanSize(size)
	}
	return fmt.Sprintf("%d", size)
}

func formatCopyStatus(kind fileOpKind, progress vfs.CopyProgress) string {
	return fmt.Sprintf(
		"%s in background: %d/%d files, %s/%s",
		strings.Title(operationVerb(kind)),
		progress.FilesDone,
		progress.FilesTotal,
		formatSize(progress.BytesDone, true),
		formatSize(progress.BytesTotal, true),
	)
}

func formatArchiveStatus(progress vfs.CopyProgress) string {
	return fmt.Sprintf(
		"Archiving in background: %d/%d files, %s/%s",
		progress.FilesDone,
		progress.FilesTotal,
		formatSize(progress.BytesDone, true),
		formatSize(progress.BytesTotal, true),
	)
}

func transferSourceLabel(paths []string) string {
	if len(paths) == 0 {
		return "n/a"
	}
	if len(paths) == 1 {
		return paths[0]
	}
	return fmt.Sprintf("%d selected entries", len(paths))
}

func pluralSuffix(count int, singular string, plural string) string {
	if count == 1 {
		return singular
	}
	return plural
}

func progressTitle(kind fileOpKind) string {
	switch kind {
	case opMove:
		return "Moving"
	case opArchive:
		return "Archiving"
	default:
		return "Copying"
	}
}

func operationDoneLabel(kind fileOpKind) string {
	switch kind {
	case opMove:
		return "Moved"
	case opCopy:
		return "Copied"
	case opDelete:
		return "Deleted"
	case opArchive:
		return "Archived"
	default:
		return "Done"
	}
}

func operationVerb(kind fileOpKind) string {
	switch kind {
	case opCopy:
		return "copy"
	case opMove:
		return "move"
	case opDelete:
		return "delete"
	case opArchive:
		return "archive"
	default:
		return "operate on"
	}
}

func externalCommand(envVar string, fallbacks []string, path string) (*exec.Cmd, string, error) {
	envVars := []string{}
	if envVar != "" {
		envVars = append(envVars, envVar)
	}
	return externalCommandFromEnv(envVars, fallbacks, path)
}

func externalCommandFromEnv(envVars []string, fallbacks []string, path string) (*exec.Cmd, string, error) {
	commandLine := ""
	source := "fallbacks"
	for _, envVar := range envVars {
		commandLine = strings.TrimSpace(os.Getenv(envVar))
		if commandLine != "" {
			source = envVar
			break
		}
	}
	if commandLine == "" {
		for _, candidate := range fallbacks {
			if resolved, err := exec.LookPath(candidate); err == nil {
				commandLine = resolved
				source = candidate
				break
			}
		}
	}
	if commandLine == "" {
		if len(envVars) > 0 {
			return nil, "", fmt.Errorf("no command for %s", strings.Join(envVars, "/"))
		}
		return nil, "", fmt.Errorf("no fallback command found")
	}

	parts := strings.Fields(commandLine)
	if len(parts) == 0 {
		return nil, "", fmt.Errorf("invalid command for %s", source)
	}

	args := append(parts[1:], path)
	return exec.Command(parts[0], args...), filepath.Base(parts[0]), nil
}

func startExternalOpenCmd(command *exec.Cmd, path string) tea.Cmd {
	return func() tea.Msg {
		command.Stdin = nil
		command.Stdout = io.Discard
		command.Stderr = io.Discard
		if err := command.Start(); err != nil {
			return externalOpenMsg{path: path, err: err}
		}
		return externalOpenMsg{path: path}
	}
}

func enableMouseCmd() tea.Cmd {
	return func() tea.Msg {
		return tea.EnableMouseCellMotion()
	}
}

func disableMouseCmd() tea.Cmd {
	return func() tea.Msg {
		return tea.DisableMouse()
	}
}

func resolveStartPath(raw string, fallback string) (string, error) {
	value := strings.TrimSpace(raw)
	if value == "" {
		return fallback, nil
	}
	if strings.HasPrefix(value, "~/") {
		home, err := os.UserHomeDir()
		if err != nil {
			return "", err
		}
		value = filepath.Join(home, strings.TrimPrefix(value, "~/"))
	}
	abs, err := filepath.Abs(value)
	if err != nil {
		return "", err
	}
	info, err := os.Stat(abs)
	if err != nil {
		return "", err
	}
	if !info.IsDir() {
		return "", fmt.Errorf("startup path is not a directory: %s", abs)
	}
	return abs, nil
}

func min(a, b int) int {
	if a < b {
		return a
	}
	return b
}

func clamp(n, low, high int) int {
	if n < low {
		return low
	}
	if n > high {
		return high
	}
	return n
}

func max(a, b int) int {
	if a > b {
		return a
	}
	return b
}

func (m *Model) mouseTarget(x, y int) (PaneID, int, bool) {
	if m.width <= 0 || m.height <= 0 {
		return "", 0, false
	}

	leftWidth, previewWidth, rightWidth := m.layoutWidths()
	top := 0
	bodyHeight := m.bodyHeight()
	if y < top || y >= top+bodyHeight {
		return "", 0, false
	}

	gap := m.cfg.UI.PaneGap
	leftStart := 0
	rightStart := leftWidth + gap
	if m.infoMode && m.active == PaneRight {
		rightStart = previewWidth + gap
	}

	switch {
	case x >= leftStart && x < leftStart+leftWidth:
		if m.infoMode && m.active == PaneRight {
			return "", 0, false
		}
		index, ok := paneIndexFromMouse(y-top, bodyHeight, &m.left)
		if !ok {
			return "", 0, false
		}
		return PaneLeft, index, true
	case x >= rightStart && x < rightStart+rightWidth:
		if m.infoMode && m.active == PaneLeft {
			return "", 0, false
		}
		index, ok := paneIndexFromMouse(y-top, bodyHeight, &m.right)
		if !ok {
			return "", 0, false
		}
		return PaneRight, index, true
	default:
		return "", 0, false
	}
}

func paneIndexFromMouse(localY int, height int, pane *BrowserPane) (int, bool) {
	const listStartY = 3
	if localY < listStartY || localY >= height-1 {
		return 0, false
	}
	row := localY - listStartY
	index := pane.Offset + row
	if index < 0 {
		index = 0
	}
	if index >= len(pane.Entries) {
		return 0, false
	}
	return index, true
}

func isEditableEntry(entry vfs.Entry) bool {
	switch entry.Category() {
	case "text", "config", "executable":
		return true
	default:
		return false
	}
}

func isArchiveEntry(entry vfs.Entry) bool {
	return !entry.IsDir && !entry.IsParent && entry.Category() == "archive"
}

func (m Model) syncImageOverlay(leftWidth int, previewWidth int, bodyHeight int) {
	if m.overlay == nil {
		return
	}
	if m.modal.kind != modalNone {
		m.overlay.hide()
		return
	}
	if m.previewData.Kind != vfs.PreviewKindImage {
		m.overlay.hide()
		return
	}
	imagePath := strings.TrimSpace(m.previewData.Metadata.Path)
	if imagePath == "" {
		m.overlay.hide()
		return
	}

	rect := overlayRect{}
	if m.viewMode {
		rect = overlayRect{
			x:      1,
			y:      1,
			width:  max(m.width-2, 1),
			height: max(bodyHeight-2, 1),
		}
	} else if m.infoMode {
		startX := 0
		if m.active == PaneLeft {
			startX = leftWidth + m.cfg.UI.PaneGap
		}
		innerWidth := max(previewWidth-2, 1)
		metaHeight := 0
		if m.cfg.Preview.ShowMetadata {
			metaHeight = lipgloss.Height(renderMetadata(m.previewData.Metadata, m.palette, innerWidth, m.nerdIcons))
		}
		titleHeight := 1
		topInset := 1
		contentBorder := 1
		safetyGap := 1
		contentTop := topInset + titleHeight + metaHeight + contentBorder + safetyGap
		rect = overlayRect{
			x:      startX + 3,
			y:      contentTop,
			width:  max(previewWidth-6, 1),
			height: max(bodyHeight-contentTop-2, 1),
		}
	} else {
		m.overlay.hide()
		return
	}

	if err := m.overlay.show(imagePath, rect); err != nil {
		m.overlay.hide()
	}
}

func (m *Model) cleanupImageOverlay() {
	if m.overlay == nil {
		return
	}
	m.overlay.stop()
}

func (m *Model) hoverIndexFor(pane PaneID) int {
	if m.hover.ok && m.hover.pane == pane {
		return m.hover.index
	}
	return -1
}

func (m *Model) mouseOverPreview(x, y int) bool {
	if !m.infoMode || m.width <= 0 || m.height <= 0 {
		return false
	}

	leftWidth, previewWidth, _ := m.layoutWidths()
	top := 0
	bodyHeight := m.bodyHeight()
	if y < top || y >= top+bodyHeight {
		return false
	}

	gap := m.cfg.UI.PaneGap
	if m.active == PaneLeft {
		startX := leftWidth + gap
		return x >= startX && x < startX+previewWidth
	}

	return x >= 0 && x < previewWidth
}

func (m *Model) mouseOverPathLine(x, y int) bool {
	if !m.infoMode || !m.cfg.Preview.ShowMetadata || m.width <= 0 || m.height <= 0 {
		return false
	}

	leftWidth, previewWidth, _ := m.layoutWidths()
	bodyHeight := m.bodyHeight()
	if y < 0 || y >= bodyHeight {
		return false
	}

	// Preview pane x-range
	var startX int
	if m.active == PaneLeft {
		startX = leftWidth + m.cfg.UI.PaneGap
	} else {
		startX = 0
	}
	if x < startX || x >= startX+previewWidth {
		return false
	}

	// The path line is within the metadata section (approximate Y range 1-7).
	// Check that Y is in the metadata area and X is in the right half where the icon is.
	if y < 1 || y > 7 {
		return false
	}
	// The icon is at the far-right end of the preview pane content area
	iconStartX := startX + previewWidth/2
	return x >= iconStartX
}
