// SPDX-FileCopyrightText: 2022 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0

package dan200.computercraft.gametest.api

import dan200.computercraft.api.peripheral.IPeripheral
import dan200.computercraft.gametest.core.ManagedComputers
import dan200.computercraft.mixin.gametest.GameTestInfoAccessor
import dan200.computercraft.mixin.gametest.GameTestSequenceAccessor
import dan200.computercraft.shared.platform.PlatformHelper
import dan200.computercraft.shared.util.RegistryHelper
import dan200.computercraft.test.core.computer.LuaTaskContext
import dan200.computercraft.test.shared.ItemStackMatcher.isStack
import net.minecraft.commands.arguments.blocks.BlockInput
import net.minecraft.core.BlockPos
import net.minecraft.core.Direction
import net.minecraft.core.Holder
import net.minecraft.core.NonNullList
import net.minecraft.core.registries.BuiltInRegistries
import net.minecraft.gametest.framework.GameTestAssertException
import net.minecraft.gametest.framework.GameTestHelper
import net.minecraft.gametest.framework.GameTestInfo
import net.minecraft.gametest.framework.GameTestSequence
import net.minecraft.network.chat.Component
import net.minecraft.resources.Identifier
import net.minecraft.server.level.ServerLevel
import net.minecraft.world.Container
import net.minecraft.world.InteractionHand
import net.minecraft.world.clock.WorldClock
import net.minecraft.world.entity.Entity
import net.minecraft.world.entity.EntityType
import net.minecraft.world.entity.EntityTypes
import net.minecraft.world.item.Item
import net.minecraft.world.item.ItemStack
import net.minecraft.world.item.ItemStackTemplate
import net.minecraft.world.item.context.UseOnContext
import net.minecraft.world.item.crafting.CraftingInput
import net.minecraft.world.item.crafting.CraftingRecipe
import net.minecraft.world.item.crafting.RecipeType
import net.minecraft.world.item.crafting.display.RecipeDisplay
import net.minecraft.world.item.crafting.display.ShapedCraftingRecipeDisplay
import net.minecraft.world.item.crafting.display.ShapelessCraftingRecipeDisplay
import net.minecraft.world.item.crafting.display.SlotDisplayContext
import net.minecraft.world.level.GameType
import net.minecraft.world.level.block.Blocks
import net.minecraft.world.level.block.entity.BarrelBlockEntity
import net.minecraft.world.level.block.entity.BlockEntity
import net.minecraft.world.level.block.entity.BlockEntityType
import net.minecraft.world.level.block.state.BlockState
import net.minecraft.world.level.block.state.properties.Property
import net.minecraft.world.phys.BlockHitResult
import net.minecraft.world.phys.Vec3
import org.hamcrest.Matchers
import org.hamcrest.StringDescription
import kotlin.jvm.optionals.getOrNull

/**
 * Globally usable structures.
 *
 * @see GameTest.template
 */
object Structures {
    /** The "default" structure, a 5x5 area with a polished Andesite floor */
    const val DEFAULT = "default"
}

/** Pre-set in-game times */
object Times {
    const val NOON: Int = 6000

    const val MIDNIGHT: Int = 18000
}

/**
 * Custom timeouts for various test types.
 *
 * @see GameTest.timeoutTicks
 */
object Timeouts {
    const val SECOND: Int = 20

    const val DEFAULT: Int = SECOND * 5

    const val COMPUTER_TIMEOUT: Int = SECOND * 15
}

/**
 * Equivalent to [GameTestSequence.thenExecute], but which won't run the next steps if the parent fails.
 */
fun GameTestSequence.thenExecuteFailFast(task: Runnable): GameTestSequence =
    thenExecute(task).thenWaitUntil {
        val failure = (this as GameTestSequenceAccessor).parent.error
        if (failure != null) throw failure
    }

fun GameTestInfo.getComputerLabel(name: String? = null): String = id().path + (if (name == null) "" else ".$name")

fun GameTestSequence.getComputerLabel(name: String? = null): String =
    (this as GameTestSequenceAccessor).parent.getComputerLabel(name)

/**
 * Wait until a computer has finished running and check it is OK.
 */
fun GameTestSequence.thenComputerOk(name: String? = null, marker: String = ComputerState.DONE): GameTestSequence {
    val label = getComputerLabel(name)

    thenWaitUntil {
        val computer = ComputerState.get(label)
        if (computer == null || !computer.isDone(marker)) abort("Computer '$label' has not reached $marker yet.")
    }
    thenExecuteFailFast {
        val error = ComputerState.get(label)!!.check(marker)
        if (error != null) abort(error)
    }
    return this
}

/**
 * Run a task on a computer but don't wait for it to finish.
 */
