/*
 * Copyright 2003-2023 The IdeaVim authors
 *
 * Use of this source code is governed by an MIT-style
 * license that can be found in the LICENSE.txt file or at
 * https://opensource.org/licenses/MIT.
 */
package org.jetbrains.plugins.ideavim

import com.intellij.ide.ClipboardSynchronizer
import com.intellij.ide.bookmark.BookmarksManager
import com.intellij.ide.highlighter.JavaFileType
import com.intellij.ide.highlighter.XmlFileType
import com.intellij.json.JsonFileType
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.ActionPlaces
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.ex.ActionUtil
import com.intellij.openapi.application.PathManager
import com.intellij.openapi.application.WriteAction
import com.intellij.openapi.editor.CaretVisualAttributes
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.Inlay
import com.intellij.openapi.editor.LogicalPosition
import com.intellij.openapi.editor.VisualPosition
import com.intellij.openapi.editor.colors.EditorColors
import com.intellij.openapi.editor.ex.EditorEx
import com.intellij.openapi.fileEditor.ex.FileEditorManagerEx
import com.intellij.openapi.fileTypes.FileType
import com.intellij.openapi.fileTypes.PlainTextFileType
import com.intellij.openapi.project.Project
import com.intellij.testFramework.EditorTestUtil
import com.intellij.testFramework.LightProjectDescriptor
import com.intellij.testFramework.PlatformTestUtil
import com.intellij.testFramework.fixtures.CodeInsightTestFixture
import com.intellij.testFramework.fixtures.IdeaTestFixtureFactory
import com.intellij.testFramework.fixtures.impl.LightTempDirTestFixtureImpl
import com.intellij.testFramework.junit5.RunInEdt
import com.intellij.util.ui.EmptyClipboardOwner
import com.maddyhome.idea.vim.KeyHandler
import com.maddyhome.idea.vim.VimPlugin
import com.maddyhome.idea.vim.action.VimShortcutKeyAction
import com.maddyhome.idea.vim.api.VimOptionGroup
import com.maddyhome.idea.vim.api.getKnownToggleOption
import com.maddyhome.idea.vim.api.globalOptions
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.api.options
import com.maddyhome.idea.vim.api.setToggleOption
import com.maddyhome.idea.vim.api.visualLineToBufferLine
import com.maddyhome.idea.vim.command.MappingMode
import com.maddyhome.idea.vim.command.VimStateMachine
import com.maddyhome.idea.vim.command.VimStateMachine.SubMode
import com.maddyhome.idea.vim.ex.ExException
import com.maddyhome.idea.vim.ex.ExOutputModel.Companion.getInstance
import com.maddyhome.idea.vim.group.visual.VimVisualTimer.swingTimer
import com.maddyhome.idea.vim.handler.isOctopusEnabled
import com.maddyhome.idea.vim.helper.EditorHelper
import com.maddyhome.idea.vim.helper.GuicursorChangeListener
import com.maddyhome.idea.vim.helper.RunnableHelper.runWriteCommand
import com.maddyhome.idea.vim.helper.TestInputModel
import com.maddyhome.idea.vim.helper.editorMode
import com.maddyhome.idea.vim.helper.getGuiCursorMode
import com.maddyhome.idea.vim.helper.inBlockSubMode
import com.maddyhome.idea.vim.helper.subMode
import com.maddyhome.idea.vim.key.MappingOwner
import com.maddyhome.idea.vim.key.ToKeysMappingInfo
import com.maddyhome.idea.vim.listener.SelectionVimListenerSuppressor
import com.maddyhome.idea.vim.newapi.IjVimEditor
import com.maddyhome.idea.vim.newapi.ij
import com.maddyhome.idea.vim.newapi.vim
import com.maddyhome.idea.vim.options.OptionConstants
import com.maddyhome.idea.vim.options.OptionScope
import com.maddyhome.idea.vim.options.OptionValueAccessor
import com.maddyhome.idea.vim.options.ToggleOption
import com.maddyhome.idea.vim.options.helpers.GuiCursorOptionHelper
import com.maddyhome.idea.vim.options.helpers.GuiCursorType
import com.maddyhome.idea.vim.ui.ex.ExEntryPanel
import com.maddyhome.idea.vim.vimscript.model.datatypes.VimFuncref
import com.maddyhome.idea.vim.vimscript.parser.errors.IdeavimErrorListener
import org.assertj.core.api.Assertions
import org.jetbrains.annotations.ApiStatus
import org.jetbrains.plugins.ideavim.impl.EmptyTransferable
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.TestInfo
import org.junit.jupiter.api.assertThrows
import java.awt.event.KeyEvent
import java.util.*
import javax.swing.KeyStroke
import kotlin.math.roundToInt
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue

