package peco

import (
	"bytes"
	"errors"
	"fmt"
	"math"
	"os"
	"strconv"
	"unicode"

	"context"

	"github.com/lestrrat-go/pdebug"
	"github.com/peco/peco/config"
	"github.com/peco/peco/hub"
	"github.com/peco/peco/internal/keyseq"
	"github.com/peco/peco/internal/util"
	"github.com/peco/peco/line"
	"github.com/peco/peco/selection"
)

// Action describes an action that can be executed upon receiving user input.
type Action interface {
	Execute(context.Context, *Peco, Event)
}

// ActionFunc is a type of Action that is basically just a callback.
type ActionFunc func(context.Context, *Peco, Event)

// ActionMap is the interface for dispatching actions based on key events.
type ActionMap interface {
	ExecuteAction(context.Context, *Peco, Event) error
}

// This is the global map of canonical action name to actions
var nameToActions map[string]Action

// This is the default keybinding used by NewKeymap()
var defaultKeyBinding map[string]Action

// execQueryAndDraw runs ExecQuery and, if the query was non-empty
// (ExecQuery returns false), sends a draw-prompt message.
func execQueryAndDraw(ctx context.Context, state *Peco) {
	if state.ExecQuery(ctx, state.selectOneCallback()) {
		return
	}
	state.Hub().SendDrawPrompt(ctx)
}

// Execute fulfills the Action interface for AfterFunc
func (a ActionFunc) Execute(ctx context.Context, state *Peco, e Event) {
	a(ctx, state, e)
}

// registerKeySequence registers a key sequence in the default key binding map for action dispatch.
func (a ActionFunc) registerKeySequence(k keyseq.KeyList) {
	defaultKeyBinding[k.String()] = a
}

// Register registers `a` into the global action registry by the name
// `name`, and maps to default keys via `defaultKeys`. Called during
// package init() to set up built-in actions.
func (a ActionFunc) Register(name string, defaultKeys ...keyseq.KeyType) {
	nameToActions["peco."+name] = a
	for _, k := range defaultKeys {
		a.registerKeySequence(keyseq.KeyList{keyseq.NewKeyFromKey(k)})
	}
}

// RegisterKeySequence registers the action to be mapped against a
// multi-key sequence. Called during package init() for actions like
// KonamiCommand.
func (a ActionFunc) RegisterKeySequence(name string, k keyseq.KeyList) {
	nameToActions["peco."+name] = a
	a.registerKeySequence(k)
}

// wrapDeprecated wraps an action function to emit a deprecation warning before executing it.
func wrapDeprecated(fn func(context.Context, *Peco, Event), oldName, newName string) ActionFunc {
	return ActionFunc(func(ctx context.Context, state *Peco, e Event) {
		state.Hub().SendStatusMsg(ctx, fmt.Sprintf("%s is deprecated. Use %s", oldName, newName), 0)
		fn(ctx, state, e)
	})
}

// makePagingAction creates an ActionFunc that sends the given PagingRequest.
func makePagingAction(req hub.PagingRequest) ActionFunc {
	return ActionFunc(func(ctx context.Context, state *Peco, _ Event) {
		state.Hub().SendPaging(ctx, req)
	})
}

