// Package hostlist implements the host list view.
package hostlist

import (
	"context"
	"errors"
	"fmt"
	"regexp"
	"slices"
	"strings"

	"charm.land/bubbles/v2/key"
	"charm.land/bubbles/v2/list"
	tea "charm.land/bubbletea/v2"
	"github.com/samber/lo"

	"github.com/grafviktor/goto/internal/constant"
	hostModel "github.com/grafviktor/goto/internal/model/host"
	"github.com/grafviktor/goto/internal/state"
	"github.com/grafviktor/goto/internal/storage"
	"github.com/grafviktor/goto/internal/ui/message"
	"github.com/grafviktor/goto/internal/utils"
)

var (
	itemNotSelectedErrMsg = "you must select an item"
	modeCloseApp          = "closeApp"
	modeDefault           = ""
	modeRemoveItem        = "removeItem"
	modeSSHCopyID         = "sshCopyID"
	defaultListTitle      = "press 'n' to add a new host"
)

type iLogger interface {
	Debug(format string, args ...any)
	Info(format string, args ...any)
	Error(format string, args ...any)
}

type msgToggleLayout struct{ layout constant.ScreenLayout }

type ListModel struct {
	list.Model

	repo     storage.HostStorage
	keyMap   *keyMap
	appState *state.State
	logger   iLogger
	mode     string
	styles   styles
}

// New - creates new host list model.
// context - is not used.
// storage - is the data layer.
// appState - is the application state, usually we want to restore previous state when application restarts,
// for instance focus previously selected host.
// log - application logger.
func New(_ context.Context, storage storage.HostStorage, appState *state.State, log iLogger) *ListModel {
	delegate := NewHostDelegate(&appState.ScreenLayout, &appState.Group, log)
	delegateKeys := newDelegateKeyMap()

	var listItems []list.Item
	model := list.New(listItems, delegate, 0, 0)
	model.Filter = hostListFilter

	// Setup styles.
	styles := defaultStyles()
	model.Styles = styles.list
	filterStyles := model.FilterInput.Styles()
	filterStyles.Focused.Prompt = styles.prompt
	filterStyles.Focused.Text = styles.filterInput
	model.FilterInput.SetStyles(filterStyles)

	model.Paginator.ActiveDot = styles.paginatorActiveDot
	model.Paginator.InactiveDot = styles.paginatorInactiveDot
	model.Help.Styles = styles.help

	m := ListModel{
		Model:    model,
		keyMap:   delegateKeys,
		repo:     storage,
		appState: appState,
		logger:   log,
		styles:   styles,
	}

	m.KeyMap.CursorUp.Unbind()
	m.KeyMap.CursorUp = delegateKeys.cursorUp
	m.KeyMap.CursorDown.Unbind()
	m.KeyMap.CursorDown = delegateKeys.cursorDown

	// Additional key mappings for the short and full help views. This allows
	// you to add additional key mappings to the help menu without
	// re-implementing the help component.
	m.AdditionalShortHelpKeys = delegateKeys.ShortHelp
	m.AdditionalFullHelpKeys = delegateKeys.FullHelp

	m.Title = defaultListTitle

	return &m
}

func (m *ListModel) Init() tea.Cmd {
	// This function is called from model.go#init() file
	return m.loadHosts()
}

func (m *ListModel) loadHosts() tea.Cmd {
	m.logger.Debug("[UI] Load hostnames from the database")
	hosts, err := m.repo.GetAll()
	if err != nil {
		m.logger.Error("[UI] Cannot read database. %v", err)
		return message.TeaCmd(message.ExitWithError{Err: err})
	}

	// If host group is selected only load hosts from this group.
	if m.appState.Group != "" {
		hosts = lo.Filter(hosts, func(h hostModel.Host, _ int) bool {
			return strings.EqualFold(h.Group, m.appState.Group)
		})
	}

	// Wrap hosts into List items.
	items := make([]list.Item, 0, len(hosts))
	for _, h := range hosts {
		items = append(items, ListItemHost{Host: h})
	}

	slices.SortFunc(items, hostComparator)
	setItemsCmd := m.SetItems(items)
	selectHostByIDCmd := m.selectHostByID(m.appState.Selected)
	return tea.Sequence(setItemsCmd, selectHostByIDCmd)
}