/**
 * JUnit 5 tests
 *
 * To plugin writers: this class is internal, thus not allowed to be used by third-party plugins.
 * This is done as we have no mechanism to guarantee compatibility as we update this test case.
 * Feel free to copy this class into your plugin, or copy just needed functions.
 */
@RunInEdt
@ApiStatus.Internal
abstract class VimTestCase {
  protected lateinit var fixture: CodeInsightTestFixture

  internal lateinit var testInfo: TestInfo

  @BeforeEach
  open fun setUp(testInfo: TestInfo) {
    val factory = IdeaTestFixtureFactory.getFixtureFactory()
    val projectDescriptor = LightProjectDescriptor.EMPTY_PROJECT_DESCRIPTOR
    val fixtureBuilder = factory.createLightFixtureBuilder(projectDescriptor, "IdeaVim")
    val fixture = fixtureBuilder.fixture
    this.fixture = IdeaTestFixtureFactory.getFixtureFactory().createCodeInsightFixture(
      fixture,
      LightTempDirTestFixtureImpl(true),
    )
    this.fixture.setUp()
    this.fixture.testDataPath = testDataPath
    // Note that myFixture.editor is usually null here. It's only set once configureByText has been called
    val editor = this.fixture.editor
    if (editor != null) {
      KeyHandler.getInstance().fullReset(editor.vim)
    }
    VimPlugin.getOptionGroup().resetAllOptions()
    VimPlugin.getKey().resetKeyMappings()
    VimPlugin.getSearch().resetState()
    if (!VimPlugin.isEnabled()) VimPlugin.setEnabled(true)
    (injector.optionGroup.getOption(OptionConstants.ideastrictmode) as? ToggleOption)?.let { option ->
      injector.optionGroup.setToggleOption(option, OptionScope.GLOBAL)
    }
    GuicursorChangeListener.processGlobalValueChange(null)
    Checks.reset()
    clearClipboard()

    // Make sure the entry text field gets a bounds, or we won't be able to work out caret location
    ExEntryPanel.getInstance().entry.setBounds(0, 0, 100, 25)

    NeovimTesting.setUp(testInfo)

    VimPlugin.clearError()

    this.testInfo = testInfo
  }

  private val testDataPath: String
    get() = PathManager.getHomePath() + "/community/plugins/ideavim/testData"

  @AfterEach
  open fun tearDown(testInfo: TestInfo) {
    val swingTimer = swingTimer
    swingTimer?.stop()
    val bookmarksManager = BookmarksManager.getInstance(fixture.project)
    bookmarksManager?.bookmarks?.forEach { bookmark ->
      bookmarksManager.remove(bookmark)
    }
    SelectionVimListenerSuppressor.lock().use { fixture.tearDown() }
    ExEntryPanel.getInstance().deactivate(false)
    VimPlugin.getVariableService().clear()
    VimFuncref.lambdaCounter = 0
    VimFuncref.anonymousCounter = 0
    IdeavimErrorListener.testLogger.clear()
    VimPlugin.getRegister().resetRegisters()
    VimPlugin.getSearch().resetState()
    injector.markService.resetAllMarks()
    injector.jumpService.resetJumps()
    VimPlugin.getChange().resetRepeat()
    VimPlugin.getKey().savedShortcutConflicts.clear()

    // Tear down neovim
    NeovimTesting.tearDown(testInfo)
  }

  protected fun enableExtensions(vararg extensionNames: String) {
    for (name in extensionNames) {
      injector.optionGroup.setToggleOption(injector.optionGroup.getKnownToggleOption(name), OptionScope.GLOBAL)
    }
  }

  protected fun <T> assertEmpty(collection: Collection<T>) {
    assertTrue(collection.isEmpty(), "Collection should be empty, but it contains ${collection.size} elements")
  }

