package ru.dbotthepony.mc.otm.capability

import com.mojang.blaze3d.systems.RenderSystem
import com.mojang.blaze3d.vertex.PoseStack
import it.unimi.dsi.fastutil.ints.IntAVLTreeSet
import it.unimi.dsi.fastutil.ints.IntSet
import it.unimi.dsi.fastutil.objects.Object2ObjectArrayMap
import net.minecraft.ChatFormatting
import net.minecraft.client.model.PlayerModel
import net.minecraft.client.player.AbstractClientPlayer
import net.minecraft.core.HolderLookup
import net.minecraft.core.registries.BuiltInRegistries
import net.minecraft.nbt.CompoundTag
import net.minecraft.nbt.ListTag
import net.minecraft.nbt.StringTag
import net.minecraft.network.chat.Component
import net.minecraft.network.protocol.common.custom.CustomPacketPayload
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.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.Item
import net.minecraft.world.item.ItemStack
import net.minecraft.world.item.ProjectileWeaponItem
import net.minecraft.world.item.crafting.RecipeManager
import net.minecraft.world.item.crafting.RecipeType
import net.minecraft.world.item.crafting.SingleRecipeInput
import net.minecraft.world.level.GameRules
import net.minecraft.world.level.Level
import net.minecraft.world.phys.Vec3
import net.neoforged.bus.api.Event
import net.neoforged.bus.api.ICancellableEvent
import net.neoforged.neoforge.common.CommonHooks
import net.neoforged.neoforge.common.NeoForge
import net.neoforged.neoforge.event.entity.living.LivingDeathEvent
import net.neoforged.neoforge.event.entity.living.LivingIncomingDamageEvent
import net.neoforged.neoforge.event.entity.living.MobEffectEvent
import net.neoforged.neoforge.event.entity.player.PlayerEvent
import net.neoforged.neoforge.event.entity.player.PlayerSpawnPhantomsEvent
import net.neoforged.neoforge.event.tick.PlayerTickEvent
import net.neoforged.neoforge.network.PacketDistributor
import org.apache.logging.log4j.LogManager
import org.joml.Vector4f
import ru.dbotthepony.kommons.collect.ListenableMap
import ru.dbotthepony.kommons.collect.ListenableSet
import ru.dbotthepony.mc.otm.network.syncher.SynchableGroup
import ru.dbotthepony.kommons.util.ListenableDelegate
import ru.dbotthepony.kommons.util.getValue
import ru.dbotthepony.kommons.util.setValue
import ru.dbotthepony.kommons.util.value
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.ItemJob
import ru.dbotthepony.mc.otm.block.entity.JobContainer
import ru.dbotthepony.mc.otm.block.entity.JobStatus
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.CombinedContainer
import ru.dbotthepony.mc.otm.container.DynamicallyProxiedContainer
import ru.dbotthepony.mc.otm.container.IContainer
import ru.dbotthepony.mc.otm.container.IMatteryContainer
import ru.dbotthepony.mc.otm.container.MatteryContainer
import ru.dbotthepony.mc.otm.container.get
import ru.dbotthepony.mc.otm.container.util.slotIterator
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.RGBColorDFUCodec
import ru.dbotthepony.mc.otm.core.math.minus
import ru.dbotthepony.mc.otm.core.nbt.getCompoundList
import ru.dbotthepony.mc.otm.core.nbt.getStringList
import ru.dbotthepony.mc.otm.core.nbt.set
import ru.dbotthepony.mc.otm.core.util.Savetables
import ru.dbotthepony.mc.otm.core.util.TickList
import ru.dbotthepony.mc.otm.menu.ExopackInventoryMenu
import ru.dbotthepony.mc.otm.menu.IItemStackSortingSettings
import ru.dbotthepony.mc.otm.network.*
import ru.dbotthepony.mc.otm.network.SmokeParticlesPacket.Companion.makeSmoke
import ru.dbotthepony.mc.otm.registry.game.AndroidFeatures
import ru.dbotthepony.mc.otm.registry.MDamageTypes
import ru.dbotthepony.mc.otm.registry.game.MItems
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.ExopackGainedEnderAccessTrigger
import ru.dbotthepony.mc.otm.triggers.ExopackGainedSmeltingTrigger
import ru.dbotthepony.mc.otm.triggers.ExopackObtainedTrigger
import ru.dbotthepony.mc.otm.triggers.ExopackSlotsExpandedTrigger
import ru.dbotthepony.mc.otm.triggers.MatteryInventoryChangeTrigger
import java.io.Closeable
import java.util.*
import java.util.stream.Stream
import kotlin.collections.ArrayDeque
import kotlin.reflect.KMutableProperty1

