package li.cil.oc.util

import li.cil.oc.util.ExtendedWorld._
import net.minecraft.block.BlockChest
import net.minecraft.entity.item.EntityItem
import net.minecraft.entity.item.EntityMinecartContainer
import net.minecraft.entity.player.EntityPlayer
import net.minecraft.inventory.IInventory
import net.minecraft.inventory.ISidedInventory
import net.minecraft.item.ItemStack
import net.minecraft.tileentity.TileEntityChest
import net.minecraftforge.common.util.ForgeDirection

import scala.collection.convert.WrapAsScala._

object InventoryUtils {
  /**
   * Check if two item stacks are of equal type, ignoring the stack size.
   * <p/>
   * Optionally check for equality in NBT data.
   */
  def haveSameItemType(stackA: ItemStack, stackB: ItemStack, checkNBT: Boolean = false) =
    stackA != null && stackB != null &&
      stackA.getItem == stackB.getItem &&
      (!stackA.getHasSubtypes || stackA.getItemDamage == stackB.getItemDamage) &&
      (!checkNBT || ItemStack.areItemStackTagsEqual(stackA, stackB))

  /**
   * Retrieves an actual inventory implementation for a specified world coordinate.
   * <p/>
   * This performs special handling for (double-)chests and also checks for
   * mine carts with chests.
   */
  def inventoryAt(position: BlockPosition): Option[IInventory] = position.world match {
    case Some(world) if world.blockExists(position) => (world.getBlock(position), world.getTileEntity(position)) match {
      case (block: BlockChest, chest: TileEntityChest) => Option(block.func_149951_m(world, chest.xCoord, chest.yCoord, chest.zCoord))
      case (_, inventory: IInventory) => Some(inventory)
      case _ => world.getEntitiesWithinAABB(classOf[EntityMinecartContainer], position.bounds).
        map(_.asInstanceOf[EntityMinecartContainer]).
        find(!_.isDead)
    }
    case _ => None
  }

  /**
   * Inserts a stack into an inventory.
   * <p/>
   * Only tries to insert into the specified slot. This <em>cannot</em> be
   * used to empty a slot. It can only insert stacks into empty slots and
   * merge additional items into an existing stack in the slot.
   * <p/>
   * The passed stack's size will be adjusted to reflect the number of items
   * inserted into the inventory, i.e. if 10 more items could fit into the
   * slot, the stack's size will be 10 smaller than before the call.
   * <p/>
   * This will return <tt>true</tt> if <em>at least</em> one item could be
   * inserted into the slot. It will return <tt>false</tt> if the passed
   * stack did not change.
   * <p/>
   * This takes care of handling special cases such as sided inventories,
   * maximum inventory and item stack sizes.
   * <p/>
   * The number of items inserted can be limited, to avoid unnecessary
   * changes to the inventory the stack may come from, for example.
   */
  def insertIntoInventorySlot(stack: ItemStack, inventory: IInventory, side: Option[ForgeDirection], slot: Int, limit: Int = 64, simulate: Boolean = false) =
    (stack != null && limit > 0) && {
      val isSideValidForSlot = (inventory, side) match {
        case (inventory: ISidedInventory, Some(s)) => inventory.canInsertItem(slot, stack, s.ordinal)
        case _ => true
      }
      (stack.stackSize > 0 && inventory.isItemValidForSlot(slot, stack) && isSideValidForSlot) && {
        val maxStackSize = math.min(inventory.getInventoryStackLimit, stack.getMaxStackSize)
        val existing = inventory.getStackInSlot(slot)
        val shouldMerge = existing != null && existing.stackSize < maxStackSize &&
          existing.isItemEqual(stack) && ItemStack.areItemStackTagsEqual(existing, stack)
        if (shouldMerge) {
          val space = maxStackSize - existing.stackSize
          val amount = math.min(space, math.min(stack.stackSize, limit))
          stack.stackSize -= amount
          if (simulate) amount > 0
          else {
            existing.stackSize += amount
            inventory.markDirty()
            true
          }
        }
        else (existing == null) && {
          val amount = math.min(maxStackSize, math.min(stack.stackSize, limit))
          val inserted = stack.splitStack(amount)
          if (simulate) amount > 0
          else {
            inventory.setInventorySlotContents(slot, inserted)
            true
          }
        }
      }
    }