func init() {
	// Build the global maps
	nameToActions = map[string]Action{}
	defaultKeyBinding = map[string]Action{}

	ActionFunc(doInvertSelection).Register("InvertSelection")
	ActionFunc(doBeginningOfLine).Register("BeginningOfLine", keyseq.KeyCtrlA)
	ActionFunc(doBackwardChar).Register("BackwardChar", keyseq.KeyCtrlB)
	ActionFunc(doBackwardWord).Register("BackwardWord")
	ActionFunc(doCancel).Register("Cancel", keyseq.KeyCtrlC, keyseq.KeyEsc)
	ActionFunc(doDeleteAll).Register("DeleteAll")
	ActionFunc(doDeleteBackwardChar).Register(
		"DeleteBackwardChar",
		keyseq.KeyBackspace,
		keyseq.KeyBackspace2,
	)
	ActionFunc(doDeleteBackwardWord).Register(
		"DeleteBackwardWord",
		keyseq.KeyCtrlW,
	)
	ActionFunc(doDeleteForwardChar).Register("DeleteForwardChar", keyseq.KeyCtrlD)
	ActionFunc(doDeleteForwardWord).Register("DeleteForwardWord")
	ActionFunc(doEndOfFile).Register("EndOfFile")
	ActionFunc(doEndOfLine).Register("EndOfLine", keyseq.KeyCtrlE)
	ActionFunc(doFinish).Register("Finish", keyseq.KeyEnter)
	ActionFunc(doForwardChar).Register("ForwardChar", keyseq.KeyCtrlF)
	ActionFunc(doForwardWord).Register("ForwardWord")
	ActionFunc(doKillEndOfLine).Register("KillEndOfLine", keyseq.KeyCtrlK)
	ActionFunc(doKillBeginningOfLine).Register("KillBeginningOfLine", keyseq.KeyCtrlU)
	ActionFunc(doRotateFilter).Register("RotateFilter", keyseq.KeyCtrlR)
	wrapDeprecated(doRotateFilter, "RotateMatcher", "RotateFilter").Register("RotateMatcher")
	ActionFunc(doBackToInitialFilter).Register("BackToInitialFilter")

	selectUp := makePagingAction(hub.ToLineAbove)
	selectDown := makePagingAction(hub.ToLineBelow)
	scrollPageUp := makePagingAction(hub.ToScrollPageUp)
	scrollPageDown := makePagingAction(hub.ToScrollPageDown)

	selectUp.Register("SelectUp", keyseq.KeyArrowUp, keyseq.KeyCtrlP)
	wrapDeprecated(selectDown, "SelectNext", "SelectUp/SelectDown").Register("SelectNext")

	scrollPageDown.Register("ScrollPageDown", keyseq.KeyArrowRight, keyseq.KeyPgdn)
	wrapDeprecated(scrollPageDown, "SelectNextPage", "ScrollPageDown/ScrollPageUp").Register("SelectNextPage")

	selectDown.Register("SelectDown", keyseq.KeyArrowDown, keyseq.KeyCtrlN)
	wrapDeprecated(selectUp, "SelectPrevious", "SelectUp/SelectDown").Register("SelectPrevious")

	scrollPageUp.Register("ScrollPageUp", keyseq.KeyArrowLeft, keyseq.KeyPgup)
	wrapDeprecated(scrollPageUp, "SelectPreviousPage", "ScrollPageDown/ScrollPageUp").Register("SelectPreviousPage")

	makePagingAction(hub.ToScrollLeft).Register("ScrollLeft")
	makePagingAction(hub.ToScrollRight).Register("ScrollRight")

	makePagingAction(hub.ToScrollFirstItem).Register("ScrollFirstItem", keyseq.KeyHome)
	makePagingAction(hub.ToScrollLastItem).Register("ScrollLastItem", keyseq.KeyEnd)

	ActionFunc(doToggleSelection).Register("ToggleSelection")
	ActionFunc(doToggleSelectionAndSelectNext).Register(
		"ToggleSelectionAndSelectNext",
		keyseq.KeyCtrlSpace,
	)
	ActionFunc(doSelectNone).Register(
		"SelectNone",
		keyseq.KeyCtrlG,
	)
	ActionFunc(doSelectAll).Register("SelectAll")
	ActionFunc(doSelectVisible).Register("SelectVisible")
	wrapDeprecated(doToggleRangeMode, "ToggleSelectMode", "ToggleRangeMode").Register("ToggleSelectMode")
	wrapDeprecated(doCancelRangeMode, "CancelSelectMode", "CancelRangeMode").Register("CancelSelectMode")
	ActionFunc(doToggleRangeMode).Register("ToggleRangeMode")
	ActionFunc(doCancelRangeMode).Register("CancelRangeMode")
	ActionFunc(doToggleQuery).Register("ToggleQuery", keyseq.KeyCtrlT)
	ActionFunc(doRefreshScreen).Register("RefreshScreen", keyseq.KeyCtrlL)
	ActionFunc(doToggleSingleKeyJump).Register("ToggleSingleKeyJump")

	ActionFunc(doToggleViewAround).Register("ViewAround", keyseq.KeyCtrlV)
	wrapDeprecated(doToggleViewAround, "ViewArround", "ViewAround").Register("ViewArround")

	ActionFunc(doFreezeResults).Register("FreezeResults")
	ActionFunc(doUnfreezeResults).Register("UnfreezeResults")

	ActionFunc(doZoomIn).Register("ZoomIn")
	ActionFunc(doZoomOut).Register("ZoomOut")

	ActionFunc(doGoToNextSelection).RegisterKeySequence("GoToNextSelection",
		keyseq.KeyList{keyseq.Key{Modifier: keyseq.ModAlt, Ch: 'j'}})
	ActionFunc(doGoToPreviousSelection).RegisterKeySequence("GoToPreviousSelection",
		keyseq.KeyList{keyseq.Key{Modifier: keyseq.ModAlt, Ch: 'k'}})

	ActionFunc(doKonamiCommand).RegisterKeySequence(
		"KonamiCommand",
		keyseq.KeyList{
			keyseq.Key{Modifier: 0, Key: keyseq.KeyCtrlX, Ch: 0},
			keyseq.Key{Modifier: 0, Key: keyseq.KeyArrowUp, Ch: 0},
			keyseq.Key{Modifier: 0, Key: keyseq.KeyArrowUp, Ch: 0},
			keyseq.Key{Modifier: 0, Key: keyseq.KeyArrowDown, Ch: 0},
			keyseq.Key{Modifier: 0, Key: keyseq.KeyArrowDown, Ch: 0},
			keyseq.Key{Modifier: 0, Key: keyseq.KeyArrowLeft, Ch: 0},
			keyseq.Key{Modifier: 0, Key: keyseq.KeyArrowRight, Ch: 0},
			keyseq.Key{Modifier: 0, Key: keyseq.KeyArrowLeft, Ch: 0},
			keyseq.Key{Modifier: 0, Key: keyseq.KeyArrowRight, Ch: 0},
			keyseq.Key{Modifier: 0, Key: 0, Ch: 'b'},
			keyseq.Key{Modifier: 0, Key: 0, Ch: 'a'},
		},
	)
}