  protected fun typeTextInFile(keys: List<KeyStroke?>, fileContents: String): Editor {
    configureByText(fileContents)
    return typeText(keys)
  }

  protected fun typeTextInFile(keys: String, fileContents: String): Editor {
    configureByText(fileContents)
    return typeText(keys)
  }

  protected val screenWidth: Int
    get() = 80
  protected val screenHeight: Int
    get() = 35

  protected fun setEditorVisibleSize(width: Int, height: Int) {
    val w = (width * EditorHelper.getPlainSpaceWidthFloat(fixture.editor)).roundToInt()
    val h = height * fixture.editor.lineHeight
    EditorTestUtil.setEditorVisibleSizeInPixels(fixture.editor, w, h)
  }

  protected fun setEditorVirtualSpace() {
    // Enable virtual space at the bottom of the file and force a layout to pick up the changes
    fixture.editor.settings.isAdditionalPageAtBottom = true
    (fixture.editor as EditorEx).scrollPane.viewport.doLayout()
  }

  protected fun configureByText(content: String) = configureByText(PlainTextFileType.INSTANCE, content)
  protected fun configureByJavaText(content: String) = configureByText(JavaFileType.INSTANCE, content)
  protected fun configureByXmlText(content: String) = configureByText(XmlFileType.INSTANCE, content)
  protected fun configureByJsonText(@Suppress("SameParameterValue") content: String) =
    configureByText(JsonFileType.INSTANCE, content)

  protected fun configureAndGuard(content: String) {
    val ranges = extractBrackets(content)
    for ((start, end) in ranges) {
      fixture.editor.document.createGuardedBlock(start, end)
    }
  }

  protected fun configureAndFold(content: String, @Suppress("SameParameterValue") placeholder: String) {
    val ranges = extractBrackets(content)
    fixture.editor.foldingModel.runBatchFoldingOperation {
      for ((start, end) in ranges) {
        val foldRegion = fixture.editor.foldingModel.addFoldRegion(start, end, placeholder)
        foldRegion?.isExpanded = false
      }
    }
  }

  private fun extractBrackets(content: String): ArrayList<Pair<Int, Int>> {
    var myContent = content.replace(c, "").replace(s, "").replace(se, "")
    val ranges = ArrayList<Pair<Int, Int>>()
    while (true) {
      val start = myContent.indexOfFirst { it == '[' }
      if (start < 0) break
      myContent = myContent.removeRange(start, start + 1)
      val end = myContent.indexOfFirst { it == ']' }
      if (end < 0) break
      myContent = myContent.removeRange(end, end + 1)
      ranges.add(start to end)
    }
    configureByText(content.replace("[", "").replace("]", ""))
    return ranges
  }

  private fun configureByText(fileType: FileType, content: String): Editor {
    fixture.configureByText(fileType, content)
    NeovimTesting.setupEditor(fixture.editor, testInfo)
    setEditorVisibleSize(screenWidth, screenHeight)
    return fixture.editor
  }

  private fun configureByText(fileName: String, content: String): Editor {
    fixture.configureByText(fileName, content)
    NeovimTesting.setupEditor(fixture.editor, testInfo)
    setEditorVisibleSize(screenWidth, screenHeight)
    return fixture.editor
  }

  protected fun configureByFileName(fileName: String): Editor {
    fixture.configureByText(fileName, "\n")
    NeovimTesting.setupEditor(fixture.editor, testInfo)
    setEditorVisibleSize(screenWidth, screenHeight)
    return fixture.editor
  }

  @Suppress("SameParameterValue")
  protected fun configureByPages(pageCount: Int) {
    val stringBuilder = StringBuilder()
    repeat(pageCount * screenHeight) {
      stringBuilder.appendLine("Lorem ipsum dolor sit amet,")
    }
    configureByText(stringBuilder.toString())
  }

  protected fun configureByLines(lineCount: Int, line: String) {
    val stringBuilder = StringBuilder()
    repeat(lineCount - 1) {
      stringBuilder.appendLine(line)
    }
    stringBuilder.append(line)
    configureByText(stringBuilder.toString())
  }

  protected fun configureByColumns(columnCount: Int) {
    val content = buildString {
      repeat(columnCount) {
        append('0' + (it % 10))
      }
    }
    configureByText(content)
  }