func (m *ListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case tea.KeyPressMsg:
		return m, m.handleKeyboardEvent(msg)
	case tea.WindowSizeMsg:
		// Triggers immediately after app start because we render this component by default
		h, v := m.styles.componentMargins.GetFrameSize()
		m.SetSize(msg.Width-h, msg.Height-v)
		m.logger.Debug("[UI] Set host list size: %d %d", m.Width(), m.Height())
		return m, nil
	case message.HostSSHConfigLoadComplete:
		m.onHostSSHConfigLoaded(msg)
		return m, nil
	case message.HostUpdate:
		cmd := m.onHostUpdated(msg)
		return m, cmd
	case message.HostCreate:
		cmd := m.onHostCreated(msg)
		return m, cmd
	case message.GroupSelect:
		cmd := m.onGroupSelect(msg)
		return m, cmd
	case message.HideUINotification:
		if msg.ComponentName == "hostlist" {
			m.logger.Debug("[UI] Hide notification message")
			m.updateTitle()
		}

		return m, nil
	case message.ErrorOccurred:
		return m, m.displayNotificationMsg(msg.Err.Error())
	default:
		m.SetShowStatusBar(m.FilterState() != list.Unfiltered)
		return m, m.updateChildModel(msg)
	}
}

func (m *ListModel) handleKeyboardEvent(msg tea.KeyPressMsg) tea.Cmd {
	switch {
	case m.SettingFilter():
		m.logger.Debug("[UI] Process key message when in filter mode")
		// If filter is enabled, we should not handle any keyboard messages,
		// as it should be done by filter component.
		if key.Matches(msg, m.KeyMap.AcceptWhileFiltering) {
			// When user presses enter key while in filter mode, we should load SSH config.
			m.logger.Debug("[UI] Focus item while in filter mode")
			return tea.Sequence(m.updateChildModel(msg), m.onFocusChanged())
		} else if key.Matches(msg, m.KeyMap.CancelWhileFiltering) {
			// When user presses Escape key while in filter mode without accepting filter results.
			// Be aware that focus will be set to the first item from the search results, though we haven't
			// selected it explicitly.
			m.logger.Debug("[UI] Clear and exit filter mode")

			if hostItem, ok := m.SelectedItem().(ListItemHost); ok {
				selectedID := hostItem.ID
				return tea.Sequence(m.updateChildModel(msg), m.selectHostByID(selectedID))
			}
		}
		return m.updateChildModel(msg)
	case key.Matches(msg, m.Model.KeyMap.ClearFilter):
		// When user clears the host filter, child model resets the focus. Explicitly set focus on previously selected item.
		selectedID := m.SelectedItem().(ListItemHost).ID //nolint:errcheck // SelectedItem always returns ListItemHost
		return tea.Sequence(m.updateChildModel(msg), m.selectHostByID(selectedID))
	case m.mode != modeDefault:
		// Handle key event when some mode is enabled. For instance "removeMode".
		return m.handleKeyEventWhenModeEnabled(msg)
	case key.Matches(msg, m.keyMap.selectGroup):
		return message.TeaCmd(message.ViewGroupListOpen{})
	case key.Matches(msg, m.keyMap.connect):
		return m.constructProcessCmd(constant.ProcessTypeSSHConnect)
	case key.Matches(msg, m.keyMap.copyID):
		return m.enterSSHCopyIDMode()
	case key.Matches(msg, m.keyMap.remove):
		return m.enterRemoveItemMode()
	case key.Matches(msg, m.keyMap.edit):
		return m.editItem()
	case key.Matches(msg, m.keyMap.append):
		return message.TeaCmd(message.ViewHostEditOpen{}) // When create a new item, jump to edit mode.
	case key.Matches(msg, m.keyMap.clone):
		return m.copyItem()
	case key.Matches(msg, m.keyMap.toggleLayout):
		return m.onToggleLayout()
	case msg.Key().Code == tea.KeyEsc:
		m.logger.Debug("[UI] Receive Escape key. Ask user for confirmation to close the app.")
		m.enterCloseAppMode()
		return nil
	default:
		cmd := m.updateChildModel(msg)
		return tea.Sequence(cmd, m.onFocusChanged())
	}
}

func (m *ListModel) View() tea.View {
	return tea.NewView(m.styles.componentMargins.Render(m.Model.View()))
}

