package ru.dbotthepony.mc.otm.capability

import it.unimi.dsi.fastutil.ints.IntAVLTreeSet
import it.unimi.dsi.fastutil.objects.Reference2ObjectOpenHashMap
import net.minecraft.ChatFormatting
import net.minecraft.commands.Commands
import net.minecraft.commands.arguments.EntityArgument
import net.minecraft.core.Direction
import net.minecraft.nbt.ByteTag
import net.minecraft.nbt.CompoundTag
import net.minecraft.nbt.IntTag
import net.minecraft.nbt.ListTag
import net.minecraft.nbt.StringTag
import net.minecraft.network.chat.Component
import net.minecraft.resources.ResourceLocation
import net.minecraft.server.level.ServerLevel
import net.minecraft.server.level.ServerPlayer
import net.minecraft.tags.TagKey
import net.minecraft.world.Container
import net.minecraft.world.Difficulty
import net.minecraft.world.damagesource.DamageSource
import net.minecraft.world.effect.MobEffect
import net.minecraft.world.effect.MobEffectInstance
import net.minecraft.world.effect.MobEffects
import net.minecraft.world.entity.Entity
import net.minecraft.world.entity.LivingEntity
import net.minecraft.world.entity.boss.wither.WitherBoss
import net.minecraft.world.entity.player.Inventory
import net.minecraft.world.entity.player.Player
import net.minecraft.world.item.ItemStack
import net.minecraft.world.item.Items
import net.minecraft.world.item.ProjectileWeaponItem
import net.minecraft.world.item.crafting.RecipeManager
import net.minecraft.world.item.crafting.RecipeType
import net.minecraft.world.level.GameRules
import net.minecraft.world.level.Level
import net.minecraft.world.phys.Vec3
import net.minecraftforge.common.ForgeHooks
import net.minecraftforge.common.MinecraftForge
import net.minecraftforge.common.capabilities.Capability
import net.minecraftforge.common.capabilities.ICapabilityProvider
import net.minecraftforge.common.util.INBTSerializable
import net.minecraftforge.common.util.LazyOptional
import net.minecraftforge.event.AttachCapabilitiesEvent
import net.minecraftforge.event.RegisterCommandsEvent
import net.minecraftforge.event.TickEvent
import net.minecraftforge.event.TickEvent.PlayerTickEvent
import net.minecraftforge.event.entity.living.LivingAttackEvent
import net.minecraftforge.event.entity.living.LivingDeathEvent
import net.minecraftforge.event.entity.living.LivingHurtEvent
import net.minecraftforge.event.entity.living.MobEffectEvent
import net.minecraftforge.event.entity.player.PlayerEvent
import net.minecraftforge.eventbus.api.Cancelable
import net.minecraftforge.eventbus.api.Event
import net.minecraftforge.registries.ForgeRegistries
import net.minecraftforge.server.command.EnumArgument
import org.apache.logging.log4j.LogManager
import ru.dbotthepony.mc.otm.*
import ru.dbotthepony.mc.otm.android.AndroidFeature
import ru.dbotthepony.mc.otm.android.AndroidFeatureType
import ru.dbotthepony.mc.otm.android.AndroidResearch
import ru.dbotthepony.mc.otm.android.AndroidResearchManager
import ru.dbotthepony.mc.otm.android.AndroidResearchType
import ru.dbotthepony.mc.otm.block.entity.JobContainer
import ru.dbotthepony.mc.otm.block.entity.JobStatus
import ru.dbotthepony.mc.otm.block.entity.MachineItemJob
import ru.dbotthepony.mc.otm.block.entity.MachineJobEventLoop
import ru.dbotthepony.mc.otm.capability.energy.BatteryBackedEnergyStorage
import ru.dbotthepony.mc.otm.capability.energy.IMatteryEnergyStorage
import ru.dbotthepony.mc.otm.capability.energy.ProfiledEnergyStorage
import ru.dbotthepony.mc.otm.capability.energy.extractEnergyExact
import ru.dbotthepony.mc.otm.capability.energy.receiveEnergyExact
import ru.dbotthepony.mc.otm.client.minecraft
import ru.dbotthepony.mc.otm.config.AndroidConfig
import ru.dbotthepony.mc.otm.config.ExopackConfig
import ru.dbotthepony.mc.otm.container.MatteryContainer
import ru.dbotthepony.mc.otm.container.get
import ru.dbotthepony.mc.otm.container.iterator
import ru.dbotthepony.mc.otm.container.stream
import ru.dbotthepony.mc.otm.container.vanishCursedItems
import ru.dbotthepony.mc.otm.core.*
import ru.dbotthepony.mc.otm.core.collect.UUIDIntModifiersMap
import ru.dbotthepony.mc.otm.core.collect.filter
import ru.dbotthepony.mc.otm.core.math.Decimal
import ru.dbotthepony.mc.otm.core.math.RGBAColor
import ru.dbotthepony.mc.otm.core.math.minus
import ru.dbotthepony.mc.otm.core.nbt.getByteList
import ru.dbotthepony.mc.otm.core.nbt.getCompoundList
import ru.dbotthepony.mc.otm.core.nbt.getIntList
import ru.dbotthepony.mc.otm.core.nbt.getStringList
import ru.dbotthepony.mc.otm.core.nbt.set
import ru.dbotthepony.mc.otm.core.util.IntValueCodec
import ru.dbotthepony.mc.otm.core.util.ItemValueCodec
import ru.dbotthepony.mc.otm.core.util.RGBCodec
import ru.dbotthepony.mc.otm.core.util.Savetables
import ru.dbotthepony.mc.otm.core.util.TickList
import ru.dbotthepony.mc.otm.core.util.UUIDValueCodec
import ru.dbotthepony.mc.otm.core.util.VarIntValueCodec
import ru.dbotthepony.mc.otm.menu.ExoPackInventoryMenu
import ru.dbotthepony.mc.otm.network.*
import ru.dbotthepony.mc.otm.network.synchronizer.FieldSynchronizer
import ru.dbotthepony.mc.otm.registry.AndroidFeatures
import ru.dbotthepony.mc.otm.registry.MDamageTypes
import ru.dbotthepony.mc.otm.registry.MRegistry
import ru.dbotthepony.mc.otm.registry.MatteryDamageSource
import ru.dbotthepony.mc.otm.triggers.AndroidResearchTrigger
import ru.dbotthepony.mc.otm.triggers.AndroidTravelUnderwater
import ru.dbotthepony.mc.otm.triggers.BecomeAndroidDeathTrigger
import ru.dbotthepony.mc.otm.triggers.BecomeAndroidSleepTrigger
import ru.dbotthepony.mc.otm.triggers.BecomeAndroidTrigger
import ru.dbotthepony.mc.otm.triggers.BecomeHumaneTrigger
import ru.dbotthepony.mc.otm.triggers.ExopackGainedCraftingTrigger
import ru.dbotthepony.mc.otm.triggers.ExopackGainedSmeltingTrigger
import ru.dbotthepony.mc.otm.triggers.ExopackObtainedTrigger
import ru.dbotthepony.mc.otm.triggers.ExopackSlotsExpandedTrigger
import java.util.*
import java.util.stream.Stream
import kotlin.collections.ArrayDeque
import kotlin.collections.ArrayList
import kotlin.collections.HashMap
import kotlin.reflect.KMutableProperty1

private fun Player.dropContainer(container: Container, spill: Boolean = true, setThrower: Boolean = false) {
	val iterator = container.iterator()

	for (item in iterator) {
		if (item.isNotEmpty) {
			drop(item, spill, setThrower)
			iterator.remove()
		}
	}
}