  @JvmOverloads
  protected fun setPositionAndScroll(scrollToLogicalLine: Int, caretLogicalLine: Int, caretLogicalColumn: Int = 0) {
    // Note that it is possible to request a position which would be invalid under normal Vim!
    // We disable scrolloff + scrolljump, position as requested, and reset. When resetting scrolloff, Vim will
    // recalculate the correct offsets, and that could move the top and/or caret line
    val scrolloff = options().getIntValue(OptionConstants.scrolloff)
    val scrolljump = options().getIntValue(OptionConstants.scrolljump)

    enterCommand("set scrolloff=0")
    enterCommand("set scrolljump=1")

    typeText("${scrollToLogicalLine + 1}z<CR>", "${caretLogicalLine + 1}G", "${caretLogicalColumn + 1}|")

    enterCommand("set scrolloff=$scrolloff")
    enterCommand("set scrolljump=$scrolljump")

    // Make sure we're where we want to be. If there are block inlays, we can't easily assert the bottom line because
    // we'd have to duplicate the scrolling logic here. Asserting top when we know height is good enough
    assertTopLogicalLine(scrollToLogicalLine)
    assertPosition(caretLogicalLine, caretLogicalColumn)

    // Belt and braces. Let's make sure that the caret is fully onscreen
    val bottomLogicalLine = fixture.editor.vim.visualLineToBufferLine(
      EditorHelper.getVisualLineAtBottomOfScreen(fixture.editor),
    )
    assertTrue(bottomLogicalLine >= caretLogicalLine)
    assertTrue(caretLogicalLine >= scrollToLogicalLine)
  }

  protected fun typeText(vararg keys: String) = typeText(keys.flatMap { injector.parser.parseKeys(it) })

  protected fun typeText(keys: List<KeyStroke?>): Editor {
    val editor = fixture.editor
    NeovimTesting.typeCommand(
      keys.filterNotNull().joinToString(separator = "") { injector.parser.toKeyNotation(it) },
      testInfo,
      editor,
    )
    val project = fixture.project
    when (Checks.keyHandler) {
      Checks.KeyHandlerMethod.DIRECT_TO_VIM -> typeText(keys.filterNotNull(), editor, project)
      Checks.KeyHandlerMethod.VIA_IDE -> typeTextViaIde(keys.filterNotNull(), editor)
    }
    return editor
  }

  protected fun enterCommand(command: String): Editor {
    return typeText(commandToKeys(command))
  }

  protected fun enterSearch(pattern: String, forwards: Boolean = true): Editor {
    return typeText(searchToKeys(pattern, forwards))
  }

  protected fun setText(text: String) {
    WriteAction.runAndWait<RuntimeException> {
      fixture.editor.document.setText(text)
    }
  }

  /**
   * Gets an accessor for effective option value
   *
   * This will return an accessor to retrieve the effective value for the current editor - a global value for global
   * options (e.g. 'clipboard') or a (potentially) local value for local to buffer, local to window or global-local
   * options (e.g. 'iskeyword', 'relativenumber' or 'scrolloff' respectively). Tests are only expected to require
   * effective values. To test other global/local values, use [VimOptionGroup].
   */
  protected fun options(): OptionValueAccessor {
    assertNotNull(
      fixture.editor,
      "Editor is null! Move the call to after editor is initialised, or use optionsNoEditor",
    )
    return injector.options(fixture.editor.vim)
  }

  /**
   * Gets an option value accessor purely for global options, when there is no editor available
   *
   * Tests should normally use effective option values, via [options], but that requires a test that has created an
   * editor. If the editor isn't available, this function will return an accessor that can be used to access global
   * options only. It should not be used to access local options, as there is nothing for them to be local to.
   *
   * Note that this isn't handled automatically by [options] to avoid the scenario of trying to use effective values
   * before the editor has been initialised.
   */
  protected fun optionsNoEditor(): OptionValueAccessor {
    assertNull(fixture.editor, "Editor is not null! Use options() to access effective option values")
    return injector.globalOptions()
  }

  fun assertState(textAfter: String) {
    fixture.checkResult(textAfter)
    NeovimTesting.assertState(fixture.editor, testInfo)
  }