  /**
   * Extracts a stack from an inventory.
   * <p/>
   * Only tries to extract from the specified slot. This <em>can</em> be used
   * to empty a slot. It will extract items using the specified consumer method
   * which is called with the extracted stack before the stack in the inventory
   * that we extract from is cleared from. This allows placing back excess
   * items with as few inventory updates as possible.
   * <p/>
   * The consumer is the only way to retrieve the actually extracted stack. It
   * is called with a separate stack instance, so it does not have to be copied
   * again.
   * <p/>
   * This will return <tt>true</tt> if <em>at least</em> one item could be
   * extracted from the slot. It will return <tt>false</tt> if the stack in
   * the slot did not change.
   * <p/>
   * This takes care of handling special cases such as sided inventories and
   * maximum stack sizes.
   * <p/>
   * The number of items extracted can be limited, to avoid unnecessary
   * changes to the inventory the stack is extracted from. Note that this could
   * also be achieved by a check in the consumer, but it saves some unnecessary
   * code repetition this way.
   */
  def extractFromInventorySlot(consumer: (ItemStack) => Unit, inventory: IInventory, side: ForgeDirection, slot: Int, limit: Int = 64) = {
    val stack = inventory.getStackInSlot(slot)
    (stack != null && limit > 0) && {
      val isSideValidForSlot = inventory match {
        case inventory: ISidedInventory => inventory.canExtractItem(slot, stack, side.ordinal)
        case _ => true
      }
      (stack.stackSize > 0 && isSideValidForSlot) && {
        val maxStackSize = math.min(inventory.getInventoryStackLimit, stack.getMaxStackSize)
        val amount = math.min(maxStackSize, math.min(stack.stackSize, limit))
        val extracted = stack.splitStack(amount)
        consumer(extracted)
        val success = extracted.stackSize < amount
        stack.stackSize += extracted.stackSize
        if (stack.stackSize == 0) {
          inventory.setInventorySlotContents(slot, null)
        }
        else if (success) {
          inventory.markDirty()
        }
        success
      }
    }
  }

  /**
   * Inserts a stack into an inventory.
   * <p/>
   * This will try to fit the stack in any and as many as necessary slots in
   * the inventory. It will first try to merge the stack in stacks already
   * present in the inventory. After that it will try to fit the stack into
   * empty slots in the inventory.
   * <p/>
   * This uses the <tt>insertIntoInventorySlot</tt> method, and therefore
   * handles special cases such as sided inventories and stack size limits.
   * <p/>
   * This returns <tt>true</tt> if at least one item was inserted. The passed
   * item stack will be adjusted to reflect the number items inserted, by
   * having its size decremented accordingly.
   */
  def insertIntoInventory(stack: ItemStack, inventory: IInventory, side: Option[ForgeDirection] = None, limit: Int = 64, simulate: Boolean = false, slots: Option[Iterable[Int]] = None) =
    (stack != null && limit > 0) && {
      var success = false
      var remaining = limit
      val range = slots.getOrElse(inventory match {
        case sided: ISidedInventory => sided.getAccessibleSlotsFromSide(side.getOrElse(ForgeDirection.UNKNOWN).ordinal).toIterable
        case _ => 0 until inventory.getSizeInventory
      })

      if (range.nonEmpty) {
        // This is a special case for inserting with an explicit ordering,
        // such as when inserting into robots, where the range starts at the
        // selected slot. In that case we want to prefer inserting into that
        // slot, if at all possible, over merging.
        if (slots.isDefined) {
          val stackSize = stack.stackSize
          if ((inventory.getStackInSlot(range.head) == null) && insertIntoInventorySlot(stack, inventory, side, range.head, remaining, simulate)) {
            remaining -= stackSize - stack.stackSize
            success = true
          }
        }

        val shouldTryMerge = !stack.isItemStackDamageable && stack.getMaxStackSize > 1 && inventory.getInventoryStackLimit > 1
        if (shouldTryMerge) {
          for (slot <- range) {
            val stackSize = stack.stackSize
            if ((inventory.getStackInSlot(slot) != null) && insertIntoInventorySlot(stack, inventory, side, slot, remaining, simulate)) {
              remaining -= stackSize - stack.stackSize
              success = true
            }
          }
        }

        for (slot <- range) {
          val stackSize = stack.stackSize
          if ((inventory.getStackInSlot(slot) == null) && insertIntoInventorySlot(stack, inventory, side, slot, remaining, simulate)) {
            remaining -= stackSize - stack.stackSize
            success = true
          }
        }
      }

      success
    }