fun GameTestSequence.thenStartComputer(name: String? = null, action: suspend LuaTaskContext.() -> Unit): GameTestSequence {
    val test = (this as GameTestSequenceAccessor).parent
    val label = getComputerLabel(name)
    return thenExecuteFailFast { ManagedComputers.enqueue(test, label, action) }
}

/**
 * Run a task on a computer and wait for it to finish.
 */
fun GameTestSequence.thenOnComputer(name: String? = null, action: suspend LuaTaskContext.() -> Unit): GameTestSequence {
    val self = (this as GameTestSequenceAccessor)
    val test = self.parent

    val label = getComputerLabel(name)
    var monitor: ManagedComputers.Monitor? = null
    thenExecuteFailFast { monitor = ManagedComputers.enqueue(test, label, action) }
    thenWaitUntil {
        if (!monitor!!.isFinished) {
            val runningFor = (test as GameTestInfoAccessor).`computercraft$getTick`() - self.lastTick
            abort("Computer '$label' has not finished yet (running for $runningFor ticks).")
        }
    }
    thenExecuteFailFast { monitor!!.check() }
    return this
}

/**
 * Create a new game test sequence
 */
fun GameTestHelper.sequence(run: GameTestSequence.() -> Unit) {
    val sequence = startSequence()
    run(sequence)
    sequence.thenSucceed()
}

/**
 * Run a function immediately, and then succeed.
 */
fun GameTestHelper.immediate(run: () -> Unit) {
    run()
    succeed()
}

// Helper functions for failing tests

/** Raise a [GameTestAssertException]. */
fun GameTestHelper.abort(message: String): Nothing = throw assertionException(Component.literal(message))

/** Raise a [GameTestAssertException] at a position. */
fun GameTestHelper.abort(message: String, pos: BlockPos): Nothing =
    throw assertionException(pos, Component.literal(message))

/** Assert a condition is true, or raise a [GameTestAssertException] if not. */
fun GameTestHelper.assertTrue(condition: Boolean, message: String) = assertTrue(condition, Component.literal(message))

/** Raise a [GameTestAssertException]. */
fun GameTestSequence.abort(message: String): Nothing =
    throw GameTestAssertException(
        Component.literal(message),
        ((this as GameTestSequenceAccessor).parent as GameTestInfoAccessor).`computercraft$getTick`(),
    )

/** Fail with an optional context message. */
private fun GameTestHelper.abort(message: String?, detail: String, pos: BlockPos): Nothing {
    abort(if (message.isNullOrEmpty()) detail else "$message: $detail", pos)
}

/**
 * A version of [GameTestHelper.assertBlockState] which also includes the current block state.
 */
fun GameTestHelper.assertBlockIs(pos: BlockPos, predicate: (BlockState) -> Boolean) = assertBlockIs(pos, predicate, "")

/**
 * A version of [GameTestHelper.assertBlockState] which also includes the current block state.
 */
fun GameTestHelper.assertBlockIs(pos: BlockPos, predicate: (BlockState) -> Boolean, message: String) {
    val state = getBlockState(pos)
    if (!predicate(state)) abort(message, state.toString(), pos)
}

/**
 * A version of [GameTestHelper.assertBlockProperty] which includes the current block state in the error message.
 */
fun <T : Comparable<T>> GameTestHelper.assertBlockHas(pos: BlockPos, property: Property<T>, value: T, message: String = "") {
    val state = getBlockState(pos)
    if (!state.hasProperty(property)) {
        val id = RegistryHelper.getKeyOrThrow(BuiltInRegistries.BLOCK, state.block)
        abort(message, "block $id does not have property ${property.name}", pos)
    } else if (state.getValue(property) != value) {
        abort(message, "${property.name} is ${state.getValue(property)}, expected $value", pos)
    }
}

/**
 * Get a [Container] at a given position.
 */
fun GameTestHelper.getContainerAt(pos: BlockPos): Container =
    when (val container: BlockEntity? = level.getBlockEntity(absolutePos(pos))) {
        is Container -> container
        null -> abort("Expected a container at $pos, found nothing", pos)
        else -> abort("Expected a container at $pos, found ${getName(container.type)}", pos)
    }

/**
 * Assert a container is empty. Identical to [GameTestHelper.assertContainerEmpty], but works on any container BE, not
 * just [BaseContainerBlockEntity].
 *
 * @param pos The position of the container.
 */
fun GameTestHelper.assertContainerLikeEmpty(pos: BlockPos) {
    if (!getContainerAt(pos).isEmpty) throw assertionException(pos, "test.error.expected_empty_container")
}

/**
 * Assert a container contains exactly these items and no more.
 *
 * @param pos The position of the container.
 * @param items The list of items this container must contain. This should be equal to the expected contents of the
 * first `n` slots - the remaining are required to be empty.
 */