// selectLine marks the line as dirty and adds it to the selection.
func selectLine(l line.Line, s *selection.Set) {
	l.SetDirty(true)
	s.Add(l)
}

// This is a noop action
func doNothing(_ context.Context, _ *Peco, _ Event) {}

// This is an exception to the rule. This does not get registered
// anywhere. You just call it directly
func doAcceptChar(ctx context.Context, state *Peco, e Event) {
	if e.Key == keyseq.KeySpace {
		e.Ch = ' '
	}

	ch := e.Ch
	if ch <= 0 {
		return
	}

	if state.SingleKeyJump().Mode() {
		doSingleKeyJump(ctx, state, e)
		return
	}

	q := state.Query()
	c := state.Caret()

	q.InsertAt(ch, c.Pos())
	c.Move(1)

	h := state.Hub()
	h.SendDrawPrompt(ctx) // Update prompt before running query

	state.ExecQuery(ctx, state.selectOneCallback())
}

// doRotateFilter cycles to the next filter in the configured filter set and re-runs the query.
func doRotateFilter(ctx context.Context, state *Peco, _ Event) {
	if pdebug.Enabled {
		g := pdebug.Marker("doRotateFilter")
		defer g.End()
	}

	filters := state.Filters()
	filters.Rotate()

	execQueryAndDraw(ctx, state)
}

// doBackToInitialFilter resets the filter back to the one configured at startup and re-runs the query.
func doBackToInitialFilter(ctx context.Context, state *Peco, _ Event) {
	if pdebug.Enabled {
		g := pdebug.Marker("doBackToInitialFilter")
		defer g.End()
	}

	filters := state.Filters()
	filters.Reset()

	execQueryAndDraw(ctx, state)
}

// doToggleSelection toggles the selection state of the line at the current cursor position.
func doToggleSelection(_ context.Context, state *Peco, _ Event) {
	if pdebug.Enabled {
		g := pdebug.Marker("doToggleSelection")
		defer g.End()
	}

	l, err := state.CurrentLineBuffer().LineAt(state.Location().LineNumber())
	if err != nil {
		return
	}

	selection := state.Selection()
	if selection.Has(l) {
		selection.Remove(l)
		return
	}
	selection.Add(l)
}

// doToggleRangeMode enables or disables range selection mode, anchoring or clearing the range start.
func doToggleRangeMode(_ context.Context, state *Peco, _ Event) {
	if pdebug.Enabled {
		g := pdebug.Marker("doToggleRangeMode")
		defer g.End()
	}

	r := state.SelectionRangeStart()
	if r.Valid() {
		r.Reset()
	} else {
		cl := state.Location().LineNumber()
		r.SetValue(cl)
		if l, err := state.CurrentLineBuffer().LineAt(cl); err == nil {
			state.Selection().Add(l)
		}
	}
}

// doCancelRangeMode exits range selection mode without modifying the current selections.
func doCancelRangeMode(_ context.Context, state *Peco, _ Event) {
	state.SelectionRangeStart().Reset()
}

// doSelectNone deselects all currently selected lines and redraws the screen.
func doSelectNone(ctx context.Context, state *Peco, _ Event) {
	state.Selection().Reset()
	state.Hub().SendDraw(ctx, &hub.DrawOptions{DisableCache: true})
}

// doSelectAll selects every line in the current line buffer.
func doSelectAll(ctx context.Context, state *Peco, _ Event) {
	selection := state.Selection()
	b := state.CurrentLineBuffer()
	for x := range b.Size() {
		l, err := b.LineAt(x)
		if err != nil {
			continue
		}
		selectLine(l, selection)
	}
	state.Hub().SendDraw(ctx, nil)
}

