package li.cil.oc.server.component

import java.util.UUID

import li.cil.oc.Settings
import li.cil.oc.api.machine._
import li.cil.oc.api.network.EnvironmentHost
import li.cil.oc.api.prefab.AbstractValue
import li.cil.oc.common.EventHandler
import li.cil.oc.util.InventoryUtils
import net.minecraft.entity.Entity
import net.minecraft.entity.IMerchant
import net.minecraft.item.ItemStack
import net.minecraft.nbt.NBTTagCompound
import net.minecraft.tileentity.TileEntity
import net.minecraft.village.MerchantRecipe
import net.minecraftforge.common.DimensionManager
import net.minecraftforge.common.util.ForgeDirection

import scala.collection.convert.WrapAsScala._
import scala.ref.WeakReference

class Trade(val info: TradeInfo) extends AbstractValue {
  def this() = this(new TradeInfo())

  def this(upgrade: UpgradeTrading, merchant: IMerchant, recipeID: Int) =
    this(new TradeInfo(upgrade.host, merchant, recipeID))

  def maxRange = Settings.get.tradingRange

  def isInRange = (info.merchant.get, info.host) match {
    case (Some(merchant: Entity), Some(host)) => merchant.getDistanceSq(host.xPosition, host.yPosition, host.zPosition) < maxRange * maxRange
    case _ => false
  }

  // Queue the load because when load is called we can't access the world yet
  // and we need to access it to get the Robot's TileEntity / Drone's Entity.
  override def load(nbt: NBTTagCompound) = EventHandler.scheduleServer(() => info.load(nbt))

  override def save(nbt: NBTTagCompound) = info.save(nbt)

  @Callback(doc = "function():table, table -- Returns the items the merchant wants for this trade.")
  def getInput(context: Context, arguments: Arguments): Array[AnyRef] =
    result(info.recipe.map(_.getItemToBuy.copy()).orNull,
      if (info.recipe.exists(_.hasSecondItemToBuy)) info.recipe.map(_.getSecondItemToBuy.copy()).orNull else null)

  @Callback(doc = "function():table -- Returns the item the merchant offers for this trade.")
  def getOutput(context: Context, arguments: Arguments): Array[AnyRef] =
    result(info.recipe.map(_.getItemToSell.copy()).orNull)

  @Callback(doc = "function():boolean -- Returns whether the merchant currently wants to trade this.")
  def isEnabled(context: Context, arguments: Arguments): Array[AnyRef] =
    result(info.merchant.get.exists(merchant => !info.recipe.exists(_.isRecipeDisabled))) // Make sure merchant is neither dead/gone nor the recipe has been disabled.

  @Callback(doc = "function():boolean, string -- Returns true when trade succeeds and nil, error when not.")
  def trade(context: Context, arguments: Arguments): Array[AnyRef] = {
    // Make sure we can access an inventory.
    info.inventory match {
      case Some(inventory) =>
        // Make sure merchant hasn't died, it somehow gone or moved out of range and still wants to trade this.
        info.merchant.get match {
          case Some(merchant: Entity) if merchant.isEntityAlive && isInRange =>
            if (!merchant.isEntityAlive) {
              result(false, "trader died")
            } else if (!isInRange) {
              result(false, "out of range")
            } else {
              info.recipe match {
                case Some(recipe) =>
                  if (recipe.isRecipeDisabled) {
                    result(false, "trade is disabled")
                  } else {
                    // Now we'll check if we have enough items to perform the trade, caching first
                    val firstInputStack = recipe.getItemToBuy
                    val secondInputStack = if (recipe.hasSecondItemToBuy) Option(recipe.getSecondItemToBuy) else None

                    def containsAccumulativeItemStack(stack: ItemStack) =
                      InventoryUtils.extractFromInventory(stack, inventory, ForgeDirection.UNKNOWN, simulate = true).stackSize == 0
                    def hasRoomForItemStack(stack: ItemStack) = {
                      val remainder = stack.copy()
                      InventoryUtils.insertIntoInventory(remainder, inventory, None, remainder.stackSize, simulate = true)
                      remainder.stackSize == 0
                    }

                    // Check if we have enough to perform the trade.
                    if (containsAccumulativeItemStack(firstInputStack) && secondInputStack.forall(containsAccumulativeItemStack)) {
                      // Now we need to check if we have enough inventory space to accept the item we get for the trade.
                      val outputStack = recipe.getItemToSell.copy()
                      if (hasRoomForItemStack(outputStack)) {
                        // We established that out inventory allows to perform the trade, now actually do the trade.
                        InventoryUtils.extractFromInventory(firstInputStack, inventory, ForgeDirection.UNKNOWN)
                        secondInputStack.map(InventoryUtils.extractFromInventory(_, inventory, ForgeDirection.UNKNOWN))
                        InventoryUtils.insertIntoInventory(outputStack, inventory, None, outputStack.stackSize)

                        // Tell the merchant we used the recipe, so MC can disable it and/or enable more recipes.
                        info.merchant.get.orNull.useRecipe(recipe)

                        result(true)
                      } else {
                        result(false, "not enough inventory space to trade")
                      }
                    } else {
                      result(false, "not enough items to trade")
                    }
                  }
                case _ => result(false, "trade has become invalid")
              }
            }
          case _ => result(false, "trade has become invalid")
        }
      case _ => result(false, "trading requires an inventory upgrade to be installed")
    }
  }
}