  protected fun assertState(modeAfter: VimStateMachine.Mode, subModeAfter: SubMode) {
    assertMode(modeAfter)
    assertSubMode(subModeAfter)
    assertCaretsVisualAttributes()
  }

  fun assertPosition(line: Int, column: Int) {
    val carets = fixture.editor.caretModel.allCarets
    assertEquals(1, carets.size, "Wrong amount of carets")
    val actualPosition = carets[0].logicalPosition
    assertEquals(LogicalPosition(line, column), actualPosition)
    NeovimTesting.assertCaret(fixture.editor, testInfo)
  }

  fun assertVisualPosition(visualLine: Int, visualColumn: Int) {
    val carets = fixture.editor.caretModel.allCarets
    assertEquals(1, carets.size, "Wrong amount of carets")
    val actualPosition = carets[0].visualPosition
    assertEquals(VisualPosition(visualLine, visualColumn), actualPosition)
  }

  fun assertOffset(vararg expectedOffsets: Int) {
    val carets = fixture.editor.caretModel.allCarets
    if (expectedOffsets.size == 2 && carets.size == 1) {
      assertEquals(
        expectedOffsets.size,
        carets.size,
        "Wrong amount of carets. Did you mean to use assertPosition?",
      )
    }
    assertEquals(expectedOffsets.size, carets.size, "Wrong amount of carets")
    for (i in expectedOffsets.indices) {
      assertEquals(expectedOffsets[i], carets[i].offset)
    }

    NeovimTesting.assertState(fixture.editor, testInfo)
  }

  fun assertOffsetAt(text: String) {
    val indexOf = fixture.editor.document.charsSequence.indexOf(text)
    if (indexOf < 0) kotlin.test.fail()
    assertOffset(indexOf)
  }

  // Use logical rather than visual lines, so we can correctly test handling of collapsed folds and soft wraps
  fun assertVisibleArea(topLogicalLine: Int, bottomLogicalLine: Int) {
    assertTopLogicalLine(topLogicalLine)
    assertBottomLogicalLine(bottomLogicalLine)
  }

  fun assertTopLogicalLine(topLogicalLine: Int) {
    val actualVisualTop = EditorHelper.getVisualLineAtTopOfScreen(fixture.editor)
    val actualLogicalTop = fixture.editor.vim.visualLineToBufferLine(actualVisualTop)

    assertEquals(topLogicalLine, actualLogicalTop, "Top logical lines don't match")
  }

  fun assertBottomLogicalLine(bottomLogicalLine: Int) {
    val actualVisualBottom = EditorHelper.getVisualLineAtBottomOfScreen(fixture.editor)
    val actualLogicalBottom = fixture.editor.vim.visualLineToBufferLine(actualVisualBottom)

    assertEquals(bottomLogicalLine, actualLogicalBottom, "Bottom logical lines don't match")
  }

  fun assertVisibleLineBounds(logicalLine: Int, leftLogicalColumn: Int, rightLogicalColumn: Int) {
    val visualLine = IjVimEditor(fixture.editor).bufferLineToVisualLine(logicalLine)
    val actualLeftVisualColumn = EditorHelper.getVisualColumnAtLeftOfDisplay(fixture.editor, visualLine)
    val actualLeftLogicalColumn =
      fixture.editor.visualToLogicalPosition(VisualPosition(visualLine, actualLeftVisualColumn)).column
    val actualRightVisualColumn = EditorHelper.getVisualColumnAtRightOfDisplay(fixture.editor, visualLine)
    val actualRightLogicalColumn =
      fixture.editor.visualToLogicalPosition(VisualPosition(visualLine, actualRightVisualColumn)).column

    val expected = ScreenBounds(leftLogicalColumn, rightLogicalColumn)
    val actual = ScreenBounds(actualLeftLogicalColumn, actualRightLogicalColumn)
    assertEquals(expected, actual)
  }

  fun assertLineCount(expected: Int) {
    assertEquals(expected, fixture.editor.vim.lineCount())
  }

  fun putMapping(modes: Set<MappingMode>, from: String, to: String, recursive: Boolean) {
    VimPlugin.getKey().putKeyMapping(
      modes,
      injector.parser.parseKeys(from),
      MappingOwner.IdeaVim.System,
      injector.parser.parseKeys(to),
      recursive,
    )
  }

