// @ts-check
import {
	BlockVolume,
	Entity,
	EntityComponentTypes,
	ItemStack,
	Player,
	system,
	world,
} from "@minecraft/server";
import { getRotationPointingAt } from "../../utilities/helpers";
import { UndeadGolemManager } from "./undead_golem_manager";
import { GolemState } from "./enums";
import { StalkedPlayerManager } from "./stalked_player_manager";
import { ANGER_PROBABILITIES } from "./config.js";

/** Doors that can be broken by undead villagers. */
const breakableDoors = [
	"minecraft:acacia_door",
	"minecraft:bamboo_door",
	"minecraft:birch_door",
	"minecraft:cherry_door",
	"minecraft:copper_door",
	"minecraft:crimson_door",
	"minecraft:dark_oak_door",
	"minecraft:exposed_copper_door",
	// "minecraft:iron_door",
	"minecraft:jungle_door",
	"minecraft:mangrove_door",
	"minecraft:oxidized_copper_door",
	"minecraft:pale_oak_door",
	"minecraft:spruce_door",
	"minecraft:warped_door",
	"minecraft:waxed_copper_door",
	"minecraft:waxed_exposed_copper_door",
	"minecraft:waxed_oxidized_copper_door",
	"minecraft:waxed_weathered_copper_door",
	"minecraft:weathered_copper_door",
	"minecraft:wooden_door",
];

/**
 * @class UndeadVillager
 */
export class UndeadVillager {
	/**
	 *  The chance of triggering the drop item behavior reevaluated every tick(). There
	 * are other conditions in addition to this one.
	 * @readonly
	 */
	static DROP_ITEM_CHANCE = 0.15;

	/**
	 * The chance that an undead golem will spawn when a villager is killed by a player.
	 * @readonly
	 */
	static SPAWN_GOLEM_ON_DEATH_CHANCE = 0.25;

	/**
	 * @readonly
	 */
	static GIFT_ITEM_TABLE = {
		"minecraft:rotten_flesh": {
			dropWeight: 20,
			angryChance: 0.2,
		},
		"minecraft:bone": {
			dropWeight: 10,
			angryChance: 0.3,
		},
		"minecraft:diamond": {
			dropWeight: 1,
			angryChance: 0.95,
		},
		"minecraft:emerald": {
			dropWeight: 1,
			angryChance: 0.9,
		},
		"minecraft:gold_ingot": {
			dropWeight: 2,
			angryChance: 0.8,
		},
		"minecraft:iron_ingot": {
			dropWeight: 3,
			angryChance: 0.75,
		},
		"minecraft:apple": {
			dropWeight: 5,
			angryChance: 0.5,
		},
	};

	/**
	 * @private
	 * @static
	 * @type {Map<string, UndeadVillager>}
	 */
	static entityRegistry = new Map();

	/**
	 * The undead_villager entity.
	 * @type {Entity}
	 */
	entity;

	/**
	 * The item dropped by the undead villager for the player.
	 * @type {Entity | undefined}
	 */
	giftingItem;

	/**
	 * @type {string}
	 */
	recentlyDroppedItemType;

	/**
	 * The handle to the job of breaking doors.
	 * @type {number | undefined}
	 */
	breakDoorsJobId;

	/**
	 * Creates UndeadVillager instance and registers it to the entityRegistry map.
	 * @param {Entity} entity
	 */
	constructor(entity) {
		this.entity = entity;
		this.recentlyDroppedItemType = "minecraft:diamond";
		UndeadVillager.entityRegistry.set(entity.id, this);
	}