func (m *ListModel) updateChildModel(msg tea.Msg) tea.Cmd {
	var cmd tea.Cmd
	m.Model, cmd = m.Model.Update(msg)

	return cmd
}

/*
 * Actions.
 */

func (m *ListModel) removeItem() tea.Cmd {
	m.logger.Debug("[UI] Remove host from the database")
	// Potential bug when filter is enabled as selected item reads from collection duplicate!
	// Consider taking "item" from m.Items()
	item, ok := m.SelectedItem().(ListItemHost)
	if !ok {
		// We should not be here at all, because delete
		// button isn't available when a host is not selected.
		m.logger.Error("[UI] Cannot cast selected item to host model")
		return message.TeaCmd(message.ErrorOccurred{Err: errors.New(itemNotSelectedErrMsg)})
	}

	err := m.repo.Delete(item.ID)
	if err != nil {
		m.logger.Debug("[UI] Error removing host from the database. %v", err)
		return message.TeaCmd(message.ErrorOccurred{Err: err})
	}

	_, index, _ := lo.FindIndexOf(m.Items(), func(i list.Item) bool {
		return i.(ListItemHost).ID == item.ID //nolint:errcheck // i always contains ListItemHost
	})

	/*
		nolint-godox BUG: Steps to reproduce:
		Create hosts with following titles:
		1
		1 (7)
		1 (7) (1)
		1 (7) (2)
		1 (7) (3)

		Go into filter mode and type "7"
		Delete host "1 (7) (2)"
		Go to host "1 (7)" and copy it
		Host "1 (7) (2)" will be re-created. All looks correct:
		1
		1 (7)
		1 (7) (1)
		1 (7) (2) // If you try to edit it, all fields will be empty
		1 (7) (3)

		Now restart the application and notice that re-created host was saved with a wrong title:
		1
		1 (7)
		1 (7) (1)
		1 (7) (1) // WRONG: Should be "1 (7) (2)"
		1 (7) (3)

		Brief analysis:
		When filter is enabled and user deletes and item, list.go modifies 2 collections simultaneously:
		1. m.items
		2. m.filteredItems

		These 2 collections are modified by RemoveItem method where we send the index of the item which should be removed.
		Because the index of the same item can be different in m.items and m.filteredItems, RemoveItem deletes different
		items in m.Items and m.filteredItems:

		m.items
			1
			1 (7)
			1 (7) (1)
			1 (7) (2) // index = 3, in m.items we delete item "1 (7) (2)"
			1 (7) (3)

		m.filteredItems
			1 (7)
			1 (7) (1)
			1 (7) (2)
			1 (7) (3) // index = 3, in m.filteredItems we delete item "1 (7) (3)"

		To raise a bug in https://charm.land/bubbles/v2 project
	*/
	m.Model.RemoveItem(index)
	// We have to reset filter when remove an item from the list because of the aforementioned bug.
	m.Model.ResetFilter()

	if index >= 1 {
		// If it's not the first item in the list, then let's focus on the previous one.
		m.Select(index - 1)
	}

	return tea.Sequence(
		m.onFocusChanged(),
		m.displayNotificationMsg(fmt.Sprintf("deleted \"%s\"", item.Title())),
	)
}

func (m *ListModel) editItem() tea.Cmd {
	item, ok := m.SelectedItem().(ListItemHost)
	if !ok {
		return message.TeaCmd(message.ErrorOccurred{Err: errors.New(itemNotSelectedErrMsg)})
	}

	// m.Model.ResetFilter()
	m.logger.Info("[UI] Edit item id: %d, title: %s", item.ID, item.Title())
	return tea.Sequence(
		message.TeaCmd(message.ViewHostEditOpen{HostID: item.ID}),
		// Load SSH config for the selected host
		message.TeaCmd(message.RunProcessSSHLoadConfig{Host: item.Host}),
	)
}