  fun assertNoMapping(from: String) {
    val keys = injector.parser.parseKeys(from)
    for (mode in MappingMode.ALL) {
      assertNull(VimPlugin.getKey().getKeyMapping(mode)[keys])
    }
  }

  fun assertNoMapping(from: String, modes: Set<MappingMode>) {
    val keys = injector.parser.parseKeys(from)
    for (mode in modes) {
      assertNull(VimPlugin.getKey().getKeyMapping(mode)[keys])
    }
  }

  fun assertMappingExists(from: String, to: String, modes: Set<MappingMode>) {
    val keys = injector.parser.parseKeys(from)
    val toKeys = injector.parser.parseKeys(to)
    for (mode in modes) {
      val info = VimPlugin.getKey().getKeyMapping(mode)[keys]
      assertNotNull<Any>(info)
      if (info is ToKeysMappingInfo) {
        assertEquals(toKeys, info.toKeys)
      }
    }
  }

  private data class ScreenBounds(val leftLogicalColumn: Int, val rightLogicalColumn: Int) {
    override fun toString(): String {
      return "[$leftLogicalColumn-$rightLogicalColumn]"
    }
  }

  fun assertMode(expectedMode: VimStateMachine.Mode) {
    val mode = fixture.editor.editorMode
    assertEquals(expectedMode, mode)
  }

  fun assertSubMode(expectedSubMode: SubMode) {
    val subMode = fixture.editor.subMode
    assertEquals(expectedSubMode, subMode)
  }

  fun assertSelection(expected: String?) {
    val selected = fixture.editor.selectionModel.selectedText
    assertEquals(expected, selected)
  }

  fun assertExOutput(expected: String) {
    val actual = getInstance(fixture.editor).text
    assertNotNull("No Ex output", actual)
    assertEquals(expected, actual)
    NeovimTesting.typeCommand("<esc>", testInfo, fixture.editor)
  }

  fun assertNoExOutput() {
    val actual = getInstance(fixture.editor).text
    assertNull(actual, "Ex output not null")
  }

  fun assertPluginError(isError: Boolean) {
    assertEquals(isError, injector.messages.isError())
  }

  fun assertPluginErrorMessageContains(message: String) {
    Assertions.assertThat(VimPlugin.getMessage()).contains(message)
  }

  protected fun assertCaretsVisualAttributes() {
    if (!Checks.caretShape) return
    val editor = fixture.editor
    val attributes = GuiCursorOptionHelper.getAttributes(getGuiCursorMode(editor))
    val colour = editor.colorsScheme.getColor(EditorColors.CARET_COLOR)

    editor.caretModel.allCarets.forEach { caret ->
      // All carets should be the same except when in block sub mode, where we "hide" them (by drawing a zero width bar)
      if (caret !== editor.caretModel.primaryCaret && editor.inBlockSubMode) {
        assertEquals(CaretVisualAttributes.Shape.BAR, caret.visualAttributes.shape)
        assertEquals(0F, caret.visualAttributes.thickness)
      } else {
        val shape = when (attributes.type) {
          GuiCursorType.BLOCK -> CaretVisualAttributes.Shape.BLOCK
          GuiCursorType.VER -> CaretVisualAttributes.Shape.BAR
          GuiCursorType.HOR -> CaretVisualAttributes.Shape.UNDERSCORE
        }
        assertEquals(shape, editor.caretModel.primaryCaret.visualAttributes.shape)
        assertEquals(
          attributes.thickness / 100.0F,
          editor.caretModel.primaryCaret.visualAttributes.thickness,
        )
        editor.caretModel.primaryCaret.visualAttributes.color?.let {
          assertEquals(colour, it)
        }
      }
    }
  }

  @JvmOverloads
  fun doTest(
    keys: List<String>,
    before: String,
    after: String,
    modeAfter: VimStateMachine.Mode = VimStateMachine.Mode.COMMAND,
    subModeAfter: SubMode = SubMode.NONE,
    fileType: FileType? = null,
    fileName: String? = null,
    afterEditorInitialized: ((Editor) -> Unit)? = null,
  ) {
    doTest(
      keys.joinToString(separator = ""),
      before,
      after,
      modeAfter,
      subModeAfter,
      fileType,
      fileName,
      afterEditorInitialized,
    )
  }