	/**
	 * Registers all handlers needed by undead_villager. This function should run only
	 * once.
	 */
	static register() {
		// Deregistration to 'entityRegistry' map
		world.afterEvents.entityRemove.subscribe((event) => {
			const entityId = event.removedEntityId;
			const undeadVillager = UndeadVillager.entityRegistry.get(entityId);
			if (undeadVillager === undefined) {
				return; // Nothing to do
			}
			if (undeadVillager.breakDoorsJobId !== undefined) {
				system.clearJob(undeadVillager.breakDoorsJobId);
				undeadVillager.breakDoorsJobId = undefined;
			}
			UndeadVillager.entityRegistry.delete(entityId);
		});

		// Handle villager death and possibly spawn a golem
		world.afterEvents.entityDie.subscribe(
			(event) => {
				const deadEntity = event.deadEntity;
				if (deadEntity.typeId !== "hb_hr:undead_villager") {
					return;
				}

				// Check if killed by a player
				const damagingEntity = event.damageSource.damagingEntity;
				if (damagingEntity?.typeId !== "minecraft:player") {
					return;
				}
				const player = /** @type {Player} */ (damagingEntity);

				// Random chance to spawn a golem
				if (Math.random() > UndeadVillager.SPAWN_GOLEM_ON_DEATH_CHANCE) {
					return;
				}
				const { dimension, location } = deadEntity;

				// We will hide the message informing the player about being stalked by
				// the golem if they were already being stalked by one.
				const playersStalkingData = StalkedPlayerManager.getStalkedPlayer(
					player.id
				);

				if (playersStalkingData !== undefined) {
					// If the player was already being stalked by the golem we heal it
					// to full health and increase the anger level based on configuration.
					if (Math.random() < ANGER_PROBABILITIES.VILLAGER_KILLED) {
						playersStalkingData.angerLevel++;
					}
					playersStalkingData.health = -1; // We use -1 as a max health flag
				}

				// Handle existing golems, despawn them, if we find the golem of THIS
				// player we just heal it to full health.
				const existingGolemEntities = dimension.getEntities({
					type: "hb_hr:undead_golem",
				});
				let golemAlreadyExists = false;
				for (const currGolemEntity of existingGolemEntities) {
					const currGolem = UndeadGolemManager.getFromRegistry(
						currGolemEntity.id
					);
					if (currGolem === undefined || !currGolem.entity.isValid()) {
						// Skip non tracking or invalid golems
						continue;
					}
					const currGolemStalkedPlayerId = currGolem.getStalkedPlayerId();

					if (currGolemStalkedPlayerId === player.id) {
						// We accidentally found the golem that was stalking the player.
						// Heal this golem instance to full health.
						golemAlreadyExists = true;
						const healthComponent = currGolem.entity.getComponent(
							EntityComponentTypes.Health
						);
						if (healthComponent !== undefined) {
							healthComponent.setCurrentValue(healthComponent.effectiveMax);
						}
					} else if (currGolemStalkedPlayerId !== undefined) {
						// All other golems that track someone should despawn
						currGolem.emergencyTeleport(true);
					}
				}

				// Skip if the golem already exists
				if (golemAlreadyExists) {
					return;
				}

				// Spawn a golem targeting the player
				const spawnLocation = UndeadGolemManager.getExactSpawnLocation(
					dimension,
					location,
					3
				);
				if (spawnLocation === undefined) {
					return;
				}

				// If player is known, do some other action than just summoning
				if (playersStalkingData !== undefined) {
					if (playersStalkingData.canKillPlayer()) {
						// If angry enough, kill the player
						UndeadGolemManager.spawnAndKillPlayer(player, spawnLocation);
					} else {
						// Otherwise attack once (even if canAttackPlayerOnce can be false)
						UndeadGolemManager.spawnAndAttackPlayerOnce(player, spawnLocation);
					}
					return;
				}

				// No cool actions available for the golem. We just summon it with the
				// MARK_TARGET state.
				dimension.runCommand("tag @e remove hb_hr_undead_golem_target");
				player.addTag("hb_hr_undead_golem_target");
				UndeadGolemManager.summon(
					/** @type {Player} */ (damagingEntity),
					dimension,
					spawnLocation,
					GolemState.MARK_TARGET_STATE
				);
			},
			{ entityTypes: ["hb_hr:undead_villager"] }
		);

		// Handle tickinng, villagers tick on their own using events. That means that
		// they only tick when their behavior is active. Ticking is also used for
		// registration.
		system.afterEvents.scriptEventReceive.subscribe(
			(event) => {
				if (event.id !== "hb_hr:undead_villager_tick") {
					return;
				}
				const undeadVillagerEntity = event.sourceEntity;
				if (undeadVillagerEntity?.typeId !== "hb_hr:undead_villager") {
					return;
				}
				// Get the entity or register a new one if it doesn't exist.
				const undeadVillager =
					UndeadVillager.entityRegistry.get(undeadVillagerEntity.id) ??
					new UndeadVillager(undeadVillagerEntity);
				undeadVillager.tick();
			},
			{
				namespaces: ["hb_hr"],
			}
		);
		// Handle stopping gifting
		system.afterEvents.scriptEventReceive.subscribe((event) => {
			if (event.id !== "hb_hr:undead_villager_stop_gifting") {
				return;
			}
			const undeadVillagerEntity = event.sourceEntity;
			if (undeadVillagerEntity?.typeId !== "hb_hr:undead_villager") {
				return;
			}
			const undeadVillager = UndeadVillager.entityRegistry.get(
				undeadVillagerEntity.id
			);
			if (undeadVillager === undefined) {
				return;
			}
			undeadVillager.removeGiftingItem();
		});
	}