func (m *ListModel) copyItem() tea.Cmd {
	item, ok := m.SelectedItem().(ListItemHost)
	if !ok {
		m.logger.Error("[UI] Cannot cast selected item to host model")
		return message.TeaCmd(message.ErrorOccurred{Err: errors.New(itemNotSelectedErrMsg)})
	}

	originalHost := item.Host
	m.logger.Info("[UI] Copy host item id: %d, title: %s", originalHost.ID, originalHost.Title)
	clonedHost := originalHost.Clone()
	for i := 1; ok; i++ {
		// Keep generating new title until it's unique
		clonedHostTitle := fmt.Sprintf("%s (%d)", originalHost.Title, i)
		listItems := m.Items()
		idx := slices.IndexFunc(listItems, func(li list.Item) bool {
			return li.(ListItemHost).Title() == clonedHostTitle //nolint:errcheck // list item always contains ListItemHost
		})

		// If title is unique, then we assign the title to the cloned host
		if idx < 0 {
			clonedHost.Title = clonedHostTitle
			break
		}
	}

	var err error
	// Re-assign clonedHost to obtain host ID which is assigned by the database
	if clonedHost, err = m.repo.Save(clonedHost); err != nil {
		return message.TeaCmd(message.ErrorOccurred{Err: err})
	}

	titles := lo.Reduce(m.Items(), func(agg []string, item list.Item, _ int) []string {
		return append(agg, item.(ListItemHost).Title()) //nolint:errcheck // item always contains ListItemHost
	}, []string{clonedHost.Title})

	slices.Sort(titles)
	index := lo.IndexOf(titles, clonedHost.Title)
	// We should NOT call onFocusChanged here, because we do not change focus when copying an item.
	return tea.Sequence(
		m.Model.InsertItem(index, ListItemHost{Host: clonedHost}),
		m.displayNotificationMsg(fmt.Sprintf("cloned \"%s\"", clonedHost.Title)),
	)
}

/*
 * Event handlers - those events come from other components.
 */

// onHostUpdated - not only updates a host, it also re-inserts the host into
// a correct position of the host list, to keep it sorted.
func (m *ListModel) onHostUpdated(msg message.HostUpdate) tea.Cmd {
	updatedHost := ListItemHost{Host: msg.Host}
	// Get all item titles, replacing the updated host's title
	allItems := lo.Map(m.Items(), func(item list.Item, _ int) list.Item {
		host, _ := item.(ListItemHost)
		return lo.Ternary(host.ID == updatedHost.ID, updatedHost, host)
	})

	slices.SortFunc(allItems, hostComparator)
	_, newIndex, _ := lo.FindIndexOf(allItems, func(item list.Item) bool {
		return updatedHost.ID == item.(ListItemHost).ID //nolint:errcheck // item always contains ListItemHost
	})

	_, currentIndex, _ := lo.FindIndexOf(m.Items(), func(item list.Item) bool {
		return updatedHost.ID == item.(ListItemHost).ID //nolint:errcheck // item always contains ListItemHost
	})

	// Do not use m.Index(), as it returns Visible Index, whilst
	// all other functions require the index among all items.
	cmd := lo.Ternary(
		newIndex == currentIndex,
		m.SetItem(currentIndex, updatedHost),
		m.setItemAndReorder(newIndex, currentIndex, updatedHost),
	)

	return tea.Sequence(
		cmd,
		m.onFocusChanged(),
		m.displayNotificationMsg(fmt.Sprintf("saved %q", updatedHost.Title())),
	)
}

func (m *ListModel) setItemAndReorder(newIndex, currentIndex int, updatedHost ListItemHost) tea.Cmd {
	m.Model.RemoveItem(currentIndex)
	cmd := m.Model.InsertItem(newIndex, updatedHost)

	// The collection is not yet updated and m.VisibleItems() may NOT contain the updated host yet,
	// filtering is enabled. However, we must predict the new host index.
	visibleItems := lo.Map(m.VisibleItems(), func(item list.Item, _ int) list.Item {
		host, _ := item.(ListItemHost)
		return lo.Ternary(host.ID == updatedHost.ID, updatedHost, host)
	})

	slices.SortFunc(visibleItems, hostComparator)
	_, newVisibleItemsIndex, _ := lo.FindIndexOf(visibleItems, func(item list.Item) bool {
		return updatedHost.ID == item.(ListItemHost).ID //nolint:errcheck // item always contains ListItemHost
	})

	m.Select(newVisibleItemsIndex)
	return cmd
}