class TradeInfo(var host: Option[EnvironmentHost], var merchant: WeakReference[IMerchant], var recipeID: Int) {
  def this() = this(None, new WeakReference[IMerchant](null), -1)

  def this(host: EnvironmentHost, merchant: IMerchant, recipeID: Int) =
    this(Option(host), new WeakReference[IMerchant](merchant), recipeID)

  def recipe = merchant.get.map(_.getRecipes(null).get(recipeID).asInstanceOf[MerchantRecipe])

  def inventory = host match {
    case Some(agent: li.cil.oc.api.internal.Agent) => Option(agent.mainInventory())
    case _ => None
  }

  def load(nbt: NBTTagCompound): Unit = {
    val isEntity = nbt.getBoolean("hostIsEntity")
    // If drone we find it again by its UUID, if Robot we know the X/Y/Z of the TileEntity.
    host = if (isEntity) loadHostEntity(nbt) else loadHostTileEntity(nbt)
    merchant = new WeakReference[IMerchant](loadEntity(nbt, new UUID(nbt.getLong("merchantUUIDMost"), nbt.getLong("merchantUUIDLeast"))) match {
      case Some(merchant: IMerchant) => merchant
      case _ => null
    })
    recipeID = nbt.getInteger("recipeID")
  }

  def save(nbt: NBTTagCompound): Unit = {
    host match {
      case Some(entity: Entity) =>
        nbt.setBoolean("hostIsEntity", true)
        nbt.setInteger("dimensionID", entity.world.provider.dimensionId)
        nbt.setLong("hostUUIDLeast", entity.getPersistentID.getLeastSignificantBits)
        nbt.setLong("hostUUIDMost", entity.getPersistentID.getMostSignificantBits)
      case Some(tileEntity: TileEntity) =>
        nbt.setBoolean("hostIsEntity", false)
        nbt.setInteger("dimensionID", tileEntity.getWorldObj.provider.dimensionId)
        nbt.setInteger("hostX", tileEntity.xCoord)
        nbt.setInteger("hostY", tileEntity.yCoord)
        nbt.setInteger("hostZ", tileEntity.zCoord)
      case _ => // Welp!
    }
    merchant.get match {
      case Some(entity: Entity) =>
        nbt.setLong("merchantUUIDLeast", entity.getPersistentID.getLeastSignificantBits)
        nbt.setLong("merchantUUIDMost", entity.getPersistentID.getMostSignificantBits)
      case _ =>
    }
    nbt.setInteger("recipeID", recipeID)
  }

  private def loadEntity(nbt: NBTTagCompound, uuid: UUID): Option[Entity] = {
    val dimension = nbt.getInteger("dimensionID")
    val world = DimensionManager.getProvider(dimension).worldObj

    world.loadedEntityList.find {
      case entity: Entity if entity.getPersistentID == uuid => true
      case _ => false
    }.map(_.asInstanceOf[Entity])
  }

  private def loadHostEntity(nbt: NBTTagCompound): Option[EnvironmentHost] = {
    loadEntity(nbt, new UUID(nbt.getLong("hostUUIDMost"), nbt.getLong("hostUUIDLeast"))) match {
      case Some(entity: Entity with li.cil.oc.api.internal.Agent) => Option(entity: EnvironmentHost)
      case _ => None
    }
  }

  private def loadHostTileEntity(nbt: NBTTagCompound): Option[EnvironmentHost] = {
    val dimension = nbt.getInteger("dimensionID")
    val world = DimensionManager.getProvider(dimension).worldObj

    val x = nbt.getInteger("hostX")
    val y = nbt.getInteger("hostY")
    val z = nbt.getInteger("hostZ")

    world.getTileEntity(x, y, z) match {
      case robotProxy: li.cil.oc.common.tileentity.RobotProxy => Option(robotProxy.robot)
      case agent: li.cil.oc.api.internal.Agent => Option(agent)
      case null => None
    }
  }
}