@Suppress("unused")
class MatteryPlayerCapability(val ply: Player) : ICapabilityProvider, INBTSerializable<CompoundTag> {
	/**
	 * This event is fired on main event bus before ticking logic takes place.
	 *
	 * Cancelling it is probably not a good idea, but you can do it anyway.
	 */
	@Cancelable
	data class PreTick(val capability: MatteryPlayerCapability) : Event() {
		val player get() = capability.ply
		val level: Level get() = capability.ply.level()
	}

	/**
	 * This event is fired on main event bus after ticking logic took place.
	 */
	data class PostTick(val capability: MatteryPlayerCapability) : Event() {
		val player get() = capability.ply
		val level: Level get() = capability.ply.level()
	}

	/**
	 * This event is fired on main event bus when [Inventory.add] was called and entire [ItemStack] can not be stored
	 * (both in [Inventory] and [MatteryPlayerCapability] exopack due to inventory filters or no free space).
	 */
	data class ItemStackLeftoverEvent(val stack: ItemStack, val capability: MatteryPlayerCapability) : Event() {
		val player get() = capability.ply
		val level: Level get() = capability.ply.level()
	}

	private inner class PlayerMatteryContainer(size: Int) : MatteryContainer(size) {
		override fun setChanged(slot: Int, new: ItemStack, old: ItemStack) {
			super.setChanged(slot, new, old)
			// TODO: Minecraft has hard-coded inventory triggers to Inventory class
			// when nothing use Inventory class directly.

			// Why?
			// CriteriaTriggers.INVENTORY_CHANGED.trigger(this, this.getInventory(), p_143468_)
		}
	}

	/**
	 * For fields that need to be synchronized only to owning player
	 *
	 * Please mind when you use this in your mod;
	 * any differences in field order/types/etc between client and server
	 * will break *everything*
	 */
	val synchronizer = FieldSynchronizer()

	/**
	 * For fields that need to be synchronized to everyone
	 *
	 * Please mind when you use this in your mod;
	 * any differences in field order/types/etc between client and server
	 * will break *everything*
	 */
	val publicSynchronizer = FieldSynchronizer()

	/**
	 * For data to be stored to and loaded from NBT automatically
	 */
	val savetables = Savetables()

	/**
	 * Whenever player has Exopack
	 */
	var hasExoPack by publicSynchronizer.bool(setter = setter@{ value, access, _ ->
		access.write(value)

		if (value && ply is ServerPlayer) {
			ExopackObtainedTrigger.trigger(ply)
		}
	}).property

	/**
	 * Whenever to render Exopack on player
	 */
	var displayExoPack by publicSynchronizer.bool(true).property

	/**
	 * Whenever to render Exopack glow in dark
	 */
	var exopackGlows by publicSynchronizer.bool(true).property

	var exopackColor by publicSynchronizer.Field(null, RGBCodec.nullable)

	/**
	 * Tick event schedulers
	 */
	val tickList = TickList()

	private val exoPackSlotModifierMap: MutableMap<UUID, Int> by synchronizer.Map(
		keyCodec = UUIDValueCodec,
		valueCodec = IntValueCodec,
		backingMap = HashMap(),
		callback = {
			this.exoPackSlotModifier.recompute()
		},
	)

	/**
	 * Modifier map for Exopack slot counts
	 *
	 * If you want to properly extend Exopack suit capacity, add your value into this map
	 */
	val exoPackSlotModifier = UUIDIntModifiersMap(observer = observer@{
		if (it < 0) {
			exoPackContainer = PlayerMatteryContainer(0)
		} else {
			exoPackContainer = PlayerMatteryContainer(it)
		}
	}, backingMap = this.exoPackSlotModifierMap)

	val regularSlotFilters = immutableList(Inventory.INVENTORY_SIZE) {
		synchronizer.Field(null, ItemValueCodec.nullable)
	}

	val regularSlotChargeFlag = immutableList(Inventory.INVENTORY_SIZE + 4) {
		synchronizer.bool()
	}

	private fun slotChargeToDefault() {
		// броня
		regularSlotChargeFlag[36].boolean = true
		regularSlotChargeFlag[37].boolean = true
		regularSlotChargeFlag[38].boolean = true
		regularSlotChargeFlag[39].boolean = true
	}

	init {
		slotChargeToDefault()
	}

	val exoPackSlotsChargeFlag by synchronizer.Set(
		codec = VarIntValueCodec,
		backingSet = IntAVLTreeSet(),
	)

	/**
	 * Exopack container, which actually store items inside Exopack
	 */
	var exoPackContainer: MatteryContainer = PlayerMatteryContainer(0)
		private set(value) {
			_exoPackMenu = null
			field.removeFilterSynchronizer()

			@Suppress("SENSELESS_COMPARISON") // false positive - fields of player can easily be nulls, despite annotations saying otherwise
			if (ply.containerMenu != null && (ply !is ServerPlayer || ply.connection != null)) {
				ply.closeContainer()
			}

			for (i in value.containerSize until field.containerSize) {
				if (ply is ServerPlayer)
					ply.spawnAtLocation(field[i])

				field[i] = ItemStack.EMPTY
			}

			value.deserializeNBT(field.serializeNBT())
			value.addFilterSynchronizer(synchronizer)
			field = value
		}

	/**
	 * Whenever Exopack has 3x3 crafting grid upgrade installed
	 */
	var isExoPackCraftingUpgraded by publicSynchronizer.bool(setter = setter@{ value, access, _ ->
		if (value != access.readBoolean()) {
			access.write(value)
			_exoPackMenu = null

			if (value && ply is ServerPlayer) {
				ExopackGainedCraftingTrigger.trigger(ply)
			}
		}
	}).property

	private var _exoPackMenu: ExoPackInventoryMenu? = null
		set(value) {
			if (field == ply.containerMenu) { ply.closeContainer() }
			field?.actuallyRemoved()
			field = value
		}

	/**
	 * [ExoPackInventoryMenu] which player will see when pressing inventory key
	 */
	val exoPackMenu: ExoPackInventoryMenu
		get() {
			if (_exoPackMenu == null) {
				_exoPackMenu = ExoPackInventoryMenu(this)
			}

			return _exoPackMenu!!
		}

	fun recreateExoPackMenu() {
		_exoPackMenu = ExoPackInventoryMenu(this)
	}

	private var shouldSendIteration = false

	/**
	 * Android' iteration counter (death counter, updating only when Android)
	 */
	var iteration = 0
		private set

	private var lastDeathTick = 0

	private val deathLog = ArrayDeque<Pair<Int, Component>>()

	private val featureMap = IdentityHashMap<AndroidFeatureType<*>, AndroidFeature>()
	private val networkQueue = ArrayList<Any>()
	private val queuedTicks = ArrayList<Runnable>()
	private var tickedOnce = false

	private var shouldPlaySound = false
	private val research = IdentityHashMap<AndroidResearchType, AndroidResearch>()
	private var nextDischargeHurt = 20
	private var nextHealTick = 0

	// players tracking us
	// stored separately because EntityTracker and ChunkMup, etc are buried deep and
	// getting them unburied will be a very work intense task
	private val trackingPlayers = Reference2ObjectOpenHashMap<ServerPlayer, FieldSynchronizer.Endpoint>()

	/**
	 * This returns if player is an Android or will become one on death/sleep/etc
	 */
	val isEverAndroid: Boolean get() = isAndroid || willBecomeAndroid
	internal var lastJumpTicks = 14

	/**
	 * In-game ticks this player exists (server play time, excluding time while "dead"), used by various things across the mod, such as
	 * "play time chance" loot condition
	 */
	var ticksIExist = 0
		private set

	private var lastOutsideLiquid = Vec3(0.0, 0.0, 0.0)
	private var wasInLiquid = false
	private var lastDimension = ResourceLocation("overworld")

	/**
	 * Whenever player should become an Android once transformation conditions are met (e.g. player dies or sleeps in bed)
	 */
	var willBecomeAndroid by publicSynchronizer.bool().property