func (m *ListModel) onHostCreated(msg message.HostCreate) tea.Cmd {
	// ResetFilter is required here because, user can create a new Item which will be filtered out,
	// therefore the user will not see any changes in the UI which is confusing.
	// ResetFilter must be done before calculating index of the new item.
	// Consider checking if the item will be filtered out or not before deciding whether to reset the filter.
	m.ResetFilter()
	createdHostItem := ListItemHost{Host: msg.Host}

	// Create a safe copy of visible items to avoid modifying the original collection
	items := append([]list.Item{createdHostItem}, m.VisibleItems()...)
	slices.SortFunc(items, hostComparator)
	_, index, _ := lo.FindIndexOf(items, func(item list.Item) bool {
		return createdHostItem.ID == item.(ListItemHost).ID //nolint:errcheck // item always contains ListItemHost
	})

	cmd := m.Model.InsertItem(index, createdHostItem)

	// m.Select requires a visible item index, but because we reset filter VisibleItems array equals to Items
	m.Select(index)

	return tea.Sequence(
		// If host position coincides with other host, then let the underlying model to handle that
		cmd,
		m.onFocusChanged(),
		m.displayNotificationMsg(fmt.Sprintf("created %q", createdHostItem.Title())),
	)
}

func (m *ListModel) onGroupSelect(msg message.GroupSelect) tea.Cmd {
	var cmds []tea.Cmd
	m.logger.Debug("[UI] Update app state. Active group: %q", msg.Name)
	m.appState.Group = msg.Name
	// Reset filter when group is selected
	m.ResetFilter()
	// We re-load hosts every time a group is selected. This is not the best way
	// to handle this, as it leads to series of hacks here and there. But it's the
	// simplest way to implement it.
	cmds = append(cmds, m.loadHosts())
	// Display selected group notification
	message := lo.Ternary(utils.StringEmpty(&msg.Name), "display all hosts", fmt.Sprintf("group %q", msg.Name))
	cmds = append(cmds, m.displayNotificationMsg(message))

	return tea.Sequence(cmds...)
}

func (m *ListModel) onFocusChanged() tea.Cmd {
	m.updateTitle()
	m.updateKeyMap()

	if hostItem, ok := m.SelectedItem().(ListItemHost); ok {
		m.logger.Debug("[UI] Focus changed to host id: %v, title: %q", hostItem.ID, hostItem.Title())

		return tea.Sequence(
			message.TeaCmd(message.HostSelect{HostID: hostItem.ID}),
			message.TeaCmd(message.RunProcessSSHLoadConfig{Host: hostItem.Host}),
		)
	}

	m.logger.Debug("[UI] Focus is not set to any item in the list")
	return nil
}

func (m *ListModel) onHostSSHConfigLoaded(msg message.HostSSHConfigLoadComplete) {
	for index, item := range m.Items() {
		if hostListItem, ok := item.(ListItemHost); ok && hostListItem.ID == msg.HostID {
			hostListItem.SSHHostConfig = &msg.Config
			m.SetItem(index, hostListItem)
			m.updateTitle()
			break
		}
	}
}

func (m *ListModel) onToggleLayout() tea.Cmd {
	m.updateChildModel(msgToggleLayout{m.appState.ScreenLayout})
	// When switch between screen layouts, it's required to update pagination.
	// ListModel's updatePagination method is private and cannot be called from
	// here. One of the ways to trigger it is to invoke model.SetSize method.
	m.Model.SetSize(m.Width(), m.Height())
	// Another hack - need to invoke inner model's private updateKeyBindings method
	// when screen layout is toggled. If not update keybindings, then pagination
	// keys stop working properly when switching between compact and normal layouts.
	// The only way to trigger updateKeyBindings is via SetFilteringEnabled method.
	// The alternative way is copy and paste the entire updateKeyBindings method into
	// this file, and invoke it from here.
	m.SetFilteringEnabled(m.FilteringEnabled())
	notificationMsg := map[constant.ScreenLayout]string{
		constant.ScreenLayoutDescription: "show description",
		constant.ScreenLayoutGroup:       "group view",
		constant.ScreenLayoutCompact:     "compact view",
	}[m.appState.ScreenLayout]

	return m.displayNotificationMsg(notificationMsg)
}

/*
 * Helper methods.
 */