// doSelectVisible selects all lines currently visible on screen within the page crop.
func doSelectVisible(ctx context.Context, state *Peco, _ Event) {
	if pdebug.Enabled {
		g := pdebug.Marker("doSelectVisible")
		defer g.End()
	}

	b := state.CurrentLineBuffer()
	selection := state.Selection()
	loc := state.Location()
	pc := loc.PageCrop()
	lb := pc.Crop(b)
	for x := range lb.Size() {
		l, err := lb.LineAt(x)
		if err != nil {
			continue
		}
		selectLine(l, selection)
	}
	state.Hub().SendDraw(ctx, nil)
}

type collectResultsError struct{}

func (err collectResultsError) Error() string {
	return "collect results"
}
func (err collectResultsError) CollectResults() bool {
	return true
}

// doFinish completes the peco session. If execOnFinish is set, it runs the configured
// command with the selected lines as stdin; otherwise it exits with a collect-results signal.
func doFinish(ctx context.Context, state *Peco, _ Event) {
	if pdebug.Enabled {
		g := pdebug.Marker("doFinish")
		defer g.End()
	}

	ccarg := state.execOnFinish
	if len(ccarg) == 0 {
		state.Exit(collectResultsError{})
		return
	}

	sel := selection.New()
	state.Selection().Copy(sel)
	if sel.Len() == 0 {
		if l, err := state.CurrentLineBuffer().LineAt(state.Location().LineNumber()); err == nil {
			sel.Add(l)
		}
	}

	var stdin bytes.Buffer
	sel.Ascend(func(l line.Line) bool {
		stdin.WriteString(l.Buffer())
		stdin.WriteRune('\n')
		return true
	})

	var err error
	state.Hub().SendStatusMsg(ctx, "Executing "+ccarg, 0)
	cmd := util.Shell(ctx, ccarg)
	cmd.Stdin = &stdin
	cmd.Stdout = state.Stdout
	cmd.Stderr = state.Stderr
	// Setup some environment variables. Start with a copy of the current
	// environment...
	env := os.Environ()

	// Add some PECO specific ones...
	// PECO_QUERY: current query value
	// PECO_FILENAME: input file name, if any. "-" for stdin
	// PECO_LINE_COUNT: number of lines in the original input
	// PECO_MATCHED_LINE_COUNT: number of lines matched (number of lines being
	//     sent to stdin of the command being executed)

	if s, ok := state.Source().(*Source); ok {
		env = append(env,
			`PECO_FILENAME=`+s.Name(),
			`PECO_LINE_COUNT=`+strconv.Itoa(s.Size()),
		)
	}

	env = append(env,
		`PECO_QUERY=`+state.Query().String(),
		`PECO_MATCHED_LINE_COUNT=`+strconv.Itoa(sel.Len()),
	)
	cmd.Env = env

	state.screen.Suspend()

	err = cmd.Run()
	if err := state.screen.Resume(ctx); err != nil {
		state.Exit(fmt.Errorf("failed to resume screen: %w", err))
		return
	}
	state.Hub().SendDraw(ctx, &hub.DrawOptions{DisableCache: true})
	if err != nil {
		// bail out, or otherwise the user cannot know what happened
		state.Exit(fmt.Errorf("failed to execute command: %w", err))
	}
}

// doCancel cancels the current operation: a pending key sequence, range mode, or the entire
// peco session. The exit status depends on the OnCancel configuration.
func doCancel(ctx context.Context, state *Peco, e Event) {
	km := state.Keymap()

	if seq := km.Sequence(); seq.InMiddleOfChain() {
		seq.CancelChain()
		return
	}

	if state.SelectionRangeStart().Valid() {
		doCancelRangeMode(ctx, state, e)
		return
	}

	// peco.Cancel -> end program, exit with failure
	err := makeIgnorable(errors.New("user canceled"))
	if state.onCancel == config.OnCancelError {
		err = setExitStatus(err, 1)
	}
	state.Exit(err)
}

// batchAction runs fn inside a Hub.Batch. Re-entrant locking is
// handled automatically by Hub.Batch via context detection.
func batchAction(ctx context.Context, state *Peco, fn func(context.Context)) {
	state.Hub().Batch(ctx, fn)
}

// doToggleSelectionAndSelectNext toggles the selection of the current line, then moves
// the cursor to the next line (direction depends on layout orientation).
func doToggleSelectionAndSelectNext(ctx context.Context, state *Peco, e Event) {
	batchAction(ctx, state, func(ctx context.Context) {
		doToggleSelection(ctx, state, e)
		if state.LayoutType() != config.LayoutTypeBottomUp {
			state.Hub().SendPaging(ctx, hub.ToLineBelow)
		} else {
			state.Hub().SendPaging(ctx, hub.ToLineAbove)
		}
	})
}