	/**
	 * Whenever player is an Android
	 *
	 * In OTM itself, Android players are generally treated as something in between alive and undead, such as:
	 * * They can't be healed using potions, can't receive regeneration buffs
	 * * But they can be harmed using harm potion
	 * * Can't be poisoned (with default datapack)
	 * * CAN be withered (with default datapack)
	 * * Ignored by the Wither but not by Wither Skeletons
	 * * Can't drown
	 * * etc
	 *
	 * Android-immune (de)buffs are specified in `data/overdrive_that_matters/tags/mob_effect/android_immune_effects.json`
	 */
	var isAndroid by publicSynchronizer.bool().property

	/**
	 * Whenever player has exosuit smelting upgrade
	 */
	var isExoPackSmeltingInstalled by synchronizer.bool(setter = { value, access, _ ->
		if (value != access.readBoolean()) {
			access.write(value)
			_exoPackMenu = null

			if (value && ply is ServerPlayer) {
				ExopackGainedSmeltingTrigger.trigger(ply)
			}
		}
	}).property

	inner class SmelterBelt(index: Int) : MachineJobEventLoop<MachineItemJob>() {
		override val energy: IMatteryEnergyStorage
			get() = exoPackEnergy
		override val isBlockedByRedstone: Boolean
			get() = false

		override fun deserializeJob(nbt: CompoundTag): MachineItemJob {
			return MachineItemJob(nbt)
		}

		override val upgrades: IMatteryUpgrade?
			get() = null

		override fun jobUpdated(new: MachineItemJob?, old: MachineItemJob?) {}

		override fun onJobFinish(job: MachineItemJob): JobStatus {
			if (output.fullyAddItem(job.itemStack)) {
				exoPackSmelterExperience += job.experience
				return JobStatus.SUCCESS
			} else {
				return JobStatus.FAILURE_ITEM
			}
		}

		private val cache = RecipeManager.createCheck(RecipeType.SMELTING)

		override fun computeNextJob(): JobContainer<MachineItemJob> {
			if (!exoPackEnergy.batteryLevel.isPositive) return JobContainer.noEnergy()
			val level = ply.level() as? ServerLevel ?: return JobContainer.failure()
			val recipe = cache.getRecipeFor(input, level)

			if (recipe.isEmpty) {
				return JobContainer.noItem()
			} else {
				val actual = recipe.get()
				val item = actual.assemble(input, level.registryAccess())
				input[0].shrink(1)
				input.setChanged(0)
				return JobContainer.success(MachineItemJob(item, actual.cookingTime.toDouble(), ExopackConfig.FURNACE_POWER_CONSUMPTION, actual.experience))
			}
		}

		val input = MatteryContainer({ notify(IdleReason.ITEM) }, 1)
		val output = MatteryContainer({ notify(IdleReason.ITEM) }, 1)

		init {
			savetables.stateful(::input, "exopack_smelter_input_$index")
			savetables.stateful(::output, "exopack_smelter_output_$index")
			savetables.stateful(this, "exopack_smelter_worker_$index")
		}
	}

	val smelter0 = SmelterBelt(0)
	val smelter1 = SmelterBelt(1)
	val smelter2 = SmelterBelt(2)
	val smelters = listOf(smelter0, smelter1, smelter2)

	var exoPackSmelterExperience = 0f

	/**
	 * [IMatteryEnergyStorage] instance, representing Android' battery charge
	 */
	val androidEnergy = BatteryBackedEnergyStorage(ply, synchronizer, AndroidConfig.ANDROID_MAX_ENERGY, AndroidConfig.ANDROID_MAX_ENERGY, true)

	/**
	 * [IMatteryEnergyStorage] instance, representing Exopack battery charge
	 */
	val exoPackEnergy = ProfiledEnergyStorage(BatteryBackedEnergyStorage(ply, synchronizer, Decimal.ZERO, ExopackConfig.ENERGY_CAPACITY, false, onChange = { for (v in smelters) v.notify(MachineJobEventLoop.IdleReason.POWER) }))

	val exoPackChargeSlots = MatteryContainer(4)

	init {
		savetables.int(::ticksIExist)
		savetables.int(::iteration)

		savetables.bool(::shouldSendIteration)
		savetables.bool(::wasInLiquid)
		savetables.bool(::isAndroid)
		savetables.bool(::willBecomeAndroid)
		savetables.bool(::hasExoPack, "hasExoSuit")
		savetables.bool(::displayExoPack, "displayExoSuit")
		savetables.bool(::isExoPackCraftingUpgraded, "isExoSuitCraftingUpgraded")
		savetables.int(::nextDischargeHurt)
		savetables.int(::nextHealTick)

		savetables.vector(::lastOutsideLiquid)
		savetables.location(::lastDimension)

		savetables.stateful(::exoPackSlotModifier, "exoSuitSlotCountModifiers")
		savetables.stateful(::exoPackContainer, "exoSuitContainer")
		savetables.stateful(::androidEnergy)
		savetables.stateful(::exoPackEnergy)
		savetables.stateful(::exoPackChargeSlots)
		savetables.float(::exoPackSmelterExperience)
		savetables.bool(::isExoPackSmeltingInstalled)

		savetables.codecNullable(::exopackColor, RGBAColor.CODECRGB)
		savetables.bool(::exopackGlows)
	}

	fun invalidateNetworkState() {
		synchronizer.invalidate()
		publicSynchronizer.invalidate()

		for (instance in research.values) {
			instance.invalidateNetwork()
		}

		for (feature in featureMap.values) {
			feature.invalidateNetwork()
		}

		for (i in 0 until exoPackMenu.slots.size) {
			exoPackMenu.setRemoteSlotNoCopy(i, ItemStack.EMPTY)
		}

		tickList.timer(20) {
			recreateExoPackMenu()
		}
	}

	/**
	 * Flags player to turn into Android
	 *
	 * This is used by Android Pill item in-game
	 *
	 * Does nothing if player is already an Android
	 */
	fun becomeAndroidSoft() {
		if (isAndroid || willBecomeAndroid) return
		willBecomeAndroid = true
		(ply as? ServerPlayer)?.displayClientMessage(TranslatableComponent("otm.pill.message").withStyle(ChatFormatting.GRAY), false)
	}

	/**
	 * Unconditionally and instantly turns player into Android
	 *
	 * This is different than setting [isAndroid] because it setup some variables,
	 * such as battery charge
	 *
	 * Does not kill the player
	 *
	 * Does nothing if player is already an Android
	 */
	fun becomeAndroid() {
		if (isAndroid) return

		isAndroid = true
		willBecomeAndroid = false
		shouldPlaySound = false
		iteration = 0
		deathLog.clear()
		androidEnergy.batteryLevel = AndroidConfig.ANDROID_MAX_ENERGY
		androidEnergy.maxBatteryLevel = AndroidConfig.ANDROID_MAX_ENERGY

		lastOutsideLiquid = ply.position()
		wasInLiquid = false

		if (ply is ServerPlayer) {
			BecomeAndroidTrigger.trigger(ply)
		}
	}

	/**
	 * [becomeAndroid] plus instantly kills the player
	 *
	 * Does nothing if player is already an Android
	 */
	fun becomeAndroidAndKill() {
		if (isAndroid) return

		becomeAndroid()

		if (!ply.abilities.invulnerable)
			ply.hurt(MatteryDamageSource(ply.level().registryAccess().damageType(MDamageTypes.BECOME_ANDROID)), ply.maxHealth * 2)
	}