func (m *ListModel) constructProcessCmd(processType constant.ProcessType) tea.Cmd {
	// Do not use m.SelectedItem() here!
	// list.Model keeps 2 collections - m.items and m.filteredItems, which can be inconsistent
	// as a result in some hosts taken from m.filteredItems ssh config is nil.
	var host *hostModel.Host
	for _, item := range m.Items() {
		if listItemHost, ok := item.(ListItemHost); ok && listItemHost.ID == m.appState.Selected {
			host = &listItemHost.Host
			break
		}
	}

	if host == nil {
		m.logger.Error("[UI] Could not find host with ID='%d'", m.appState.Selected)
		return message.TeaCmd(message.ErrorOccurred{Err: errors.New(itemNotSelectedErrMsg)})
	}

	if host.SSHHostConfig == nil {
		errorText := fmt.Sprintf("[UI] SSH config is not set for host ID='%d', Title='%s'", host.ID, host.Title)
		m.logger.Error(errorText)
		return message.TeaCmd(message.ErrorOccurred{Err: errors.New(errorText)})
	}

	switch processType { //nolint:exhaustive // allow missing cases
	case constant.ProcessTypeSSHConnect:
		return message.TeaCmd(message.RunProcessSSHConnect{Host: *host})
	case constant.ProcessTypeSSHCopyID:
		return message.TeaCmd(message.RunProcessSSHCopyID{Host: *host})
	default:
		return nil
	}
}

func (m *ListModel) updateTitle() {
	var newTitle string
	item, isHost := m.SelectedItem().(ListItemHost)
	m.resetTitleStyle()

	switch {
	case m.mode == modeSSHCopyID && isHost:
		newTitle = "copy ssh key to the remote host? (y/N)"
	case m.mode == modeRemoveItem && isHost:
		newTitle = fmt.Sprintf("delete \"%s\"? (y/N)", item.Title())
	case m.mode == modeCloseApp:
		newTitle = "close app? (y/N)"
	case isHost:
		connectCmd := cmdSSHConnectPreview(item.Host)
		newTitle = m.prefixWithGroupName(connectCmd)
	default:
		// If it's NOT a host list item, then probably the list is just empty
		newTitle = m.prefixWithGroupName(defaultListTitle)
	}

	if m.Title != newTitle {
		m.Title = newTitle
		m.logger.Debug("[UI] New list title: %q", strings.TrimSpace(utils.StripStyles(newTitle)))
	}
}

func (m *ListModel) prefixWithGroupName(title string) string {
	if !utils.StringEmpty(&m.appState.Group) {
		shortGroupName := utils.StringAbbreviation(m.appState.Group)
		title = m.Styles.Title.Render(title)
		m.Styles.Title = m.Styles.Title.Padding(0)
		return fmt.Sprintf("%s%s", m.styles.groupAbbreviation.Render(shortGroupName), title)
	}

	return title
}

// SSH config path regex. Example: "... -F /home/user/.ssh/config ...".
var sshConfigPathRe = regexp.MustCompile(`\s-F "([^"]+)"`)

const sshDefaultPort = "22"

func cmdSSHConnectPreview(h hostModel.Host) string {
	if h.StorageType == constant.HostStorageType.SSHConfig && h.SSHHostConfig != nil {
		// If ssh_config is loaded for the host, then we can build approximate connect command.
		connectCmd := fmt.Sprintf("ssh %s@%s", h.SSHHostConfig.User, h.SSHHostConfig.Hostname)
		// Display only non-default network port
		if h.SSHHostConfig.Port != "" && h.SSHHostConfig.Port != sshDefaultPort {
			connectCmd = fmt.Sprintf("%s -p %s", connectCmd, h.SSHHostConfig.Port)
		}

		return connectCmd
	}

	// Replace Windows ssh prefix "cmd /c ssh" with "ssh"
	connectCmd := strings.Replace(h.CmdSSHConnect(), "cmd /c ", "", 1)
	// Remove ssh config file path (if present)
	connectCmd = sshConfigPathRe.ReplaceAllString(connectCmd, "")

	return connectCmd
}

func (m *ListModel) updateKeyMap() {
	keyMapState := m.keyMap.UpdateKeyVisibility(m.SelectedItem())
	m.logger.Debug("[UI] Edit keyboard shortcuts: %v", keyMapState)
}