  /**
   * Extracts a slot from an inventory.
   * <p/>
   * This will try to extract a stack from any inventory slot. It will iterate
   * all slots until an item can be extracted from a slot.
   * <p/>
   * This uses the <tt>extractFromInventorySlot</tt> method, and therefore
   * handles special cases such as sided inventories and stack size limits.
   * <p/>
   * This returns <tt>true</tt> if at least one item was extracted.
   */
  def extractAnyFromInventory(consumer: (ItemStack) => Unit, inventory: IInventory, side: ForgeDirection, limit: Int = 64) = {
    val range = inventory match {
      case sided: ISidedInventory => sided.getAccessibleSlotsFromSide(side.ordinal).toIterable
      case _ => 0 until inventory.getSizeInventory
    }
    range.exists(slot => extractFromInventorySlot(consumer, inventory, side, slot, limit))
  }

  /**
    * Extracts an item stack from an inventory.
    * <p/>
    * This will try to remove items of the same type as the specified item stack
    * up to the number of the stack's size for all slots in the specified inventory.
    * <p/>
    * This uses the <tt>extractFromInventorySlot</tt> method, and therefore
    * handles special cases such as sided inventories and stack size limits.
    */
  def extractFromInventory(stack: ItemStack, inventory: IInventory, side: ForgeDirection, simulate: Boolean = false) = {
    val range = inventory match {
      case sided: ISidedInventory => sided.getAccessibleSlotsFromSide(side.ordinal).toIterable
      case _ => 0 until inventory.getSizeInventory
    }
    val remaining = stack.copy()
    for (slot <- range if remaining.stackSize > 0) {
      extractFromInventorySlot(stack => {
        if (haveSameItemType(remaining, stack, checkNBT = true)) {
          val transferred = stack.stackSize min remaining.stackSize
          remaining.stackSize -= transferred
          if (!simulate) {
            stack.stackSize -= transferred
          }
        }
      }, inventory, side, slot, remaining.stackSize)
    }
    remaining
  }

  /**
   * Utility method for calling <tt>insertIntoInventory</tt> on an inventory
   * in the world.
   */
  def insertIntoInventoryAt(stack: ItemStack, position: BlockPosition, side: Option[ForgeDirection] = None, limit: Int = 64, simulate: Boolean = false): Boolean =
    inventoryAt(position).exists(insertIntoInventory(stack, _, side, limit, simulate))

  /**
   * Utility method for calling <tt>extractFromInventory</tt> on an inventory
   * in the world.
   */
  def extractFromInventoryAt(consumer: (ItemStack) => Unit, position: BlockPosition, side: ForgeDirection, limit: Int = 64) =
    inventoryAt(position).exists(extractAnyFromInventory(consumer, _, side, limit))

  /**
   * Transfers some items between two inventories.
   * <p/>
   * This will try to extract up the specified number of items from any inventory,
   * then insert it into the specified sink inventory. If the insertion fails, the
   * items will remain in the source inventory.
   * <p/>
   * This uses the <tt>extractFromInventory</tt> and <tt>insertIntoInventory</tt>
   * methods, and therefore handles special cases such as sided inventories and
   * stack size limits.
   * <p/>
   * This returns <tt>true</tt> if at least one item was transferred.
   */
  def transferBetweenInventories(source: IInventory, sourceSide: ForgeDirection, sink: IInventory, sinkSide: Option[ForgeDirection], limit: Int = 64) =
    extractAnyFromInventory(
      insertIntoInventory(_, sink, sinkSide, limit), source, sourceSide, limit)

  /**
   * Like <tt>transferBetweenInventories</tt> but moving between specific slots.
   */
  def transferBetweenInventoriesSlots(source: IInventory, sourceSide: ForgeDirection, sourceSlot: Int, sink: IInventory, sinkSide: Option[ForgeDirection], sinkSlot: Option[Int], limit: Int = 64) =
    sinkSlot match {
      case Some(explicitSinkSlot) =>
        extractFromInventorySlot(
          insertIntoInventorySlot(_, sink, sinkSide, explicitSinkSlot, limit), source, sourceSide, sourceSlot, limit)
      case _ =>
        extractFromInventorySlot(
          insertIntoInventory(_, sink, sinkSide, limit), source, sourceSide, sourceSlot, limit)
    }