	isAngry() {
		const annoyanceLevel = /** @type {number} */ (
			this.entity.getProperty("hb_hr:annoyance_level")
		);
		return annoyanceLevel >= 5;
	}

	/**
	 * Ticks the undead villager.
	 */
	tick() {
		// Angry villager breaks doors and don't have any other behaviors
		if (this.isAngry()) {
			this.breakDoors();
			return;
		}

		// When the villager is in gifting state they wait for something to
		// destroy/pick up the item to get angry about it.
		if (
			this.isGifting() &&
			(this.giftingItem === undefined || this.giftingItem.isValid() === false)
		) {
			this.tryGetAngryAboutLostItem();
			return;
		}

		// If no special behaviors are active the villager can start gifting item.
		if (
			Math.random() < UndeadVillager.DROP_ITEM_CHANCE &&
			// Daytime
			world.getTimeOfDay() < 13000 &&
			// The cooldown is used to prevent from dropping too many items. The
			// cooldown is only set when the item is lost.
			this.entity.getProperty("hb_hr:item_regeneration_cooldown") === 0 &&
			// Must be in the 'not_interacting' state
			this.isStateNotInteracting()
		) {
			this.tryGiftingItem();
		}
	}

	/**
	 * Checks the probability of getting angry after losing the recently dropped item and
	 * optionally gets angry. If the villager decides not to get angry they stop gifting.
	 */
	tryGetAngryAboutLostItem() {
		const angryChance =
			UndeadVillager.GIFT_ITEM_TABLE[this.recentlyDroppedItemType].angryChance;
		const randomValue = Math.random();
		if (randomValue < angryChance) {
			this.entity.triggerEvent("hb_hr:angry");
			this.entity.setProperty(
				"hb_hr:item_regeneration_cooldown",
				Math.floor(Math.random() * 200 + 200) // on average 300s (5 min)
			);
			this.giftingItem = undefined;
		} else {
			this.entity.triggerEvent("hb_hr:stop_gifting");
		}
	}

	/**
	 * Checks if the undead villager is in the 'not_interacting' state.
	 */
	isStateNotInteracting() {
		return this.entity.getComponent(EntityComponentTypes.Variant)?.value === 1;
	}

	/**
	 * Returns true if the undead villager is currently gifting an item to a player.
	 * @returns {boolean}
	 */
	isGifting() {
		return this.entity.getProperty("hb_hr:gifting_trigger") === true;
	}