	/**
	 * Unconditionally and instantly turns player into "human"
	 *
	 * This is different than setting [isAndroid] because it setup some variables,
	 * such as battery charge
	 *
	 * Does not kill the player
	 *
	 * Drops equipped battery BUT does NOT refund research, nor does remove features;
	 * Once player becomes Android again, they will retain all features and research.
	 *
	 * If you need to also remove research and features, look at [obliviate]
	 *
	 * However, this does reset [iteration] and death log
	 *
	 * Does nothing if player is not an Android
	 *
	 * Unsets [willBecomeAndroid] flag if it is true
	 */
	fun becomeHumane() {
		if (willBecomeAndroid) willBecomeAndroid = false
		if (!isAndroid) return

		isAndroid = false
		shouldPlaySound = false
		iteration = 0
		deathLog.clear()
		androidEnergy.batteryLevel = Decimal.ZERO
		androidEnergy.maxBatteryLevel = AndroidConfig.ANDROID_MAX_ENERGY
		dropBattery()

		lastOutsideLiquid = ply.position()
		wasInLiquid = false

		if (ply is ServerPlayer) {
			BecomeHumaneTrigger.trigger(ply)
		}

		for (feature in featureMap.values) {
			feature.removeModifiers()
		}
	}

	/**
	 * [becomeHumane] plus kills the player
	 *
	 * Does nothing if player is not an Android
	 *
	 * Unsets [willBecomeAndroid] flag if it is true
	 */
	fun becomeHumaneAndKill() {
		if (willBecomeAndroid) willBecomeAndroid = false
		if (!isAndroid) return

		becomeHumane()

		if (!ply.abilities.invulnerable)
			ply.hurt(MatteryDamageSource(ply.level().registryAccess().damageType(MDamageTypes.BECOME_HUMANE)), ply.maxHealth * 2)
	}

	/**
	 * *Refunds* all research and removes all Android features
	 *
	 * Resets [iteration] and death log
	 */
	fun obliviate(refund: Boolean = true) {
		for (instance in research.values) {
			if (instance.isResearched) {
				instance.unResearch()

				if (refund) {
					instance.refund()
				}
			}
		}

		val copy = ArrayList<AndroidFeature>(featureMap.values.size).also { it.addAll(featureMap.values) }

		for (feature in copy) {
			removeFeature(feature.type)
		}

		iteration = 0
		deathLog.clear()
	}

	/**
	 * Returns [AndroidResearch] state for specified [AndroidResearchType]
	 */
	fun getResearch(type: AndroidResearchType): AndroidResearch {
		return research.computeIfAbsent(type) {
			return@computeIfAbsent AndroidResearch(type, this)
		}
	}

	internal fun reloadResearch() {
		val old = ArrayList<AndroidResearch>(research.size)
		old.addAll(research.values)
		research.clear()

		for (v in old) {
			val new = AndroidResearchManager.get(v.type.id) ?: continue

			research[new] = AndroidResearch(new, this).also {
				it.deserializeNBT(v.serializeNBT())
			}
		}
	}

	/**
	 * Android features stream, use this to get list of all Android features this player has
	 */
	val features: Stream<out AndroidFeature> get() = featureMap.values.stream()

	private fun addFeature(feature: AndroidFeature): Boolean {
		if (featureMap.containsKey(feature.type)) return false
		featureMap[feature.type] = feature

		if (!ply.level().isClientSide) {
			queuedTicks.add(feature::applyModifiers)
		}

		if (ply is ServerPlayer) {
			feature.invalidateNetwork()
			val payload = feature.collectNetworkPayload()

			if (payload != null) {
				sendNetwork(AndroidFeatureSyncPacket(feature.type, payload, null))
			}
		}

		return true
	}

	/**
	 * Adds or returns specified Android feature to this player
	 */
	@Suppress("unchecked_cast")
	fun <T : AndroidFeature> addFeature(feature: AndroidFeatureType<T>): T {
		val get = featureMap[feature]
		if (get != null) return get as T

		val factory = feature.create(this)

		featureMap[feature] = factory

		if (!ply.level().isClientSide && isAndroid) {
			queuedTicks.add(factory::applyModifiers)
		}

		if (ply is ServerPlayer) {
			factory.invalidateNetwork()
			val payload = factory.collectNetworkPayload()

			if (payload != null) {
				sendNetwork(AndroidFeatureSyncPacket(feature, payload, null))
			}
		}

		return factory
	}

	/**
	 * Removes specified Android feature from this player
	 *
	 * @return whenever feature was found and removed
	 */
	fun removeFeature(feature: AndroidFeatureType<*>): Boolean {
		val removed = featureMap.remove(feature)

		if (removed != null) {
			if (!ply.level().isClientSide && isAndroid) {
				queuedTicks.add(removed::removeModifiers)
			}

			if (ply is ServerPlayer) {
				sendNetwork(AndroidFeatureRemovePacket(removed.type))
			}
		}

		return removed != null
	}

	/**
	 * Whenever player has specified [feature]
	 */
	fun hasFeature(feature: AndroidFeatureType<*>): Boolean {
		return featureMap.containsKey(feature)
	}

	/**
	 * Whenever player has specified [feature] and its [AndroidFeature.level] is equal to or greater than [level]
	 */
	fun hasFeatureLevel(feature: AndroidFeatureType<*>, level: Int): Boolean {
		val get = featureMap[feature] ?: return false
		return get.level >= level
	}

	/**
	 * Raw get of Android feature of this player
	 *
	 * @return null if no such feature is present
	 */
	@Suppress("unchecked_cast")
	fun <T : AndroidFeature> getFeature(feature: AndroidFeatureType<T>): T? {
		return featureMap[feature] as T?
	}

	private fun onHurt(event: LivingHurtEvent) {
		if (isAndroid) {
			for (feature in featureMap.values) {
				feature.onHurt(event)
			}

			if (!event.isCanceled && event.amount > 0f) {
				sendNetwork(GlitchPacket((event.amount * 300).toLong().coerceAtLeast(200L).coerceAtMost(800L)))
			}
		}
	}

	private fun onAttack(event: LivingAttackEvent) {
		if (isAndroid) {
			for (feature in featureMap.values) {
				feature.onAttack(event)
			}
		}
	}

	internal fun <T : AndroidFeature> computeIfAbsent(feature: AndroidFeatureType<T>): T {
		return getFeature(feature) ?: addFeature(feature)
	}

	/**
	 * Lambda style of [getFeature]
	 */
	inline fun <T : AndroidFeature> ifFeature(feature: AndroidFeatureType<T>, consumer: (T) -> Unit) {
		val get = getFeature(feature)

		if (get != null) {
			consumer(get)
		}
	}

	override fun serializeNBT(): CompoundTag {
		val tag = savetables.serializeNBT()

		// iteration
		tag["deathLog"] = ListTag().also {
			for ((ticks, component) in deathLog) {
				it.add(CompoundTag().also {
					it["ticks"] = ticks
					it["component"] = StringTag.valueOf(Component.Serializer.toJson(component))
				})
			}
		}

		tag["features"] = ListTag().also {
			for (feature in featureMap.values) {
				it.add(feature.serializeNBT().also {
					it["id"] = feature.type.registryName!!.toString()
				})
			}
		}

		tag["research"] = ListTag().also {
			for ((type, instance) in research) {
				it.add(instance.serializeNBT().also {
					it["id"] = type.id.toString()
				})
			}
		}

		tag["regularSlotFilters"] = ListTag().also {
			for (filter in regularSlotFilters) {
				it.add(StringTag.valueOf(filter.value?.registryName?.toString() ?: ""))
			}
		}

		tag["regularSlotChargeFlag"] = ListTag().also {
			for (flag in regularSlotChargeFlag) {
				it.add(ByteTag.valueOf(flag.boolean))
			}
		}

		tag["exoPackSlotsChargeFlag"] = ListTag().also {
			for (value in exoPackSlotsChargeFlag) {
				it.add(IntTag.valueOf(value))
			}
		}

		return tag
	}