fun GameTestHelper.assertContainerExactly(pos: BlockPos, items: List<ItemStack>) =
    assertContainerExactlyImpl(pos, getContainerAt(pos), items)

/**
 * Assert a container contains exactly these items and no more.
 *
 * @param entity The entity containing these items.
 * @param items The list of items this container must contain. This should be equal to the expected contents of the
 * first `n` slots - the remaining are required to be empty.
 */
fun <T> GameTestHelper.assertContainerExactly(entity: T, items: List<ItemStack>) where T : Entity, T : Container =
    assertContainerExactlyImpl(entity.blockPosition(), entity, items)

fun ItemStack.toStringFull(): String = if (isEmpty) "<empty>" else "$count x $item$componentsPatch"

private fun formatItems(items: List<ItemStack>) = items.joinToString(", ") { it.toStringFull() }

private fun GameTestHelper.assertContainerExactlyImpl(pos: BlockPos, container: Container, items: List<ItemStack>) {
    val slot = (0 until container.containerSize).indexOfFirst { slot ->
        val expected = if (slot >= items.size) ItemStack.EMPTY else items[slot]
        !ItemStack.matches(container.getItem(slot), expected)
    }

    if (slot >= 0) {
        val invItems = (0 until container.containerSize).map { container.getItem(it) }.dropLastWhile { it.isEmpty }
        abort(
            """
            Items do not match (first mismatch at slot $slot).
            Expected:  ${formatItems(items)}
            Container: ${formatItems(invItems)}
            """.trimIndent(),
            pos,
        )
    }
}

/**
 * A nasty hack to get a peripheral at a given position, by creating a dummy [BlockEntity].
 */
private fun GameTestHelper.getPeripheralAt(pos: BlockPos, direction: Direction): IPeripheral? {
    val be = BarrelBlockEntity(absolutePos(pos).relative(direction), Blocks.BARREL.defaultBlockState())
    be.setLevel(level)
    return PlatformHelper.get().createPeripheralAccess(be) { }.get(direction.opposite)
}

fun GameTestHelper.assertPeripheral(pos: BlockPos, direction: Direction = Direction.UP, type: String) {
    val peripheral = getPeripheralAt(pos, direction)
    val block = getBlockState(pos).block.name.string
    when {
        peripheral == null -> abort("No peripheral for '$block'", pos)
        peripheral.type != type -> abort("Peripheral for '$block' is of type ${peripheral.type}, expected $type", pos)
    }
}

fun GameTestHelper.assertNoPeripheral(pos: BlockPos, direction: Direction = Direction.UP) {
    val peripheral = getPeripheralAt(pos, direction)
    val block = getBlockState(pos).block.name
    if (peripheral != null) abort("Expected no peripheral for '$block', got a ${peripheral.type}", pos)
}

fun GameTestHelper.assertExactlyItems(vararg expected: ItemStack, message: String? = null) {
    val actual = getEntities(EntityTypes.ITEM).map { it.item }
    val matcher = Matchers.containsInAnyOrder(expected.map { isStack(it) })
    if (!matcher.matches(actual)) {
        val description = StringDescription()
        matcher.describeMismatch(actual, description)
        abort(if (message.isNullOrEmpty()) description.toString() else "$message: $description")
    }
}

/**
 * Similar to [GameTestHelper.assertItemEntityCountIs], but searching anywhere in the structure bounds.
 */
fun GameTestHelper.assertItemEntityCountIs(expected: Item, count: Int) {
    val actualCount = getEntities(EntityTypes.ITEM).sumOf { if (it.item.`is`(expected)) it.item.count else 0 }
    if (actualCount != count) {
        abort("Expected $count ${ItemStack(expected).itemName.string} items to exist (found $actualCount)")
    }
}

private fun getName(type: BlockEntityType<*>): Identifier =
    RegistryHelper.getKeyOrThrow(BuiltInRegistries.BLOCK_ENTITY_TYPE, type)

/**
 * Get an [Entity] inside the game structure, requiring there to be a single one.
 */
fun <T : Entity> GameTestHelper.getEntity(type: EntityType<T>): T {
    val entities = getEntities(type)
    when (entities.size) {
        0 -> abort("No $type entities")
        1 -> return entities[0]
        else -> abort("Multiple $type entities (${entities.size} in bounding box)")
    }
}

/**
 * Set a block within the test structure.
 */
fun GameTestHelper.setBlock(pos: BlockPos, state: BlockInput) = state.place(level, absolutePos(pos), 3)

/**
 * Modify a block state within the test.
 */
fun GameTestHelper.modifyBlock(pos: BlockPos, modify: (BlockState) -> BlockState) {
    setBlock(pos, modify(getBlockState(pos)))
}