	removeGiftingItem() {
		if (this.giftingItem === undefined) {
			return;
		}
		try {
			this.giftingItem.teleport(this.entity.location);
		} catch {}
		const giftingItem = this.giftingItem;
		system.runTimeout(() => {
			// If the item somehow gets removed in that small time frame the villager
			// also gets angry
			if (giftingItem === undefined) {
				try {
					this.tryGetAngryAboutLostItem();
				} catch {}
			}
			// Try to kill the item
			try {
				giftingItem.kill();
			} catch {}
		}, 5);
		this.giftingItem = undefined;
	}

	selectItemToDrop() {
		const totalWeight = Object.values(UndeadVillager.GIFT_ITEM_TABLE).reduce(
			(sum, item) => sum + item.dropWeight,
			0
		);
		const randomValue = Math.random() * totalWeight;
		let cumulativeWeight = 0;
		for (const [itemType, item] of Object.entries(
			UndeadVillager.GIFT_ITEM_TABLE
		)) {
			cumulativeWeight += item.dropWeight;
			if (randomValue <= cumulativeWeight) {
				return itemType;
			}
		}
		return "minecraft:rotten_flesh";
	}

	/**
	 * Initializes the behavior of dropping an item to a nearby player.
	 */
	tryGiftingItem() {
		if (this.giftingItem !== undefined) {
			return; // The villager is already sharing an item.
		}
		const { location, dimension } = this.entity;
		if (dimension.getPlayers({ location, maxDistance: 5 }).length > 0) {
			// We don't want to thorw the item directly at players feet because then
			// they'll instantly pick it up making the villager angry, so if there is
			// a player too close we don't throw the item.
			return;
		}
		const players = dimension.getPlayers({ location, maxDistance: 20 }).sort(
			// Sort by nearest player first
			(a, b) =>
				Math.hypot(a.location.x - location.x, a.location.z - location.z) -
				Math.hypot(b.location.x - location.x, b.location.z - location.z)
		);
		if (players.length == 0) {
			return;
		}
		// Look for a player that looks in the general direction of the undead
		// villager
		const targetPlayer = this.getPlayerLookingAtVillager(players);
		if (targetPlayer === undefined) {
			return;
		}
		// Get the required rotation of the undead villager towards the player
		const requiredRotation = getRotationPointingAt(
			this.entity.location,
			targetPlayer.location
		);
		this.setRotationVariable(requiredRotation);
		const itemType = this.selectItemToDrop();
		const item = this.dropItem(itemType, targetPlayer.location);
		this.recentlyDroppedItemType = itemType;
		this.giftingItem = item;
		this.entity.triggerEvent("hb_hr:start_gifting");
	}

	/**
	 * Breaks all doors around the undead villager.
	 */
	breakDoors() {
		if (this.breakDoorsJobId !== undefined) {
			// Only one job can run at a time per undead villager.
			return;
		}
		const { dimension, location } = this.entity;
		const viewDirection = this.entity.getViewDirection();
		const volume = new BlockVolume(
			{
				x: location.x - 2 + viewDirection.x,
				y: location.y + viewDirection.y,
				z: location.z - 2 + viewDirection.z,
			},
			{
				x: location.x + 2 + viewDirection.x,
				y: location.y + 1 + viewDirection.y,
				z: location.z + 2 + viewDirection.z,
			}
		);
		// Create a job of breaking doors around the undead villager
		this.breakDoorsJobId = system.runJob(
			this.breakDoorsJob.bind(this)(volume, dimension)
		);
	}

	/**
	 * Drops in the target direction
	 * @param {string} itemType
	 * @param {import("@minecraft/server").Vector3} target
	 */
	dropItem(itemType, target) {
		const { location, dimension } = this.entity;
		const direction = {
			x: target.x - location.x,
			z: target.z - location.z,
		};
		const flatLength = Math.hypot(direction.x, direction.z);
		const flatDirection =
			flatLength > 0.01
				? {
						x: direction.x / flatLength,
						z: direction.z / flatLength,
				  }
				: {
						x: 1,
						z: 0,
				  };
		const item = dimension.spawnItem(new ItemStack(itemType), {
			x: location.x,
			y: location.y + 1.2,
			z: location.z,
		});
		item.applyImpulse({
			x: flatDirection.x * 0.15,
			y: 0.05,
			z: flatDirection.z * 0.15,
		});
		return item;
	}