// doInvertSelection inverts the selection state of every line in the current buffer.
func doInvertSelection(ctx context.Context, state *Peco, _ Event) {
	if pdebug.Enabled {
		g := pdebug.Marker("doInvertSelection")
		defer g.End()
	}

	selection := state.Selection()
	b := state.CurrentLineBuffer()

	for x := range b.Size() {
		l, err := b.LineAt(x)
		if err != nil {
			continue
		}
		if selection.Has(l) {
			l.SetDirty(true)
			selection.Remove(l)
		} else {
			selectLine(l, selection)
		}
	}

	state.Hub().SendDraw(ctx, nil)
}

// doDeleteBackwardWord deletes the word before the cursor in the query, handling
// whitespace boundaries, then re-runs the query.
func doDeleteBackwardWord(ctx context.Context, state *Peco, _ Event) {
	if pdebug.Enabled {
		g := pdebug.Marker("doDeleteBackwardWord")
		defer g.End()
	}

	c := state.Caret()
	if c.Pos() == 0 {
		return
	}

	q := state.Query()
	pos := q.Len()
	if l := q.Len(); l <= c.Pos() {
		pos = l
	}

	sepFunc := unicode.IsSpace
	if unicode.IsSpace(q.RuneAt(pos - 1)) {
		sepFunc = func(r rune) bool { return !unicode.IsSpace(r) }
	}

	found := false
	start := pos
	for pos = start - 1; pos >= 0; pos-- {
		if sepFunc(q.RuneAt(pos)) {
			q.DeleteRange(pos+1, start)
			c.SetPos(pos + 1)
			found = true
			break
		}
	}

	if !found {
		q.DeleteRange(0, start)
		c.SetPos(0)
	}
	execQueryAndDraw(ctx, state)
}

// doForwardWord moves the cursor forward to the beginning of the next word in the query.
func doForwardWord(ctx context.Context, state *Peco, _ Event) {
	if state.Caret().Pos() >= state.Query().Len() {
		return
	}
	defer state.Hub().SendDrawPrompt(ctx)

	foundSpace := false
	q := state.Query()
	c := state.Caret()
	for pos := c.Pos(); pos < q.Len(); pos++ {
		r := q.RuneAt(pos)
		if foundSpace {
			if !unicode.IsSpace(r) {
				c.SetPos(pos)
				return
			}
		} else {
			if unicode.IsSpace(r) {
				foundSpace = true
			}
		}
	}

	// not found. just move to the end of the buffer
	c.SetPos(q.Len())
}

// doBackwardWord moves the cursor backward to the beginning of the previous word in the query.
func doBackwardWord(ctx context.Context, state *Peco, _ Event) {
	c := state.Caret()
	q := state.Query()
	if c.Pos() == 0 {
		return
	}
	defer state.Hub().SendDrawPrompt(ctx)

	if c.Pos() >= q.Len() {
		c.Move(-1)
	}

	// if we start from a whitespace-ish position, we should
	// rewind to the end of the previous word, and then do the
	// search all over again
	for {
		if unicode.IsSpace(q.RuneAt(c.Pos())) {
			for pos := c.Pos(); pos > 0; pos-- {
				if !unicode.IsSpace(q.RuneAt(pos)) {
					c.SetPos(pos)
					break
				}
			}
		}

		// if we start from the first character of a word, we
		// should attempt to move back and search for the previous word
		if c.Pos() > 0 && unicode.IsSpace(q.RuneAt(c.Pos()-1)) {
			c.Move(-1)
			continue
		}
		break
	}

	// Now look for a space
	for pos := c.Pos(); pos > 0; pos-- {
		if unicode.IsSpace(q.RuneAt(pos)) {
			c.SetPos(pos + 1)
			return
		}
	}

	// not found. just move to the beginning of the buffer
	c.SetPos(0)
}

// doForwardChar moves the cursor one character forward in the query.
func doForwardChar(ctx context.Context, state *Peco, _ Event) {
	c := state.Caret()
	if c.Pos() >= state.Query().Len() {
		return
	}
	c.Move(1)
	state.Hub().SendDrawPrompt(ctx)
}

// doBackwardChar moves the cursor one character backward in the query.
func doBackwardChar(ctx context.Context, state *Peco, _ Event) {
	c := state.Caret()
	if c.Pos() <= 0 {
		return
	}
	c.Move(-1)
	state.Hub().SendDrawPrompt(ctx)
}