/**
 * Update items in the container at [pos], setting the item in the specified [slot] to [item], and then marking it
 * changed.
 */
fun GameTestHelper.setContainerItem(pos: BlockPos, slot: Int, item: ItemStack) {
    val container = getContainerAt(pos)
    container.setItem(slot, item)
    container.setChanged()
}

/**
 * An alternative version ot [GameTestHelper.placeAt], which sets the player's held item first.
 *
 * This is required for compatibility with Forge, which uses the in-hand stack, rather than the stack requested.
 */
fun GameTestHelper.placeItemAt(stack: ItemStack, pos: BlockPos, direction: Direction) {
    val player = makeMockPlayer(GameType.CREATIVE)
    player.setItemInHand(InteractionHand.MAIN_HAND, stack)
    val absolutePos = absolutePos(pos.relative(direction))
    val hit = BlockHitResult(Vec3.atCenterOf(absolutePos), direction, absolutePos, false)
    stack.useOn(UseOnContext(player, InteractionHand.MAIN_HAND, hit))
}

/**
 * Assert a [CraftingRecipe] is craftable.
 */
fun GameTestHelper.assertCraftable(recipe: CraftingRecipe) {
    val displays = recipe.display()
    if (displays.isEmpty()) {
        fail("$recipe has no display forms")
    }

    for (display in displays) assertCraftable(display)
}

/**
 * Assert a [RecipeDisplay] is craftable.
 */
fun GameTestHelper.assertCraftable(recipe: RecipeDisplay) {
    val context = SlotDisplayContext.fromLevel(level)
    when (recipe) {
        is ShapedCraftingRecipeDisplay -> {
            assertCraftable(
                recipe.ingredients.map { it.resolveForFirstStack(context) },
                recipe.result().resolveForFirstStack(context),
                width = recipe.width,
                height = recipe.height,
            )
        }

        is ShapelessCraftingRecipeDisplay -> {
            assertCraftable(
                recipe.ingredients.map { it.resolveForFirstStack(context) },
                recipe.result().resolveForFirstStack(context),
            )
        }

        else -> fail("Unsupported recipe $recipe")
    }
}

/**
 * Assert a series of items craft the given recipe.
 */
fun GameTestHelper.assertCraftable(items: List<ItemStack>, result: ItemStack, width: Int = 3, height: Int = 3) {
    val container = NonNullList.withSize(width * height, ItemStack.EMPTY)
    for ((i, item) in items.withIndex()) container[i] = item
    val input = CraftingInput.of(width, height, container)

    val recipe = level.server.recipeManager.getRecipeFor(RecipeType.CRAFTING, input, level)
    if (recipe.isEmpty) fail("Expected recipe to match ${formatItems(items)}")

    val actualResult = recipe.get().value.assemble(input)

    if (!ItemStack.isSameItemSameComponents(actualResult, result) || actualResult.count != result.count) {
        fail("Expected $items to craft ${result.toStringFull()}, got ${actualResult.toStringFull()}")
    }
}

/**
 * Assert a recipe is not craftable.
 */
fun GameTestHelper.assertNotCraftable(vararg items: ItemStack) {
    val container = NonNullList.withSize(3 * 3, ItemStack.EMPTY)
    for ((i, item) in items.withIndex()) container[i] = item
    val input = CraftingInput.of(3, 3, container)

    val recipe = level.server.recipeManager.getRecipeFor(RecipeType.CRAFTING, input, level)

    if (recipe.isPresent) abort("Expected no recipe to match ${items.contentToString()}")
}

/**
 * Attempt to craft an item.
 */
fun GameTestHelper.craftItem(vararg items: ItemStack): ItemStack {
    val container = NonNullList.withSize(3 * 3, ItemStack.EMPTY)
    for ((i, item) in items.withIndex()) container[i] = item
    val input = CraftingInput.of(3, 3, container)

    val recipe = level.server.recipeManager.getRecipeFor(RecipeType.CRAFTING, input, level).getOrNull()
        ?: throw assertionException("No recipe matches ${items.contentToString()}")
    return recipe.value.assemble(input)
}

/**
 * Run a function multiple times until it succeeds.
 */
inline fun tryMultipleTimes(count: Int, action: () -> Unit) {
    for (remaining in count - 1 downTo 0) {
        try {
            action()
        } catch (e: AssertionError) {
            if (remaining == 0) throw e
        }
    }
}

/** Check an [ItemStack] matches an [ItemStackTemplate]. */
fun ItemStack.matches(instance: ItemStackTemplate) =
    `is`(instance.item) && componentsPatch == instance.components

/** Get the default clock for a level */
val ServerLevel.defaultClock: Holder<WorldClock>
    get() = level.dimensionTypeRegistration().value().defaultClock.orElseThrow()