	override fun deserializeNBT(tag: CompoundTag) {
		savetables.deserializeNBT(tag)

		if (ply is ServerPlayer && hasExoPack)
			ExopackSlotsExpandedTrigger.trigger(ply, 0, exoPackContainer.containerSize)

		for (filter in regularSlotFilters) {
			filter.value = null
		}

		for (flag in regularSlotChargeFlag) {
			flag.boolean = false
		}

		exoPackSlotsChargeFlag.clear()

		slotChargeToDefault()

		val regularSlotFilters = tag.getStringList("regularSlotFilters")

		for (i in 0 until regularSlotFilters.size.coerceAtMost(this.regularSlotFilters.size)) {
			val path = regularSlotFilters[i].asString
			if (path == "") continue
			this.regularSlotFilters[i].value = ForgeRegistries.ITEMS.getValue(ResourceLocation.tryParse(path) ?: continue) ?: Items.AIR
		}

		val regularSlotChargeFlag = tag.getByteList("regularSlotChargeFlag")

		for (i in 0 until regularSlotChargeFlag.size.coerceAtMost(this.regularSlotChargeFlag.size)) {
			this.regularSlotChargeFlag[i].boolean = regularSlotChargeFlag[i].asInt > 0
		}

		for (v in tag.getIntList("exoPackSlotsChargeFlag")) {
			this.exoPackSlotsChargeFlag.add(v.asInt)
		}

		// iterations
		deathLog.clear()

		for (value in tag.getCompoundList("deathLog")) {
			val component = Component.Serializer.fromJson(value.getString("component"))

			if (component != null) {
				deathLog.add(value.getInt("ticks") to component)
			}
		}

		featureMap.clear()
		research.clear()

		for (featureTag in tag.getCompoundList("features")) {
			val feature = MRegistry.ANDROID_FEATURES.getValue(ResourceLocation(featureTag.getString("id")))

			if (feature?.isApplicable(this) == true) {
				val instance = feature.create(this)

				instance.deserializeNBT(featureTag)
				addFeature(instance)

				if (!ply.level().isClientSide) {
					queuedTicks.add(instance::applyModifiers)
				}
			}
		}

		for (researchTag in tag.getCompoundList("research")) {
			val research = AndroidResearchManager[ResourceLocation(researchTag.getString("id"))]

			if (research != null) {
				val instance = AndroidResearch(research, this)
				instance.deserializeNBT(researchTag)
				this.research[research] = instance

				if (instance.isResearched && ply is ServerPlayer) {
					tickList.timer(20) {
						if (!ply.hasDisconnected())
							AndroidResearchTrigger.trigger(ply, instance.type)
					}
				}
			}
		}

		if (isAndroid && ply is ServerPlayer) {
			tickList.timer(20) {
				if (!ply.hasDisconnected())
					BecomeAndroidTrigger.trigger(ply)
			}
		}
	}

	/**
	 * Drops Android battery as in-world item
	 */
	fun dropBattery() {
		if (androidEnergy.item.isEmpty) return
		ply.spawnAtLocation(androidEnergy.item)
		androidEnergy.item = ItemStack.EMPTY
	}

	private fun sendNetwork(packet: Any) {
		if (ply is ServerPlayer) {
			if (tickedOnce) {
				MatteryPlayerNetworkChannel.send(ply, packet)
			} else {
				networkQueue.add(packet)
			}
		}
	}

	private fun tickInventory() {
		if (hasExoPack) {
			for ((i, stack) in exoPackContainer.withIndex()) {
				if (!stack.isEmpty) {
					stack.inventoryTick(ply.level(), ply, i + 41, false)
				}
			}
		}
	}

	private fun tickClient() {
		queuedTicks.clear()

		if (!ply.isAlive) {
			return
		}

		if (isAndroid) {
			for (feature in featureMap.values) {
				feature.tickClient()
			}
		}

		tickInventory()
	}

	private fun preTick() {
		if (!ply.isAlive) return

		if (isAndroid) {
			ForgeRegistries.MOB_EFFECTS.tags()?.getTag(ANDROID_IMMUNE_EFFECTS)?.forEach {
				if (ply.hasEffect(it))
					ply.removeEffect(it)
			}
		}
	}