// doDeleteForwardWord deletes the word (or whitespace run) after the cursor in the query.
func doDeleteForwardWord(ctx context.Context, state *Peco, _ Event) {
	c := state.Caret()
	q := state.Query()
	start := c.Pos()

	if q.Len() <= start {
		return
	}

	// If we are on a word (non-Space, delete till the end of the word.
	// If we are on a space, delete till the end of space.
	sepFunc := unicode.IsSpace
	if unicode.IsSpace(q.RuneAt(start)) {
		sepFunc = func(r rune) bool { return !unicode.IsSpace(r) }
	}

	for pos := start; pos < q.Len(); pos++ {
		if pos == q.Len()-1 {
			q.DeleteRange(start, q.Len())
			c.SetPos(start)
			break
		}

		if sepFunc(q.RuneAt(pos)) {
			q.DeleteRange(start, pos)
			c.SetPos(start)
			break
		}
	}

	execQueryAndDraw(ctx, state)
}

// doBeginningOfLine moves the cursor to the beginning of the query line.
func doBeginningOfLine(ctx context.Context, state *Peco, _ Event) {
	state.Caret().SetPos(0)
	state.Hub().SendDrawPrompt(ctx)
}

// doEndOfLine moves the cursor to the end of the query line.
func doEndOfLine(ctx context.Context, state *Peco, _ Event) {
	state.Caret().SetPos(state.Query().Len())
	state.Hub().SendDrawPrompt(ctx)
}

// doEndOfFile deletes the character at the cursor if the query is non-empty, or cancels
// the session if the query is empty (similar to Ctrl-D behavior in a shell).
func doEndOfFile(ctx context.Context, state *Peco, e Event) {
	if state.Query().Len() > 0 {
		doDeleteForwardChar(ctx, state, e)
	} else {
		doCancel(ctx, state, e)
	}
}

// doKillBeginningOfLine deletes all text from the cursor to the beginning of the query.
func doKillBeginningOfLine(ctx context.Context, state *Peco, _ Event) {
	q := state.Query()
	q.DeleteRange(0, state.Caret().Pos())
	state.Caret().SetPos(0)
	execQueryAndDraw(ctx, state)
}

// doKillEndOfLine deletes all text from the cursor to the end of the query.
func doKillEndOfLine(ctx context.Context, state *Peco, _ Event) {
	if state.Query().Len() <= state.Caret().Pos() {
		return
	}

	q := state.Query()
	q.DeleteRange(state.Caret().Pos(), q.Len())
	execQueryAndDraw(ctx, state)
}

// doDeleteAll clears the entire query string and re-runs the query.
func doDeleteAll(ctx context.Context, state *Peco, _ Event) {
	state.Query().Reset()
	state.ExecQuery(ctx, state.selectOneCallback())
}

// doDeleteForwardChar deletes the character at the current cursor position in the query.
func doDeleteForwardChar(ctx context.Context, state *Peco, _ Event) {
	q := state.Query()
	c := state.Caret()
	if q.Len() <= c.Pos() {
		return
	}

	pos := c.Pos()
	q.DeleteRange(pos, pos+1)

	execQueryAndDraw(ctx, state)
}

// doDeleteBackwardChar deletes the character immediately before the cursor in the query.
func doDeleteBackwardChar(ctx context.Context, state *Peco, _ Event) {
	if pdebug.Enabled {
		g := pdebug.Marker("doDeleteBackwardChar")
		defer g.End()
	}

	q := state.Query()
	c := state.Caret()
	qlen := q.Len()
	if qlen <= 0 {
		if pdebug.Enabled {
			pdebug.Printf("doDeleteBackwardChar: QueryLen <= 0, do nothing")
		}
		return
	}

	pos := c.Pos()
	if pos == 0 {
		if pdebug.Enabled {
			pdebug.Printf("doDeleteBackwardChar: Already at position 0")
		}
		// No op
		return
	}

	if qlen == 1 {
		// Micro optimization
		q.Reset()
	} else {
		q.DeleteRange(pos-1, pos)
	}
	c.SetPos(pos - 1)

	execQueryAndDraw(ctx, state)
}

// doRefreshScreen forces a full screen redraw with cache disabled and synchronous rendering.
func doRefreshScreen(ctx context.Context, state *Peco, _ Event) {
	state.Hub().SendDraw(ctx, &hub.DrawOptions{DisableCache: true, ForceSync: true})
}

// doToggleQuery swaps between the current query and the last saved query, then re-runs the filter.
func doToggleQuery(ctx context.Context, state *Peco, _ Event) {
	if pdebug.Enabled {
		g := pdebug.Marker("doToggleQuery")
		defer g.End()
	}

	q := state.Query()
	if q.Len() == 0 {
		q.RestoreSavedQuery()
	} else {
		q.SaveQuery()
	}

	execQueryAndDraw(ctx, state)
}