  /**
   * Utility method for calling <tt>transferBetweenInventories</tt> on inventories
   * in the world.
   */
  def transferBetweenInventoriesAt(source: BlockPosition, sourceSide: ForgeDirection, sink: BlockPosition, sinkSide: Option[ForgeDirection], limit: Int = 64) =
    inventoryAt(source).exists(sourceInventory =>
      inventoryAt(sink).exists(sinkInventory =>
        transferBetweenInventories(sourceInventory, sourceSide, sinkInventory, sinkSide, limit)))

  /**
   * Utility method for calling <tt>transferBetweenInventoriesSlots</tt> on inventories
   * in the world.
   */
  def transferBetweenInventoriesSlotsAt(sourcePos: BlockPosition, sourceSide: ForgeDirection, sourceSlot: Int, sinkPos: BlockPosition, sinkSide: Option[ForgeDirection], sinkSlot: Option[Int], limit: Int = 64) =
    inventoryAt(sourcePos).exists(sourceInventory =>
      inventoryAt(sinkPos).exists(sinkInventory =>
        transferBetweenInventoriesSlots(sourceInventory, sourceSide, sourceSlot, sinkInventory, sinkSide, sinkSlot, limit)))

  /**
   * Utility method for dropping contents from a single inventory slot into
   * the world.
   */
  def dropSlot(position: BlockPosition, inventory: IInventory, slot: Int, count: Int, direction: Option[ForgeDirection] = None) = {
    Option(inventory.decrStackSize(slot, count)) match {
      case Some(stack) if stack.stackSize > 0 => spawnStackInWorld(position, stack, direction); true
      case _ => false
    }
  }

  /**
   * Utility method for dumping all inventory contents into the world.
   */
  def dropAllSlots(position: BlockPosition, inventory: IInventory): Unit = {
    for (slot <- 0 until inventory.getSizeInventory) {
      Option(inventory.getStackInSlot(slot)) match {
        case Some(stack) if stack.stackSize > 0 =>
          inventory.setInventorySlotContents(slot, null)
          spawnStackInWorld(position, stack)
        case _ => // Nothing.
      }
    }
  }

  /**
   * Try inserting an item stack into a player inventory. If that fails, drop it into the world.
   */
  def addToPlayerInventory(stack: ItemStack, player: EntityPlayer): Unit = {
    if (stack != null) {
      if (player.inventory.addItemStackToInventory(stack)) {
        player.inventory.markDirty()
        if (player.openContainer != null) {
          player.openContainer.detectAndSendChanges()
        }
      }
      if (stack.stackSize > 0) {
        player.dropPlayerItemWithRandomChoice(stack, false)
      }
    }
  }

  /**
   * Utility method for spawning an item stack in the world.
   */
  def spawnStackInWorld(position: BlockPosition, stack: ItemStack, direction: Option[ForgeDirection] = None, validator: Option[EntityItem => Boolean] = None): EntityItem = position.world match {
    case Some(world) if stack != null && stack.stackSize > 0 =>
      val rng = world.rand
      val (ox, oy, oz) = direction.fold((0, 0, 0))(d => (d.offsetX, d.offsetY, d.offsetZ))
      val (tx, ty, tz) = (
        0.1 * (rng.nextDouble - 0.5) + ox * 0.65,
        0.1 * (rng.nextDouble - 0.5) + oy * 0.75 + (ox + oz) * 0.25,
        0.1 * (rng.nextDouble - 0.5) + oz * 0.65)
      val dropPos = position.offset(0.5 + tx, 0.5 + ty, 0.5 + tz)
      val entity = new EntityItem(world, dropPos.xCoord, dropPos.yCoord, dropPos.zCoord, stack.copy())
      entity.motionX = 0.0125 * (rng.nextDouble - 0.5) + ox * 0.03
      entity.motionY = 0.0125 * (rng.nextDouble - 0.5) + oy * 0.08 + (ox + oz) * 0.03
      entity.motionZ = 0.0125 * (rng.nextDouble - 0.5) + oz * 0.03
      entity.delayBeforeCanPickup = 15
      if (validator.fold(true)(_(entity))) {
        world.spawnEntityInWorld(entity)
        entity
      }
      else null
    case _ => null
  }
}