  @JvmOverloads
  fun doTest(
    keys: String,
    before: String,
    after: String,
    modeAfter: VimStateMachine.Mode = VimStateMachine.Mode.COMMAND,
    subModeAfter: SubMode = SubMode.NONE,
    fileType: FileType? = null,
    fileName: String? = null,
    afterEditorInitialized: ((Editor) -> Unit)? = null,
  ) {
    if (fileName != null) {
      configureByText(fileName, before)
    } else if (fileType != null) {
      configureByText(fileType, before)
    } else {
      configureByText(before)
    }
    afterEditorInitialized?.invoke(fixture.editor)
    performTest(keys, after, modeAfter, subModeAfter)
  }

  protected fun performTest(keys: String, after: String, modeAfter: VimStateMachine.Mode, subModeAfter: SubMode) {
    typeText(keys)
    PlatformTestUtil.dispatchAllInvocationEventsInIdeEventQueue()
    assertState(after)
    assertState(modeAfter, subModeAfter)
  }

  protected fun setRegister(register: Char, keys: String) {
    VimPlugin.getRegister().setKeys(register, injector.parser.stringToKeys(keys))
    NeovimTesting.setRegister(register, keys, testInfo)
  }

  protected val fileManager: FileEditorManagerEx
    get() = FileEditorManagerEx.getInstanceEx(fixture.project)

  // Specify width in columns, not pixels, just like we do for visible screen size. The default text char width differs
  // per platform (e.g. Windows is 7, Mac is 8) so we can't guarantee correct positioning for tests if we use hard coded
  // pixel widths
  protected fun addInlay(
    offset: Int,
    relatesToPrecedingText: Boolean,
    @Suppress("SameParameterValue") widthInColumns: Int,
  ): Inlay<*> {
    val widthInPixels = (EditorHelper.getPlainSpaceWidthFloat(fixture.editor) * widthInColumns).roundToInt()
    return EditorTestUtil.addInlay(fixture.editor, offset, relatesToPrecedingText, widthInPixels)
  }

  // As for inline inlays, height is specified as a multiplier of line height, as we can't guarantee the same line
  // height on all platforms, so can't guarantee correct positioning for tests if we use pixels. This currently limits
  // us to integer multiples of line heights. I don't think this will cause any issues, but we can change this to a
  // float if necessary. We'd still be working scaled to the line height, so fractional values should still work.
  protected fun addBlockInlay(
    offset: Int,
    @Suppress("SameParameterValue") showAbove: Boolean,
    heightInRows: Int,
  ): Inlay<*> {
    val widthInColumns = 10 // Arbitrary width. We don't care.
    val widthInPixels = (EditorHelper.getPlainSpaceWidthFloat(fixture.editor) * widthInColumns).roundToInt()
    val heightInPixels = fixture.editor.lineHeight * heightInRows
    return EditorTestUtil.addBlockInlay(fixture.editor, offset, false, showAbove, widthInPixels, heightInPixels)
  }

  // Disable or enable checks for the particular test
  protected inline fun setupChecks(setup: Checks.() -> Unit) {
    Checks.setup()
  }

  protected fun assertExException(expectedErrorMessage: String, action: () -> Unit) {
    val exception = assertThrows<ExException> {
      action()
    }
    assertEquals(expectedErrorMessage, exception.message)
  }

  private fun typeTextViaIde(keys: List<KeyStroke?>, editor: Editor) {
    TestInputModel.getInstance(editor).setKeyStrokes(keys.filterNotNull())

    val inputModel = TestInputModel.getInstance(editor)
    var key = inputModel.nextKeyStroke()
    while (key != null) {
      val keyChar = key.getChar(editor)
      when (keyChar) {
        is CharType.CharDetected -> {
          fixture.type(keyChar.char)
          PlatformTestUtil.dispatchAllInvocationEventsInIdeEventQueue()
        }

        is CharType.EditorAction -> {
          fixture.performEditorAction(keyChar.name)
        }

        CharType.UNDEFINED -> {
          val event =
            KeyEvent(editor.component, KeyEvent.KEY_PRESSED, Date().time, key.modifiers, key.keyCode, key.keyChar)

          val e = AnActionEvent(
            event,
            injector.executionContextManager.onEditor(editor.vim).ij,
            ActionPlaces.KEYBOARD_SHORTCUT,
            VimShortcutKeyAction.instance.templatePresentation.clone(),
            ActionManager.getInstance(),
            0,
          )
          if (ActionUtil.lastUpdateAndCheckDumb(VimShortcutKeyAction.instance, e, true)) {
            ActionUtil.performActionDumbAwareWithCallbacks(VimShortcutKeyAction.instance, e)
          }
        }
      }
      key = inputModel.nextKeyStroke()
    }
  }