// doKonamiCommand is an easter egg triggered by the Konami code key sequence.
func doKonamiCommand(ctx context.Context, state *Peco, _ Event) {
	state.Hub().SendStatusMsg(ctx, "All your filters are belongs to us", 0)
}

// doToggleSingleKeyJump toggles single-key-jump mode on or off.
func doToggleSingleKeyJump(ctx context.Context, state *Peco, _ Event) {
	if pdebug.Enabled {
		g := pdebug.Marker("doToggleSingleKeyJump")
		defer g.End()
	}
	state.ToggleSingleKeyJumpMode(ctx)
}

// doToggleViewAround clears the query and jumps to the current line's position in the
// unfiltered source, effectively toggling between filtered and context views.
func doToggleViewAround(ctx context.Context, state *Peco, e Event) {
	if pdebug.Enabled {
		g := pdebug.Marker("doToggleViewAround")
		defer g.End()
	}
	q := state.Query()

	if q.Len() > 0 {
		l, err := state.CurrentLineBuffer().LineAt(state.Location().LineNumber())
		if err != nil {
			return
		}
		currentLine := l.ID()

		doDeleteAll(ctx, state, e)
		state.Hub().SendPaging(ctx, hub.JumpToLineRequest(currentLine))
	}
}

// doGoToNextSelection moves the cursor to the next selected line, wrapping around if needed.
func doGoToNextSelection(ctx context.Context, state *Peco, _ Event) {
	if pdebug.Enabled {
		g := pdebug.Marker("doGoToNextSelection")
		defer g.End()
	}
	doGoToAdjacentSelection(ctx, state, true)
}

// doGoToPreviousSelection moves the cursor to the previous selected line, wrapping around if needed.
func doGoToPreviousSelection(ctx context.Context, state *Peco, _ Event) {
	if pdebug.Enabled {
		g := pdebug.Marker("doGoToPreviousSelection")
		defer g.End()
	}
	doGoToAdjacentSelection(ctx, state, false)
}

// doGoToAdjacentSelection navigates to the next (forward=true) or previous
// (forward=false) selected line, wrapping around if necessary.
func doGoToAdjacentSelection(ctx context.Context, state *Peco, forward bool) {
	selection := state.Selection()

	if selection.Len() == 0 {
		state.Hub().SendStatusMsg(ctx, "No Selection", 0)
		return
	}

	b := state.CurrentLineBuffer()
	l, err := b.LineAt(state.Location().LineNumber())
	if err != nil {
		return
	}
	currentLine := l.ID()

	// target: the nearest selected line in the desired direction.
	// wrapTarget: the line to jump to when wrapping around
	//   (smallest ID for forward, largest ID for backward).
	var target, wrapTarget uint64
	if forward {
		target = math.MaxUint64
		wrapTarget = math.MaxUint64
	}
	found := false

	selection.Ascend(func(l line.Line) bool {
		id := l.ID()
		if forward {
			if id > currentLine && id < target {
				target = id
				found = true
			}
			if id <= wrapTarget {
				wrapTarget = id
			}
		} else {
			if id < currentLine && id > target {
				target = id
				found = true
			}
			if id > wrapTarget {
				wrapTarget = id
			}
		}
		return true
	})

	label := "Next"
	if !forward {
		label = "Previous"
	}

	if found {
		state.Hub().SendStatusMsg(ctx, label+" Selection", 0)
		state.Hub().SendPaging(ctx, hub.ToScrollFirstItem)
		state.Hub().SendPaging(ctx, hub.JumpToLineRequest(target))
	} else {
		state.Hub().SendStatusMsg(ctx, label+" Selection (first)", 0)
		state.Hub().SendPaging(ctx, hub.ToScrollFirstItem)
		state.Hub().SendPaging(ctx, hub.JumpToLineRequest(wrapTarget))
	}
}

// resetQueryState clears the query, resets the caret, and (unless sticky
// selection is enabled) clears the selection. Used by freeze/unfreeze to
// return to a clean query state.
func resetQueryState(state *Peco) {
	state.Query().Reset()
	state.Caret().SetPos(0)
	if !state.config.StickySelection {
		state.Selection().Reset()
	}
}