	/**
	 * Sets the value of the rotation variable, which is sometimes used in some
	 * of the villagers' animations when the visual rotation needs to be controlled by
	 * a script.
	 * @param {number} rotation
	 */
	setRotationVariable(rotation) {
		this.entity.setProperty("hb_hr:rotation", rotation);
	}

	/**
	 * Returns the player that is looking at the undead villager.
	 * @param {Entity[]} players
	 * @returns {Entity | undefined}
	 */
	getPlayerLookingAtVillager(players) {
		const { location } = this.entity;
		for (const player of players) {
			const playerViewDirection = player.getViewDirection();
			let playerToVillagerVector = {
				x: location.x - player.location.x,
				y: location.y - player.location.y,
				z: location.z - player.location.z,
			};
			const playerToVillagerLength = Math.hypot(
				playerToVillagerVector.x,
				playerToVillagerVector.y,
				playerToVillagerVector.z
			);
			if (playerToVillagerLength < 0.01) {
				continue; // Avoid numerical errors
			}
			playerToVillagerVector = {
				x: playerToVillagerVector.x / playerToVillagerLength,
				y: playerToVillagerVector.y / playerToVillagerLength,
				z: playerToVillagerVector.z / playerToVillagerLength,
			};
			const dotProduct =
				playerToVillagerVector.x * playerViewDirection.x +
				playerToVillagerVector.y * playerViewDirection.y +
				playerToVillagerVector.z * playerViewDirection.z;
			// dotProduct is cosine of the angle between normalized vectors.
			// 0.5 is 60 degrees
			if (dotProduct > 0.5) {
				return player;
			}
		}
		return undefined;
	}

	/**
	 * Generator method that breaks doors in the given volume
	 * @param {import("@minecraft/server").BlockVolume} volume
	 * @param {import("@minecraft/server").Dimension} dimension
	 */
	*breakDoorsJob(volume, dimension) {
		try {
			for (const blockLocation of volume.getBlockLocationIterator()) {
				// Try to get the block.If we have one error we don't care about the rest.
				let block;
				try {
					block = dimension.getBlock(blockLocation);
					if (block === undefined) {
						return;
					}
				} catch {
					return;
				}
				const isCustomDoor = block.hasTag("hb_hr:is_door");
				if (isCustomDoor || breakableDoors.includes(block.typeId)) {
					const { x, y, z } = block.location;
					try {
						// Using command because the API doesn't provide a way to
						// break blocks with the 'destroy' option.
						dimension.runCommand(`setblock ${x} ${y} ${z} air[] destroy`);
						if (isCustomDoor) {
							// Custom doors don't break their linked parts
							const isTopBit = block.permutation.getState(
								// @ts-ignore
								"hb_hr:upper_block_bit"
							);
							if (isTopBit) {
								dimension.runCommand(
									`setblock ${x} ${y - 1} ${z} air[] destroy`
								);
							} else {
								dimension.runCommand(
									`setblock ${x} ${y + 1} ${z} air[] destroy`
								);
							}
						}
					} catch {}
				}
				yield;
			}
		} finally {
			this.breakDoorsJobId = undefined;
		}
	}

	/**
	 * Starts breaking doors in the given volume
	 * @param {import("@minecraft/server").BlockVolume} volume
	 * @param {import("@minecraft/server").Dimension} dimension
	 */
	startBreakingDoors(volume, dimension) {
		this.breakDoorsJobId = system.runJob(
			this.breakDoorsJob.bind(this)(volume, dimension)
		);
	}
}

UndeadVillager.register();