	private fun tick() {
		if (!ply.isAlive) return

		PreTick(this).also {
			MinecraftForge.EVENT_BUS.post(it)
			if (it.isCanceled) return
		}

		ticksIExist++

		if (willBecomeAndroid) {
			if (ply.isSleeping && ply.sleepTimer > SLEEP_TICKS_LIMIT) {
				becomeAndroid()
				shouldPlaySound = true

				if (ply is ServerPlayer) {
					ply.displayClientMessage(TranslatableComponent("otm.pill.message_finish").withStyle(ChatFormatting.DARK_RED), false)
					BecomeAndroidSleepTrigger.trigger(ply)
				}
			}
		}

		if (hasExoPack) {
			exoPackEnergy.parent.tick()

			if (!ply.isSpectator) {
				if (isExoPackSmeltingInstalled)
					smelters.forEach { it.think() }

				if (exoPackEnergy.batteryLevel.isPositive) {
					var available = exoPackEnergy.extractEnergy(exoPackEnergy.batteryLevel, true)

					if (!exoPackChargeSlots.isEmpty) {
						for (item in exoPackChargeSlots) {
							if (item.isNotEmpty) {
								item.energy?.let {
									available -= exoPackEnergy.extractEnergy(it.receiveEnergy(available, false), false)
								}

								if (!available.isPositive) break
							}
						}
					}

					if (available.isPositive) {
						for ((i, flag) in regularSlotChargeFlag.withIndex()) {
							if (flag.boolean) {
								val item = ply.inventory[i]

								if (item.isNotEmpty) {
									item.energy?.let {
										available -= exoPackEnergy.extractEnergy(it.receiveEnergy(available, false), false)
									}

									if (!available.isPositive) break
								}
							}
						}
					}

					if (available.isPositive) {
						for (slot in exoPackSlotsChargeFlag) {
							if (slot in 0 until exoPackContainer.containerSize) {
								val item = exoPackContainer[slot]

								if (item.isNotEmpty) {
									item.energy?.let {
										available -= exoPackEnergy.extractEnergy(it.receiveEnergy(available, false), false)
									}

									if (!available.isPositive) break
								}
							}
						}
					}
				}
			}
		}

		if (isAndroid) {
			androidEnergy.tick()

			if (!ply.isSpectator) {
				if (ply.airSupply < ply.maxAirSupply)
					ply.airSupply = ply.maxAirSupply

				if (ply.isSwimming && !hasFeature(AndroidFeatures.AIR_BAGS))
					ply.isSwimming = false

				if (ply is ServerPlayer) {
					if (ply.level().dimension().location() != lastDimension) {
						lastDimension = ply.level().dimension().location()
						wasInLiquid = false
						lastOutsideLiquid = ply.position
					}

					if (ply.isUnderWater && !ply.isCreative) {
						if (!wasInLiquid) {
							wasInLiquid = true
							lastOutsideLiquid = ply.position
						}
					} else {
						if (wasInLiquid) {
							wasInLiquid = false

							if (!hasFeature(AndroidFeatures.AIR_BAGS))
								AndroidTravelUnderwater.trigger(ply, (lastOutsideLiquid - ply.position).length())
						}

						lastOutsideLiquid = ply.position
					}
				}

				val stats = ply.foodData
				val fourTimesTheHunger = AndroidConfig.ANDROID_ENERGY_PER_HUNGER_POINT * 4

				// истощение
				if (stats.exhaustionLevel > 0f) {
					val extracted = androidEnergy.extractEnergy(AndroidConfig.ANDROID_ENERGY_PER_HUNGER_POINT * (stats.exhaustionLevel / 4f), false)
					stats.setExhaustion(stats.exhaustionLevel - (extracted / AndroidConfig.ANDROID_ENERGY_PER_HUNGER_POINT).toFloat() * 4f)
				}

				// Обычный голод
				while (
					stats.foodLevel < 18 &&
					androidEnergy.batteryLevel >= fourTimesTheHunger &&
					androidEnergy.extractEnergyExact(AndroidConfig.ANDROID_ENERGY_PER_HUNGER_POINT, false)
				) {
					stats.foodLevel++
				}

				// "поглощение" излишек голода, как при мирном режиме, так и при поедании обычной еды
				if (AndroidConfig.REGENERATE_ENERGY) {
					while (stats.foodLevel > 18 && androidEnergy.receiveEnergyExact(AndroidConfig.ANDROID_ENERGY_PER_HUNGER_POINT / 2, false)) {
						stats.foodLevel--
					}
				} else if (ply.level().difficulty != Difficulty.PEACEFUL) {
					stats.foodLevel = stats.foodLevel.coerceAtMost(18)
				}

				val foodLevel = stats.foodLevel.toFloat()

				// насыщение
				if (stats.saturationLevel < foodLevel && androidEnergy.batteryLevel >= fourTimesTheHunger) {
					val extracted = androidEnergy.extractEnergy(AndroidConfig.ANDROID_ENERGY_PER_HUNGER_POINT * (foodLevel - stats.saturationLevel), false)
					stats.setSaturation(stats.saturationLevel + (extracted / AndroidConfig.ANDROID_ENERGY_PER_HUNGER_POINT).toFloat())
				}

				if (androidEnergy.batteryLevel <= Decimal.TEN && !ply.isCreative && ply.level().difficulty != Difficulty.PEACEFUL) {
					if (stats.saturationLevel > 1f) {
						if (androidEnergy.receiveEnergyExact(AndroidConfig.ANDROID_ENERGY_PER_HUNGER_POINT, false)) {
							stats.setSaturation(stats.saturationLevel - 1f)
						}
					} else if (stats.saturationLevel > 0f) {
						val received = androidEnergy.receiveEnergy(AndroidConfig.ANDROID_ENERGY_PER_HUNGER_POINT * stats.saturationLevel, false)
						stats.setSaturation(stats.saturationLevel - (received / AndroidConfig.ANDROID_ENERGY_PER_HUNGER_POINT).toFloat())
					} else if (stats.foodLevel > 0) {
						// так как голод не тикает для андроидов, "умереть с голоду" мы не можем
						// но со стороны будет выглядеть как будто мы умираем с голода
						if (androidEnergy.receiveEnergyExact(AndroidConfig.ANDROID_ENERGY_PER_HUNGER_POINT, false)) {
							stats.foodLevel--
						}
					}

					if (androidEnergy.batteryLevel <= Decimal.TEN) {
						if (--nextDischargeHurt <= 0 && ply.hurt(DamageSource(ply.level().registryAccess().damageType(MDamageTypes.ANDROID_DISCHARGE)), 1f)) {
							nextDischargeHurt = 20
						}

						val effect = ply.activeEffectsMap[MobEffects.MOVEMENT_SLOWDOWN]

						if (effect == null || effect.duration < 40 || effect.amplifier < 2) {
							ply.addEffect(MobEffectInstance(MobEffects.MOVEMENT_SLOWDOWN, effect?.duration?.coerceAtLeast(60) ?: 60, effect?.amplifier?.coerceAtLeast(2) ?: 2, false, false))
						}
					}
				} else {
					nextDischargeHurt = 20

					if (ply.isHurt && ply.level().gameRules.getBoolean(GameRules.RULE_NATURAL_REGENERATION)) {
						if (--nextHealTick <= 0) {
							nextHealTick = if (ply.level().difficulty == Difficulty.PEACEFUL) 10 else AndroidConfig.TIME_BETWEEN_NATURAL_REGENERATION
							ply.heal(1f)
						}
					} else {
						nextHealTick = if (ply.level().difficulty == Difficulty.PEACEFUL) 10 else AndroidConfig.TIME_BETWEEN_NATURAL_REGENERATION
					}
				}
			}

			for (feature in featureMap.values) {
				feature.tickServer()
			}
		}

		for (runnable in queuedTicks) {
			runnable.run()
		}

		queuedTicks.clear()

		tickedOnce = true

		val payload = synchronizer.collectNetworkPayload()

		if (payload != null) {
			MatteryPlayerNetworkChannel.send(ply, MatteryPlayerFieldPacket(payload, false))
		}

		val trackingIterator = trackingPlayers.entries.iterator()

		for ((ply, endpoint) in trackingIterator) {
			if (ply.hasDisconnected()) {
				trackingIterator.remove()
				continue
			}

			val payload2 = endpoint.collectNetworkPayload()

			if (payload2 != null) {
				MatteryPlayerNetworkChannel.send(ply, MatteryPlayerFieldPacket(payload2, true, this.ply.uuid))
			}
		}

		val payload3 = publicSynchronizer.collectNetworkPayload()

		if (payload3 != null) {
			MatteryPlayerNetworkChannel.send(ply, MatteryPlayerFieldPacket(payload3, true))
		}

		if (networkQueue.size != 0) {
			for (packet in networkQueue) {
				MatteryPlayerNetworkChannel.send(ply, packet)
			}

			networkQueue.clear()
		}

		for (instance in research.values) {
			val researchPayload = instance.collectNetworkPayload()

			if (researchPayload != null) {
				sendNetwork(AndroidResearchSyncPacket(instance.type, researchPayload, null))
			}
		}

		for (instance in featureMap.values) {
			val featurePayload = instance.collectNetworkPayload()

			if (featurePayload != null) {
				sendNetwork(AndroidFeatureSyncPacket(instance.type, featurePayload, null))
			}
		}

		if (shouldSendIteration) {
			sendNetwork(PlayerIterationPacket(iteration, deathLog))
			shouldSendIteration = false
		}

		if (hasExoPack && ply.containerMenu == ply.inventoryMenu) {
			exoPackMenu.broadcastChanges()
		}

		tickInventory()

		PostTick(this).also {
			MinecraftForge.EVENT_BUS.post(it)
		}
	}

	private val resolver = LazyOptional.of { this }

	override fun <T> getCapability(cap: Capability<T>, side: Direction?): LazyOptional<T> {
		return if (cap == MatteryCapability.MATTERY_PLAYER) {
			resolver.cast()
		} else LazyOptional.empty()
	}