// doFreezeResults snapshots the current result set into a frozen buffer, preventing
// further filtering until unfrozen.
func doFreezeResults(ctx context.Context, state *Peco, _ Event) {
	if pdebug.Enabled {
		g := pdebug.Marker("doFreezeResults")
		defer g.End()
	}

	b := state.CurrentLineBuffer()
	if b.Size() == 0 {
		state.Hub().SendStatusMsg(ctx, "Nothing to freeze", 0)
		return
	}

	frozen := NewMemoryBuffer(b.Size())
	for i := range b.Size() {
		if l, err := b.LineAt(i); err == nil {
			frozen.AppendLine(l)
		}
	}
	frozen.MarkComplete()

	state.Frozen().Set(frozen)
	resetQueryState(state)
	state.SetCurrentLineBuffer(ctx, frozen)
	state.Hub().SendStatusMsg(ctx, "Results frozen", 0)
	state.Hub().SendDrawPrompt(ctx)
}

// doUnfreezeResults restores live filtering by clearing the frozen buffer and resetting the query.
func doUnfreezeResults(ctx context.Context, state *Peco, _ Event) {
	if pdebug.Enabled {
		g := pdebug.Marker("doUnfreezeResults")
		defer g.End()
	}

	if state.Frozen().Source() == nil {
		state.Hub().SendStatusMsg(ctx, "No frozen results", 0)
		return
	}

	state.Frozen().Clear()
	resetQueryState(state)
	state.ResetCurrentLineBuffer(ctx)
	state.Hub().SendStatusMsg(ctx, "Results unfrozen", 0)
	state.Hub().SendDrawPrompt(ctx)
}

// doZoomIn expands the view to show context lines around matched lines by building
// a ContextBuffer from the current filter results and the original source.
func doZoomIn(ctx context.Context, state *Peco, _ Event) {
	if pdebug.Enabled {
		g := pdebug.Marker("doZoomIn")
		defer g.End()
	}

	// Already zoomed in?
	if state.Zoom().Buffer() != nil {
		state.Hub().SendStatusMsg(ctx, "Already zoomed in", 0)
		return
	}

	// Get the current line buffer
	currentBuf := state.CurrentLineBuffer()

	// If the current buffer is the source (no active filter), nothing to zoom into
	if currentBuf == state.source {
		state.Hub().SendStatusMsg(ctx, "Nothing to zoom into", 0)
		return
	}

	source := state.source
	contextSize := 3

	contextBuf := NewContextBuffer(currentBuf, source, contextSize)
	if contextBuf.Size() == 0 {
		state.Hub().SendStatusMsg(ctx, "Nothing to zoom into", 0)
		return
	}

	// Save current state for ZoomOut
	loc := state.Location()
	curLineNo := loc.LineNumber()
	state.Zoom().Set(currentBuf, curLineNo)

	// Map cursor to the new context buffer position
	newLineNo := 0
	indices := contextBuf.MatchEntryIndices()
	if curLineNo >= 0 && curLineNo < len(indices) && indices[curLineNo] >= 0 {
		newLineNo = indices[curLineNo]
	}

	state.setCurrentLineBufferNoNotify(contextBuf)

	loc.SetLineNumber(newLineNo)
	state.Hub().SendDraw(ctx, &hub.DrawOptions{DisableCache: true})
}

// doZoomOut restores the pre-zoom line buffer, collapsing the expanded context view.
func doZoomOut(ctx context.Context, state *Peco, _ Event) {
	if pdebug.Enabled {
		g := pdebug.Marker("doZoomOut")
		defer g.End()
	}

	preZoom := state.Zoom().Buffer()
	if preZoom == nil {
		state.Hub().SendStatusMsg(ctx, "Not zoomed in", 0)
		return
	}

	loc := state.Location()
	savedLineNo := state.Zoom().LineNo()

	state.setCurrentLineBufferNoNotify(preZoom)

	loc.SetLineNumber(savedLineNo)
	state.Zoom().Clear()
	state.Hub().SendDraw(ctx, &hub.DrawOptions{DisableCache: true})
}

// doSingleKeyJump looks up the line index for the pressed key and jumps to it, then finishes.
func doSingleKeyJump(ctx context.Context, state *Peco, e Event) {
	if pdebug.Enabled {
		g := pdebug.Marker("doSingleKeyJump %c", e.Ch)
		defer g.End()
	}
	index, ok := state.SingleKeyJump().Index(e.Ch)
	if !ok {
		// Couldn't find it? Do nothing
		return
	}

	batchAction(ctx, state, func(ctx context.Context) {
		state.Hub().SendPaging(ctx, hub.JumpToLineRequest(index))
		doFinish(ctx, state, e)
	})
}

// makeCombinedAction creates a composite action that executes multiple actions sequentially in a batch.
func makeCombinedAction(actions ...Action) ActionFunc {
	return ActionFunc(func(ctx context.Context, state *Peco, e Event) {
		batchAction(ctx, state, func(ctx context.Context) {
			for _, a := range actions {
				a.Execute(ctx, state, e)
			}
		})
	})
}