func (m *ListModel) selectHostByID(id int) tea.Cmd {
	// Use VisibleItems() instead of Items() because we need to find the correct index when deleting an item
	// while in filter mode where part of the collection is hidden. You can replicate a wrong behavior when using Items():
	// Enter filter mode, enter remove mode and then cancel it. The focus will be lost.
	_, index, found := lo.FindIndexOf(m.VisibleItems(), func(item list.Item) bool {
		hostItem, ok := item.(ListItemHost)
		return ok && hostItem.ID == id
	})

	if found {
		// Here we should check if the item with this index is already selected.
		// However, this will cause problems with title update when we enter remove
		// mode and then cancel it.
		m.Select(index)
	} else {
		// If host is not found, then we should reset the focus. Function 'onFocusChanged', will update application state.
		m.Select(0)
	}

	// This is a side effect. Ideally, it should not be here.
	return m.onFocusChanged()
}

func hostComparator(a, b list.Item) int {
	return a.(ListItemHost).CompareTo(b.(ListItemHost)) //nolint:errcheck // a and b always contain ListItemHost
}

/*
 * Deal with actions which require confirmation from the user.
 */

func (m *ListModel) enterSSHCopyIDMode() tea.Cmd {
	// Check if item is selected.
	_, ok := m.SelectedItem().(ListItemHost)
	if !ok {
		m.logger.Debug("[UI] Cannot copy id. Host is not selected.")
		return message.TeaCmd(message.ErrorOccurred{Err: errors.New(itemNotSelectedErrMsg)})
	}

	m.mode = modeSSHCopyID
	m.logger.Debug("[UI] Enter %s mode. Ask user for confirmation.", m.mode)
	m.updateTitle()

	return nil
}

func (m *ListModel) enterRemoveItemMode() tea.Cmd {
	// Check if item is selected.
	_, ok := m.SelectedItem().(ListItemHost)
	if !ok {
		m.logger.Debug("[UI] Cannot remove. Host is not selected.")
		return message.TeaCmd(message.ErrorOccurred{Err: errors.New(itemNotSelectedErrMsg)})
	}

	m.mode = modeRemoveItem
	m.logger.Debug("[UI] Enter %s mode. Ask user for confirmation.", m.mode)
	m.updateTitle()

	return nil
}

func (m *ListModel) enterCloseAppMode() {
	m.mode = modeCloseApp
	m.logger.Debug("[UI] Enter %s mode. Ask user for confirmation.", m.mode)
	m.updateTitle()
}

func (m *ListModel) handleKeyEventWhenModeEnabled(msg tea.KeyPressMsg) tea.Cmd {
	if key.Matches(msg, m.keyMap.confirm) {
		return m.confirmAction()
	}

	// If user doesn't confirm the operation, we go back to normal mode and update
	// title back to normal, this exact key event won't be handled
	m.logger.Debug("[UI] Exit %s mode. Cancel action.", m.mode)

	if hostListItem, ok := m.SelectedItem().(ListItemHost); ok {
		m.mode = modeDefault
		return m.selectHostByID(hostListItem.ID)
	}

	m.logger.Error("[UI] Exit %s mode, but cannot set focus on an item in the list of hosts.", m.mode)
	m.mode = modeDefault
	m.updateTitle()
	return nil
}

func (m *ListModel) confirmAction() tea.Cmd {
	m.logger.Debug("[UI] Exit %s mode. Confirm action.", m.mode)

	var cmd tea.Cmd

	switch m.mode {
	case modeRemoveItem:
		m.mode = modeDefault
		cmd = m.removeItem() // removeItem triggers title and keymap updates. See "onFocusChanged" method.
	case modeSSHCopyID:
		m.mode = modeDefault
		m.updateTitle()
		cmd = m.constructProcessCmd(constant.ProcessTypeSSHCopyID)
	case modeCloseApp:
		m.mode = modeDefault
		cmd = tea.Quit
	}

	return cmd
}

func (m *ListModel) SetTitle(title string) {
	m.resetTitleStyle()
	m.Title = title
}

func (m *ListModel) resetTitleStyle() {
	m.Styles.Title = m.styles.list.Title
}

func (m *ListModel) displayNotificationMsg(msg string) tea.Cmd {
	if utils.StringEmpty(&msg) {
		return nil
	}

	m.logger.Debug("[UI] Notification message: %s", msg)
	return message.DisplayNotification("hostlist", msg, m)
}