  private fun KeyStroke.getChar(editor: Editor): CharType {
    if (keyChar != KeyEvent.CHAR_UNDEFINED) return CharType.CharDetected(keyChar)
    if (isOctopusEnabled(this, editor)) {
      if (keyCode in setOf(KeyEvent.VK_ENTER)) return CharType.CharDetected(keyCode.toChar())
      if (keyCode == KeyEvent.VK_ESCAPE) return CharType.EditorAction("EditorEscape")
    }
    return CharType.UNDEFINED
  }

  private fun clearClipboard() {
    ClipboardSynchronizer.getInstance().resetContent()
    ClipboardSynchronizer.getInstance().setContent(EmptyTransferable, EmptyClipboardOwner.INSTANCE)
  }

  sealed interface CharType {
    object UNDEFINED : CharType
    class CharDetected(val char: Char) : CharType
    class EditorAction(val name: String) : CharType
  }

  companion object {
    const val c = EditorTestUtil.CARET_TAG
    const val s = EditorTestUtil.SELECTION_START_TAG
    const val se = EditorTestUtil.SELECTION_END_TAG

    fun typeText(keys: List<KeyStroke?>, editor: Editor, project: Project?) {
      val keyHandler = KeyHandler.getInstance()
      val dataContext = injector.executionContextManager.onEditor(editor.vim)
      TestInputModel.getInstance(editor).setKeyStrokes(keys.filterNotNull())
      runWriteCommand(
        project,
        Runnable {
          val inputModel = TestInputModel.getInstance(editor)
          var key = inputModel.nextKeyStroke()
          while (key != null) {
            keyHandler.handleKey(editor.vim, key, dataContext)
            key = inputModel.nextKeyStroke()
          }
        },
        null,
        null,
      )
    }

    @JvmStatic
    fun commandToKeys(command: String): List<KeyStroke> {
      val keys: MutableList<KeyStroke> = ArrayList()
      if (!command.startsWith(":")) {
        keys.addAll(injector.parser.parseKeys(":"))
      }
      keys.addAll(injector.parser.stringToKeys(command)) // Avoids trying to parse 'command ... <args>' as a special char
      keys.addAll(injector.parser.parseKeys("<Enter>"))
      return keys
    }

    fun exCommand(command: String) = ":$command<CR>"

    fun searchToKeys(pattern: String, forwards: Boolean): List<KeyStroke> {
      val keys: MutableList<KeyStroke> = ArrayList()
      keys.addAll(injector.parser.parseKeys(if (forwards) "/" else "?"))
      keys.addAll(injector.parser.stringToKeys(pattern)) // Avoids trying to parse 'command ... <args>' as a special char
      keys.addAll(injector.parser.parseKeys("<CR>"))
      return keys
    }

    fun searchCommand(pattern: String) = "$pattern<CR>"

    fun String.dotToTab(): String = replace('.', '\t')

    fun String.dotToSpace(): String = replace('.', ' ')
  }

  object Checks {
    var caretShape: Boolean = true

    val neoVim = NeoVim()

    var keyHandler = KeyHandlerMethod.VIA_IDE

    fun reset() {
      caretShape = true

      neoVim.reset()
      keyHandler = KeyHandlerMethod.VIA_IDE
    }

    class NeoVim {
      var ignoredRegisters: Set<Char> = setOf()
      var exitOnTearDown = true

      fun reset() {
        ignoredRegisters = setOf()
        exitOnTearDown = true
      }
    }

    enum class KeyHandlerMethod {
      VIA_IDE,
      DIRECT_TO_VIM,
    }
  }
}