	/**
	 * This re-implement [Inventory.add] logic (original method is redirected to this)
	 */
	fun inventoryAddImpl(stack: ItemStack): Boolean {
		val items = ply.inventory.items
		val exoPackContainer = exoPackContainer

		if (!stack.isStackable) {
			// двигаем в отфильтрованные слоты
			for (i in items.indices) {
				if (items[i].isEmpty && regularSlotFilters[i].value === stack.item) {
					items[i] = stack.copy()
					items[i].popTime = 5
					stack.count = 0
					return true
				}
			}

			for (i in 0 until exoPackContainer.containerSize) {
				if (exoPackContainer[i].isEmpty && exoPackContainer.hasSlotFilter(i) && exoPackContainer.testSlotFilter(i, stack)) {
					exoPackContainer[i] = stack.copy()
					exoPackContainer[i].popTime = 5
					stack.count = 0
					return true
				}
			}

			// двигаем в обычные слоты
			for (i in items.indices) {
				if (items[i].isEmpty && regularSlotFilters[i].value === null) {
					items[i] = stack.copy()
					items[i].popTime = 5
					stack.count = 0
					return true
				}
			}

			for (i in 0 until exoPackContainer.containerSize) {
				if (exoPackContainer[i].isEmpty && !exoPackContainer.hasSlotFilter(i)) {
					exoPackContainer[i] = stack.copy()
					exoPackContainer[i].popTime = 5
					stack.count = 0
					return true
				}
			}

			MinecraftForge.EVENT_BUS.post(ItemStackLeftoverEvent(stack, this))

			if (ply.abilities.instabuild) {
				stack.count = 0
			}

			return stack.isEmpty
		}

		// двигаем в существующие слоты
		items[ply.inventory.selected].also { stackStacks(it, stack) }
		if (stack.isEmpty) return true
		ply.inventory.offhand[0].also { stackStacks(it, stack) }
		if (stack.isEmpty) return true

		for (i in items.indices) {
			if (stackStacks(items[i], stack) && stack.isEmpty) {
				return true
			}
		}

		for (i in 0 until exoPackContainer.containerSize) {
			if (stackStacks(exoPackContainer[i], stack)) {
				exoPackContainer.setChanged(i)

				if (stack.isEmpty) {
					return true
				}
			}
		}

		// двигаем в пустые слоты

		// двигаем в отфильтрованные слоты
		for (i in items.indices) {
			if (items[i].isEmpty && regularSlotFilters[i].value === stack.item) {
				items[i] = stack.split(stack.count.coerceAtMost(stack.maxStackSize))
				items[i].popTime = 5

				if (stack.isEmpty) {
					return true
				}
			}
		}

		for (i in 0 until exoPackContainer.containerSize) {
			if (exoPackContainer[i].isEmpty && exoPackContainer.hasSlotFilter(i) && exoPackContainer.testSlotFilter(i, stack)) {
				exoPackContainer[i] = stack.split(stack.count.coerceAtMost(stack.maxStackSize))
				exoPackContainer[i].popTime = 5

				if (stack.isEmpty) {
					return true
				}
			}
		}

		// двигаем в обычные слоты
		for (i in items.indices) {
			if (items[i].isEmpty && regularSlotFilters[i].value === null) {
				items[i] = stack.split(stack.count.coerceAtMost(stack.maxStackSize))
				items[i].popTime = 5

				if (stack.isEmpty) {
					return true
				}
			}
		}

		for (i in 0 until exoPackContainer.containerSize) {
			if (exoPackContainer[i].isEmpty && !exoPackContainer.hasSlotFilter(i)) {
				exoPackContainer[i] = stack.split(stack.count.coerceAtMost(stack.maxStackSize))
				exoPackContainer[i].popTime = 5

				if (stack.isEmpty) {
					return true
				}
			}
		}

		MinecraftForge.EVENT_BUS.post(ItemStackLeftoverEvent(stack, this))

		if (ply.abilities.instabuild) {
			stack.count = 0
		}

		return stack.isEmpty
	}

	enum class UpgradeType(val prop: KMutableProperty1<MatteryPlayerCapability, Boolean>) {
		CRAFTING(MatteryPlayerCapability::isExoPackCraftingUpgraded),
		SMELTING(MatteryPlayerCapability::isExoPackSmeltingInstalled);
	}