private fun Player.dropContainer(container: Container, spill: Boolean = true, setThrower: Boolean = false) {
	for (slot in container.slotIterator()) {
		drop(slot.item, spill, setThrower)
		slot.remove()
	}
}

@Suppress("unused")
class MatteryPlayer(val ply: Player) {
	/**
	 * 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.
	 */
	data class PreTick(val capability: MatteryPlayer) : Event(), ICancellableEvent {
		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: MatteryPlayer) : 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 [MatteryPlayer] exopack due to inventory filters or no free space).
	 */
	data class ItemStackLeftoverEvent(val stack: ItemStack, val capability: MatteryPlayer) : 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) {
			if (ply is ServerPlayer) {
				val item = new.copy()

				tickList.once {
					MatteryInventoryChangeTrigger.trigger(ply, combinedInventory, item)
				}
			}
		}
	}

	/**
	 * 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 syncher = SynchableGroup()

	/**
	 * Remote for "this" player
	 */
	val privateSyncherRemote = syncher.Remote()

	/**
	 * 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 publicSyncher = SynchableGroup()

	val publicSyncherRemote = publicSyncher.Remote()
	val remoteSynchers = Object2ObjectArrayMap<ServerPlayer, SynchableGroup.Remote>()

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

	/**
	 * Whenever player has Exopack
	 */
	var hasExopack by publicSyncher.boolean(setter = setter@{ access, value ->
		access.accept(value)
		_exoPackMenu = null

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

	/**
	 * Whenever to render Exopack on player
	 */
	var isExopackVisible by publicSyncher.boolean(true)

	/**
	 * Whenever to render Exopack glow in dark
	 */
	var exopackGlows by publicSyncher.boolean(true)

	var exopackColor by publicSyncher.add(ListenableDelegate.Box(null), StreamCodecs.RGBA.nullable())

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

	// kotlin moment
	private fun _recomputeModifiers() {
		exopackSlotModifier.recompute()
	}

	private val exopackSlotModifierMap = syncher.map(
		backing = ListenableMap<UUID, Int>().also { it.addListener(Runnable { _recomputeModifiers() }) },
		keyCodec = StreamCodecs.UUID,
		valueCodec = StreamCodecs.VAR_INT,
	)

	/**
	 * 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.delegate)

	val regularSlotFilters = immutableList(Inventory.INVENTORY_SIZE) {
		syncher.add(null, StreamCodecs.ITEM_TYPE.nullable())
	}

	val slotsChargeFlag = syncher.set(
		backing = ListenableSet(IntAVLTreeSet()),
		codec = StreamCodecs.VAR_INT,
	).delegate

	private fun slotChargeToDefault() {
		// броня
		slotsChargeFlag.add(36)
		slotsChargeFlag.add(37)
		slotsChargeFlag.add(38)
		slotsChargeFlag.add(39)
	}

	init {
		slotChargeToDefault()
	}

	private var exopackContainerSynchedFilters: List<Closeable> = listOf()

	/**
	 * Exopack container, which actually store items inside Exopack
	 */
	var exopackContainer: MatteryContainer = PlayerMatteryContainer(0)
		private set(value) {
			_exoPackMenu = null
			exopackContainerSynchedFilters.forEach { it.close() }

			@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(ply.level().registryAccess(), field.serializeNBT(ply.level().registryAccess()))
			exopackContainerSynchedFilters = value.synchableFilters.map { syncher.add0(it) }
			field = value

			_combinedInventory = null
			_combinedInventory2 = null
			_combinedInventory3 = null
		}

	private var _combinedInventory: CombinedContainer? = null
	private var _combinedInventory2: CombinedContainer? = null
	private var _combinedInventory3: CombinedContainer? = null

	val wrappedInventory: IMatteryContainer = object : IMatteryContainer, IContainer by DynamicallyProxiedContainer(ply::getInventory) {
		override fun getSlotFilter(slot: Int): Item? {
			return regularSlotFilters.getOrNull(slot)?.get()
		}

		override fun setSlotFilter(slot: Int, filter: Item?): Boolean {
			regularSlotFilters.getOrNull(slot)?.accept(filter)
			return true
		}

		override fun setChanged(slot: Int) {
			ply.inventory.setChanged()
		}

		override fun clearSlotFilters() {
			regularSlotFilters.forEach { it.accept(null) }
		}
	}

	val wrappedItemInventory: IMatteryContainer = object : IMatteryContainer by wrappedInventory {
		override fun getContainerSize(): Int {
			return 36
		}
	}

	val combinedInventory: CombinedContainer
		get() {
			if (_combinedInventory == null)
				_combinedInventory = CombinedContainer(wrappedInventory, exopackContainer)

			return _combinedInventory!!
		}

	val inventoryAndExopack: CombinedContainer
		get() {
			if (_combinedInventory2 == null)
				_combinedInventory2 = CombinedContainer(wrappedItemInventory, exopackContainer)

			return _combinedInventory2!!
		}

	val inventoryAndExopackNoHotbar: CombinedContainer
		get() {
			if (_combinedInventory3 == null)
				_combinedInventory3 = CombinedContainer.Builder()
					.add(wrappedItemInventory, 9 .. 35)
					.add(exopackContainer).build()

			return _combinedInventory3!!
		}

	/**
	 * Whenever Exopack has 3x3 crafting grid upgrade installed
	 */
	var isExopackCraftingUpgraded by publicSyncher.boolean(setter = setter@{ access, value ->
		if (value != access.get()) {
			access.accept(value)
			_exoPackMenu = null

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

	var isExopackEnderAccessInstalled by publicSyncher.boolean(setter = setter@{ access, value ->
		if (value != access.get()) {
			access.accept(value)
			_exoPackMenu = null

			if (value && ply is ServerPlayer) {
				tickList.once {
					ExopackGainedEnderAccessTrigger.trigger(ply)
				}
			}
		}
	})

	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!!
		}

	val sortingSettings = IItemStackSortingSettings.Impl()
	val enderSortingSettings = IItemStackSortingSettings.Impl()

	fun recreateExoPackMenu() {
		if (_exoPackMenu == null) return
		_exoPackMenu = ExopackInventoryMenu(this)
	}

	private var shouldSendIteration = false

	private var iteration = 0
	private var lastDeathTick = 0
	private val deathLog = ArrayDeque<Pair<Int, Component>>()

	private val featureMap = IdentityHashMap<AndroidFeatureType<*>, AndroidFeature>()
	private val networkQueue = ArrayList<CustomPacketPayload>()
	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

	/**
	 * 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 lastLiquidPosition = Vec3(0.0, 0.0, 0.0)
	private var liquidDistanceTravelled = 0.0
	private var wasInLiquid = false
	private var lastDimension = ResourceLocation("minecraft", "overworld")

	// clientside only flag for render hook
	// TODO: actual code, currently particles spawn just behind player on ExopackSmokePacket
	var spawnExopackSmoke = false

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

	/**
	 * 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 publicSyncher.boolean()

	/**
	 * Whenever player has exosuit smelting upgrade
	 */
	var isExopackSmeltingInstalled by syncher.boolean(setter = { access, value ->
		if (value != access.get()) {
			access.accept(value)
			_exoPackMenu = null

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

	inner class SmelterBelt(index: Int) : MachineJobEventLoop<ItemJob>(ItemJob.CODEC) {
		override val energy: IMatteryEnergyStorage
			get() = exopackEnergy
		override val isBlockedByRedstone: Boolean
			get() = false

		override val upgrades: IMatteryUpgrade?
			get() = null

		override fun onJobFinish(status: JobStatus<ItemJob>) {
			if (output.fullyAddItem(status.job.itemStack)) {
				exopackSmelterExperience += status.job.experience
			} else {
				status.noItem()
			}
		}

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

		override fun computeNextJob(): JobContainer<ItemJob> {
			if (!exopackEnergy.batteryLevel.isPositive) return JobContainer.noEnergy()
			val level = ply.level() as? ServerLevel ?: return JobContainer.failure()
			val recipe = cache.getRecipeFor(SingleRecipeInput(input[0]), level)

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

		override fun onJobTick(status: JobStatus<ItemJob>) {
			super.onJobTick(status)

			if (isExopackVisible && ply.level().otmRandom.nextFloat() <= 0.05f) {
				PacketDistributor.sendToPlayersTrackingEntityAndSelf(ply, ExopackSmokePacket(ply.uuid))
			}
		}

		val input = MatteryContainer(Runnable { notify(IdleReason.ITEM) }, 1)
		val output = MatteryContainer(Runnable { 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, syncher, AndroidConfig.ANDROID_MAX_ENERGY, AndroidConfig.ANDROID_MAX_ENERGY, true)

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

	val exopackChargeSlots = MatteryContainer(4)
	var acceptExopackChargeFromWirelessCharger = true

	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(::isExopackVisible, "displayExoSuit")
		savetables.bool(::isExopackCraftingUpgraded, "isExoSuitCraftingUpgraded")
		savetables.bool(::isExopackEnderAccessInstalled, "isExopackEnderAccessUpgraded")
		savetables.bool(::acceptExopackChargeFromWirelessCharger)
		savetables.int(::nextDischargeHurt)
		savetables.int(::nextHealTick)

		savetables.vector(::lastLiquidPosition)
		savetables.codec(::lastDimension, ResourceLocation.CODEC)
		savetables.double(::liquidDistanceTravelled)

		savetables.stateful(::exopackSlotModifier, "exoSuitSlotCountModifiers")
		savetables.stateful(::exopackContainer, "exoSuitContainer")
		savetables.stateful(::androidEnergy)
		savetables.stateful(::exopackEnergy, "exoPackEnergy")
		savetables.stateful(::exopackChargeSlots, "exoPackChargeSlots")
		savetables.float(::exopackSmelterExperience, "exoPackSmelterExperience")
		savetables.bool(::isExopackSmeltingInstalled, "isExoPackSmeltingInstalled")

		savetables.codecNullable(::exopackColor, RGBColorDFUCodec)
		savetables.bool(::exopackGlows)

		savetables.stateful(::sortingSettings)
		savetables.stateful(::enderSortingSettings)
	}

	fun invalidateNetworkState() {
		privateSyncherRemote.invalidate()
		publicSyncherRemote.invalidate()
		remoteSynchers.values.forEach { it.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

		lastLiquidPosition = ply.position()
		wasInLiquid = false

		if (ply is ServerPlayer) {
			tickList.once {
				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()

		lastLiquidPosition = ply.position()
		wasInLiquid = false

		if (ply is ServerPlayer) {
			tickList.once {
				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(registry: HolderLookup.Provider) {
		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(registry, v.serializeNBT(registry))
			}
		}
	}

	/**
	 * 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.syncherRemote.write(ply.registryAccess())

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

		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.syncherRemote.write(ply.registryAccess())

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

		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: LivingIncomingDamageEvent) {
		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)))
			}
		}
	}

	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)
		}
	}

	fun serializeNBT(registry: HolderLookup.Provider): CompoundTag {
		val tag = savetables.serializeNBT(registry)

		// 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, registry))
				})
			}
		}

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

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

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

		tag.putIntArray("slotsChargeFlag", slotsChargeFlag.toIntArray())
		return tag
	}

	fun deserializeNBT(tag: CompoundTag, registry: HolderLookup.Provider) {
		savetables.deserializeNBT(registry, tag)

		if (MItems.ExopackUpgrades.INVENTORY_UPGRADE_ENDER_DRAGON.uuid(ItemStack.EMPTY) in exopackSlotModifierMap.delegate) {
			isExopackEnderAccessInstalled = true
		}

		if (ply is ServerPlayer && hasExopack)
			tickList.once {
				ExopackSlotsExpandedTrigger.trigger(ply, 0, exopackContainer.containerSize)
			}

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

		slotsChargeFlag.clear()

		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 = BuiltInRegistries.ITEM.get(ResourceLocation.tryParse(path) ?: continue)
		}

		if ("slotsChargeFlag" in tag) {
			for (v in tag.getIntArray("slotsChargeFlag")) {
				this.slotsChargeFlag.add(v)
			}
		} else {
			slotChargeToDefault()
		}

		// iterations
		deathLog.clear()

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

			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.get(ResourceLocation.parse(featureTag.getString("id")))

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

				instance.deserializeNBT(registry, featureTag)
				addFeature(instance)

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

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

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

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

		if (isAndroid && ply is ServerPlayer) {
			tickList.once {
				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: CustomPacketPayload) {
		if (ply is ServerPlayer) {
			if (tickedOnce) {
				PacketDistributor.sendToPlayer(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) {
			BuiltInRegistries.MOB_EFFECT.getOrCreateTag(ANDROID_IMMUNE_EFFECTS).forEach {
				if (ply.hasEffect(it))
					ply.removeEffect(it)
			}
		}
	}

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

		if (NeoForge.EVENT_BUS.post(PreTick(this)).isCanceled) return

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

		ticksIExist++

		tickList.tick()

		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 (slot in slotsChargeFlag) {
							if (slot in 0 until combinedInventory.containerSize) {
								val item = combinedInventory[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.isCreative)
					ply.isSwimming = false

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

					if (ply.isUnderWater) {
						if (!wasInLiquid) {
							wasInLiquid = true
							liquidDistanceTravelled = 0.0
							lastLiquidPosition = ply.position
						}

						liquidDistanceTravelled += (ply.position - lastLiquidPosition).length()
					} else {
						if (wasInLiquid) {
							wasInLiquid = false

							if (!hasFeature(AndroidFeatures.AIR_BAGS)) {
								AndroidTravelUnderwater.trigger(ply, liquidDistanceTravelled)
							}
						}

						liquidDistanceTravelled = 0.0
					}

					lastLiquidPosition = 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

		syncher.observe()
		publicSyncher.observe()
		val payload = privateSyncherRemote.write(ply.registryAccess())

		if (payload != null) {
			PacketDistributor.sendToPlayer(ply as ServerPlayer, MatteryPlayerDataPacket(payload, false))
		}

		val trackingIterator = remoteSynchers.entries.iterator()

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

			val payload2 = remote.write(ply.registryAccess())

			if (payload2 != null) {
				PacketDistributor.sendToPlayer(ply as ServerPlayer, MatteryPlayerDataPacket(payload2, true, this.ply.uuid))
			}
		}

		val payload3 = publicSyncherRemote.write(ply.registryAccess())

		if (payload3 != null) {
			PacketDistributor.sendToPlayer(ply as ServerPlayer, MatteryPlayerDataPacket(payload3, true))
		}

		if (networkQueue.size != 0) {
			for (packet in networkQueue) {
				PacketDistributor.sendToPlayer(ply as ServerPlayer, packet)
			}

			networkQueue.clear()
		}

		for (instance in research.values) {
			val researchPayload = instance.syncherRemote.write(ply.registryAccess())

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

		for (instance in featureMap.values) {
			val featurePayload = instance.syncherRemote.write(ply.registryAccess())

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

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

		tickInventory()

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

	/**
	 * This re-implement [Inventory.add] logic (original method is redirected to this)
	 */
	fun inventoryAddImpl(stack: ItemStack): Boolean {
		if (hasExopack) {
			combinedInventory.consumeItem(stack, false, slots = IntSet.of(ply.inventory.selected), onlyIntoExisting = true, popTime = 5)
			combinedInventory.consumeItem(stack, false, slots = offhandSlotRange, onlyIntoExisting = true, popTime = 5)
			inventoryAndExopack.consumeItem(stack, false, onlyIntoExisting = false, popTime = 5)
		} else {
			wrappedInventory.consumeItem(stack, false, slots = IntSet.of(ply.inventory.selected), onlyIntoExisting = true, popTime = 5)
			wrappedInventory.consumeItem(stack, false, slots = offhandSlotRange, onlyIntoExisting = true, popTime = 5)
			wrappedItemInventory.consumeItem(stack, false, onlyIntoExisting = false, popTime = 5)
		}

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

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

		return stack.isEmpty
	}

	fun makeSmokeParticles(poseStack: PoseStack, model: PlayerModel<AbstractClientPlayer>) {
		if (spawnExopackSmoke) {
			spawnExopackSmoke = false

			// TODO: Proper positioning

			val pos = Vector4f(model.body.x / 16f, model.body.y / 16f, model.body.z / 16f, 1f)
			val cam = minecraft.gameRenderer.mainCamera.position

			pos.mul(RenderSystem.getProjectionMatrix())
			pos.mul(poseStack.last().pose())
			makeSmoke(cam.x + pos.x, cam.y + pos.y, cam.z + pos.z, ply.level().otmRandom, ply.level())
		}
	}

	enum class UpgradeType(val prop: KMutableProperty1<MatteryPlayer, Boolean>) {
		CRAFTING(MatteryPlayer::isExopackCraftingUpgraded),
		ENDER_ACCESS(MatteryPlayer::isExopackEnderAccessInstalled),
		SMELTING(MatteryPlayer::isExopackSmeltingInstalled);
	}

	@Suppress("unused")
	companion object {
		private val offhandSlotRange = IntSet.of(40)

		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(BuiltInRegistries.MOB_EFFECT.key(), ResourceLocation(OverdriveThatMatters.MOD_ID, "android_immune_effects"))

		fun onPlayerTickPre(event: PlayerTickEvent.Pre) {
			val ent = event.entity

			if (!ent.level().isClientSide) {
				ent.matteryPlayer.preTick()
			}
		}

		fun onPlayerTickPost(event: PlayerTickEvent.Pre) {
			val ent = event.entity

			if (ent.level().isClientSide) {
				ent.matteryPlayer.tickClient()
			} else {
				ent.matteryPlayer.tick()
			}
		}

		fun isMobEffectApplicable(event: MobEffectEvent.Applicable) {
			event.entity.matteryPlayer?.let {
				if (it.isAndroid && BuiltInRegistries.MOB_EFFECT.getOrCreateTag(ANDROID_IMMUNE_EFFECTS).any { it == event.effectInstance?.effect }) {
					event.result = MobEffectEvent.Applicable.Result.DO_NOT_APPLY
				}
			}
		}

		fun onHurtEvent(event: LivingIncomingDamageEvent) {
			event.entity.matteryPlayer?.onHurt(event)
		}

		fun onPlayerChangeDimensionEvent(event: PlayerEvent.PlayerChangedDimensionEvent) {
			onceServer {
				event.entity.matteryPlayer.invalidateNetworkState()
				event.entity.matteryPlayer.recreateExoPackMenu()
			}
		}

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

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

			if (!ply.matteryPlayer.isAndroid) {
				return
			}

			ply.matteryPlayer.iteration++
			ply.matteryPlayer.shouldSendIteration = true
			ply.matteryPlayer.deathLog.addLast(ply.tickCount to ply.combatTracker.deathMessage)

			if (ply.matteryPlayer.androidEnergy.batteryLevel < AndroidConfig.ANDROID_MAX_ENERGY * Decimal("0.2"))
				ply.matteryPlayer.androidEnergy.batteryLevel = AndroidConfig.ANDROID_MAX_ENERGY * Decimal("0.2") // если смерть была от разряда батареи, то предотвращаем софтлок

			while (ply.matteryPlayer.deathLog.size > 6) {
				ply.matteryPlayer.deathLog.removeFirst()
			}
		}

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

			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.ply.registryAccess()), it.ply.registryAccess())
			it.invalidateNetworkState()

			onceServer {
				event.entity.matteryPlayer.invalidateNetworkState()
				event.entity.matteryPlayer.recreateExoPackMenu()
			}
		}

		const val SLEEP_TICKS_LIMIT = 80

		private val LOGGER = LogManager.getLogger()

		fun onPlayerSpawnPhantoms(event: PlayerSpawnPhantomsEvent) {
			if (event.entity.matteryPlayer.isAndroid) {
				event.result = PlayerSpawnPhantomsEvent.Result.DENY
			}
		}

		/**
		 * 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
			}

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

			return iterator
		}

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

			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

			if (!mattery.hasExopack) {
				return
			}

			mattery.exopackContainer.clearContent()
			mattery.exopackChargeSlots.clearContent()
			mattery.exopackEnergy.parent.clearContent()
			mattery.exopackEnergy.parent.drainBattery()

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

		@JvmStatic
		fun playerDestroyVanishingCursedItems(player: Player) {
			if (player.matteryPlayer.hasExopack) {
				player.matteryPlayer.exopackContainer.vanishCursedItems()
				player.matteryPlayer.exopackChargeSlots.vanishCursedItems()
				// it.exoPackEnergy.parent.vanishCursedItems()

				for (smelter in player.matteryPlayer.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

			if (!matteryPlayer.hasExopack) {
				return
			}

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

			if (itemSlot == -1) {
				return
			}

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

		fun onStartTracking(event: PlayerEvent.StartTracking) {
			if (event.target is ServerPlayer) {
				(event.target as ServerPlayer).matteryPlayer.let {
					it.remoteSynchers[event.entity as ServerPlayer] = it.publicSyncher.Remote()
				}
			}
		}

		fun onStopTracking(event: PlayerEvent.StopTracking) {
			if (event.target is ServerPlayer) {
				(event.target as ServerPlayer).matteryPlayer.remoteSynchers.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

			if (!matteryPlayer.hasExopack) {
				return null
			}

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