	@Suppress("unused")
	companion object {
		private fun setExoPack(players: Collection<Player>, hasExoPack: Boolean): Int {
			for (player in players) {
				player.matteryPlayer?.hasExoPack = hasExoPack
				player.matteryPlayer?._exoPackMenu = null
			}

			return players.size
		}

		private fun makeAndroid(players: Collection<Player>): Int {
			for (player in players) {
				player.matteryPlayer?.becomeAndroid()
			}

			return players.size
		}

		private fun makeHuman(players: Collection<Player>): Int {
			for (player in players) {
				player.matteryPlayer?.becomeHumane()
			}

			return players.size
		}

		private fun setUpgrade(players: Collection<Player>, type: UpgradeType, state: Boolean): Int {
			for (player in players) {
				player.matteryPlayer?.let { type.prop.set(it, state) }
			}

			return players.size
		}

		fun addCommands(event: RegisterCommandsEvent) {
			event.dispatcher.register(
				Commands.literal("exopack")
					.requires { it.hasPermission(Commands.LEVEL_GAMEMASTERS) }
					.then(Commands.literal("add")
						.executes { setExoPack(listOf(it.source.playerOrException), true) }
						.then(Commands.argument("targets", EntityArgument.players()).executes { setExoPack(EntityArgument.getPlayers(it, "targets"), true) })
					)
					.then(Commands.literal("remove")
						.executes { setExoPack(listOf(it.source.playerOrException), false) }
						.then(Commands.argument("targets", EntityArgument.players()).executes { setExoPack(EntityArgument.getPlayers(it, "targets"), false) })
					)
					.then(Commands.literal("upgrade")
						.then(Commands.argument("type", EnumArgument.enumArgument(UpgradeType::class.java))
							  .then(Commands.literal("add")
										.executes { setUpgrade(listOf(it.source.playerOrException), it.getArgument("type", UpgradeType::class.java), true) }
										.then(Commands.argument("targets", EntityArgument.players()).executes { setUpgrade(EntityArgument.getPlayers(it, "targets"), it.getArgument("type", UpgradeType::class.java), true) }))
							  .then(Commands.literal("remove")
										.executes { setUpgrade(listOf(it.source.playerOrException), it.getArgument("type", UpgradeType::class.java), false) }
										.then(Commands.argument("targets", EntityArgument.players()).executes { setUpgrade(EntityArgument.getPlayers(it, "targets"), it.getArgument("type", UpgradeType::class.java), false) })))
					)
			)

			event.dispatcher.register(
				Commands.literal("android")
					.requires { it.hasPermission(Commands.LEVEL_GAMEMASTERS) }
					.then(Commands.literal("on")
						.executes { makeAndroid(listOf(it.source.playerOrException)) }
						.then(Commands.argument("targets", EntityArgument.players()).executes { makeAndroid(EntityArgument.getPlayers(it, "targets")) })
					)
					.then(Commands.literal("off")
						.executes { makeHuman(listOf(it.source.playerOrException)) }
						.then(Commands.argument("targets", EntityArgument.players()).executes { makeHuman(EntityArgument.getPlayers(it, "targets")) })
					)
			)
		}

		private fun stackStacks(it: ItemStack, stack: ItemStack): Boolean {
			if (
				!it.isEmpty &&
				it.maxStackSize > it.count &&
				it.isStackable &&
				ItemStack.isSameItemSameTags(it, stack)
			) {
				val new = (it.count + stack.count).coerceAtMost(it.maxStackSize)
				val diff = new - it.count
				it.count = new
				stack.count -= diff
				it.popTime = 5

				return true
			}

			return false
		}

		init {
			WitherBoss.TARGETING_CONDITIONS.selector(WitherBoss.TARGETING_CONDITIONS.selector!!.and { it.matteryPlayer?.isAndroid != true })
			WitherBoss.LIVING_ENTITY_SELECTOR = WitherBoss.LIVING_ENTITY_SELECTOR.and { it.matteryPlayer?.isAndroid != true }
		}

		val ANDROID_IMMUNE_EFFECTS: TagKey<MobEffect> = TagKey.create(ForgeRegistries.MOB_EFFECTS.registryKey, ResourceLocation(OverdriveThatMatters.MOD_ID, "android_immune_effects"))

		fun onPlayerTick(event: PlayerTickEvent) {
			val ent = event.player

			if (event.phase == TickEvent.Phase.START) {
				if (!ent.level().isClientSide) {
					ent.getCapability(MatteryCapability.MATTERY_PLAYER).ifPresentK {
						it.preTick()
					}
				}
			} else {
				if (ent.level().isClientSide) {
					ent.getCapability(MatteryCapability.MATTERY_PLAYER).ifPresentK {
						it.tickClient()
					}
				} else {
					ent.getCapability(MatteryCapability.MATTERY_PLAYER).ifPresentK {
						it.tick()
					}
				}
			}
		}

		fun isMobEffectApplicable(event: MobEffectEvent.Applicable) {
			event.entity.getCapability(MatteryCapability.MATTERY_PLAYER).ifPresentK {
				if (it.isAndroid && ForgeRegistries.MOB_EFFECTS.tags()?.getTag(ANDROID_IMMUNE_EFFECTS)?.stream()?.anyMatch { it == event.effectInstance.effect } == true) {
					event.result = Event.Result.DENY
				}
			}
		}

		fun onHurtEvent(event: LivingHurtEvent) {
			event.entity.getCapability(MatteryCapability.MATTERY_PLAYER).ifPresentK { it.onHurt(event) }
		}

		fun onAttackEvent(event: LivingAttackEvent) {
			event.entity.getCapability(MatteryCapability.MATTERY_PLAYER).ifPresentK { it.onAttack(event) }
		}

		val CAPABILITY_LOCATION = ResourceLocation(OverdriveThatMatters.MOD_ID, "player")

		fun onAttachCapabilityEvent(event: AttachCapabilitiesEvent<Entity>) {
			val ent = event.`object`

			if (ent is Player) {
				event.addCapability(CAPABILITY_LOCATION, MatteryPlayerCapability(ent))
			}
		}

		fun onPlayerChangeDimensionEvent(event: PlayerEvent.PlayerChangedDimensionEvent) {
			event.entity.getCapability(MatteryCapability.MATTERY_PLAYER).ifPresentK {
				it.invalidateNetworkState()
			}
		}

		fun onPlayerDeath(event: LivingDeathEvent) {
			val ply = event.entity as? Player ?: return
			val mattery = ply.matteryPlayer ?: return

			if (mattery.lastDeathTick != ply.tickCount) {
				mattery.lastDeathTick = ply.tickCount
			} else {
				return
			}

			if (!mattery.isAndroid) {
				return
			}

			mattery.iteration++
			mattery.shouldSendIteration = true
			mattery.deathLog.addLast(ply.tickCount to ply.combatTracker.deathMessage)
			mattery.androidEnergy.batteryLevel = mattery.androidEnergy.batteryLevel.coerceAtLeast(AndroidConfig.ANDROID_MAX_ENERGY * Decimal("0.2")) // если смерть была от разряда батареи, то предотвращаем софтлок

			while (mattery.deathLog.size > 6) {
				mattery.deathLog.removeFirst()
			}
		}

		fun onPlayerCloneEvent(event: PlayerEvent.Clone) {
			val it = event.entity.matteryPlayer ?: return
			var original = event.original.matteryPlayer

			if (original == null) {
				event.original.reviveCaps()
				original = event.original.matteryPlayer
			}

			if (original == null) {
				event.original.invalidateCaps()
				return
			}

			if (original.willBecomeAndroid && event.isWasDeath) {
				original.becomeAndroid()

				val ply = event.entity

				if (ply is ServerPlayer) {
					ply.displayClientMessage(TranslatableComponent("otm.pill.message_finish").withStyle(ChatFormatting.DARK_RED), false)
					BecomeAndroidDeathTrigger.trigger(ply)
				}
			}

			it.deserializeNBT(original.serializeNBT())
			it.invalidateNetworkState()
			event.original.invalidateCaps()

			if (SERVER_IS_LIVE) {
				it.tickList.timer(20) {
					it.recreateExoPackMenu()
				}
			}
		}

		const val SLEEP_TICKS_LIMIT = 80

		private val LOGGER = LogManager.getLogger()

		/**
		 * hooked into PhantomSpawner through coremod script
		 */
		@JvmStatic
		fun phantomSpawnHook(iterator: Iterator<Player>): Iterator<Player> {
			return iterator.filter { it.matteryPlayer?.isAndroid != true }
		}

		/**
		 * hooked into LivingEntity through coremod script
		 */
		@JvmStatic
		fun addEatEffectHook(iterator: Iterator<com.mojang.datafixers.util.Pair<MobEffectInstance, Float>>, entity: LivingEntity): Iterator<com.mojang.datafixers.util.Pair<MobEffectInstance, Float>> {
			if (entity !is Player) {
				return iterator
			}

			val mattery = entity.matteryPlayer ?: return iterator

			if (mattery.isAndroid) {
				return iterator.filter {
					it.first.effect != MobEffects.HUNGER
				}
			}

			return iterator
		}

		@JvmStatic
		fun inventoryDropAll(inventory: Inventory) {
			val mattery = inventory.player.matteryPlayer ?: return

			if (!mattery.hasExoPack) {
				return
			}

			inventory.player.dropContainer(mattery.exoPackContainer)
			inventory.player.dropContainer(mattery.exoPackChargeSlots)
			inventory.player.dropContainer(mattery.exoPackEnergy.parent)

			for (smelter in mattery.smelters) {
				inventory.player.dropContainer(smelter.input)
				inventory.player.dropContainer(smelter.output)
				smelter.currentJob = null
			}
		}

		@JvmStatic
		fun inventoryClearContent(inventory: Inventory) {
			val mattery = inventory.player.matteryPlayer ?: return

			if (!mattery.hasExoPack) {
				return
			}

			mattery.exoPackContainer.clearContent()
			mattery.exoPackChargeSlots.clearContent()
			mattery.exoPackEnergy.parent.clearContent()

			for (smelter in mattery.smelters) {
				smelter.input.clearContent()
				smelter.output.clearContent()
				smelter.currentJob = null
			}
		}

		@JvmStatic
		fun playerDestroyVanishingCursedItems(player: Player) {
			player.matteryPlayer?.let {
				if (it.hasExoPack) {
					it.exoPackContainer.vanishCursedItems()
					it.exoPackChargeSlots.vanishCursedItems()
					it.exoPackEnergy.parent.vanishCursedItems()

					for (smelter in it.smelters) {
						smelter.input.vanishCursedItems()
						smelter.output.vanishCursedItems()
					}
				}
			}
		}

		@JvmStatic
		fun pickBlockHook(determinedSlot: Int, itemStack: ItemStack) {
			val player = minecraft.player ?: return

			if (player.abilities.instabuild || determinedSlot != -1 || itemStack.isEmpty) {
				return
			}

			val matteryPlayer = player.matteryPlayer ?: return

			if (!matteryPlayer.hasExoPack) {
				return
			}

			val targetSlot = player.inventory.suitableHotbarSlot
			val itemSlot = matteryPlayer.exoPackContainer.indexOfFirst { !it.isEmpty && ItemStack.isSameItemSameTags(itemStack, it) }

			if (itemSlot == -1) {
				return
			}

			MatteryPlayerNetworkChannel.sendToServer(PickItemFromInventoryPacket(targetSlot, itemSlot))
		}

		fun onStartTracking(event: PlayerEvent.StartTracking) {
			if (event.target is ServerPlayer) {
				event.target.matteryPlayer?.let {
					it.trackingPlayers[event.entity as ServerPlayer] = it.publicSynchronizer.Endpoint()
				}
			}
		}

		fun onStopTracking(event: PlayerEvent.StopTracking) {
			if (event.target is ServerPlayer) {
				event.target.matteryPlayer?.trackingPlayers?.remove(event.entity as ServerPlayer)
			}
		}

		@JvmStatic
		fun getProjectileHook(player: Player, weaponItem: ItemStack): ItemStack? {
			val weapon = weaponItem.item as? ProjectileWeaponItem ?: return null
			val matteryPlayer = player.matteryPlayer ?: return null

			if (!matteryPlayer.hasExoPack) {
				return null
			}

			val item = matteryPlayer.exoPackContainer.stream().filter(weapon.allSupportedProjectiles).findFirst().orElse(null) ?: return null
			return ForgeHooks.getProjectile(player, weaponItem, item)
		}
	}
}
