// @ts-check

import {
	Dimension,
	Entity,
	EntityComponentTypes,
	Player,
	world,
} from "@minecraft/server";
import { StalkedPlayerManager, StalkerData } from "./stalked_player_manager";
import { getRotationPointingAt, shuffle } from "../../utilities/helpers";
import { log } from "./debug";
import { GolemGoal, GolemState } from "./enums";
import { UndeadGolem } from "./undead_golem";

/**
 * Interface imports for JSDoc.
 * @typedef {import("@minecraft/server").Vector3} Vector3
 */

/**
 * Manages the undead golem objects. Provides functions for spawning and keeping track of
 * them.
 */
export class UndeadGolemManager {
	/**
	 * @private
	 * @static
	 * @type {Map<string, UndeadGolem>}
	 */
	static entityRegistry = new Map();

	/**
	 * Blocks that allow the golem to spawn.
	 * @private
	 * @static
	 * @readonly
	 * @type {Set<string>}
	 */
	static SPAWN_SAFE_BLOCKS = new Set([
		"minecraft:air",
		"minecraft:light_block",
		"hb_hr:cursed_grass",
		"hb_hr:cursed_bush",
		"hb_hr:cursed_vine",
	]);
	/**
	 * Block tags that allow the golem to spawn.
	 * @private
	 * @static
	 * @readonly
	 * @type {Set<string>}
	 */
	static SPAWN_SAFE_BLOCK_TAGS = new Set([
		// Yes, some of them have 'minecraft' namespace and some don't
		"minecraft:crop",
		"plant",
		"rail",
		"text_sign",
	]);

	// SPAWNING
	/**
	 * Tries to spawn a golem and do mean things to the player based on their anger
	 * level.
	 * @param {Player} player
	 * @returns {boolean} success status
	 */
	static spawnAndDoDeviousPlans(player) {
		const stalkedPlayerData = StalkedPlayerManager.getStalkedPlayer(player.id);
		if (stalkedPlayerData === undefined) {
			return false;
		}
		const deviousPlans = [];
		if (stalkedPlayerData.canBeCreepy()) {
			// Can be creepy
			deviousPlans.push(UndeadGolemManager.spawnAndBeCreepy);
		}
		if (stalkedPlayerData.canAttackPets()) {
			// Can attack pets
			deviousPlans.push(UndeadGolemManager.spawnAndAttackPets);
		}
		if (stalkedPlayerData.canAttackPlayerOnce()) {
			// Can attack player once
			deviousPlans.push(UndeadGolemManager.spawnAndAttackPlayerOnce);
		}
		if (stalkedPlayerData.canKillPlayer()) {
			// Can kill player
			deviousPlans.push(UndeadGolemManager.spawnAndKillPlayer);
		}

		log(
			`Golem considering ${deviousPlans.length} devious actions at anger level ${stalkedPlayerData.angerLevel}`
		);

		// Select a random actoion, if it fails try another one
		shuffle(deviousPlans);
		for (const plan of deviousPlans) {
			if (plan(player)) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Spawns a golem that will stare at the player from the distance, and despawn when
	 * the player gets too close. Returns success status.
	 * @param {Player} player
	 * @param {Vector3} [spawnLocation]
	 */
	static spawnAndBeCreepy(player, spawnLocation = undefined) {
		log(`Trying to spawn a creepy golem near ${player.name}`);
		if (spawnLocation === undefined) {
			spawnLocation = UndeadGolemManager.getSpawnLocationNearPlayer({
				player,
				minDistance: 15,
				maxDistance: 20,
				yOffset: 3,
				attempts: 3,
				wiggleRoom: 2,
			});
		}
		if (spawnLocation === undefined) {
			return false;
		}
		// Make sure only target is tagged for attacking
		player.dimension.runCommand("tag @e remove hb_hr_undead_golem_target");
		player.addTag("hb_hr_undead_golem_target");

		const golem = UndeadGolemManager.summon(
			player,
			player.dimension,
			spawnLocation,
			GolemState.CREEPY_STATE
		);
		if (golem === undefined) {
			return false;
		}
		log(`Spawned golem will use CREEPY STARE`);
		return true;
	}

	/**
	 * Spawns a golem that will attack the player's pets. Returns success status.
	 * @param {Player} player
	 * @param {Vector3} [spawnLocation]
	 */
	static spawnAndAttackPets(player, spawnLocation = undefined) {
		log(`Trying to spawn a golem to attack ${player.name}'s pets`);
		// Find a pet to target
		const pet = UndeadGolemManager.findPlayersPet(player);
		if (pet === undefined) {
			return false;
		}

		// Spawn near the pet
		if (spawnLocation === undefined) {
			spawnLocation = UndeadGolemManager.getExactSpawnLocation(
				pet.dimension,
				pet.location,
				2
			);
		}
		if (spawnLocation === undefined) {
			return false;
		}

		// Make sure only target is tagged for attacking
		pet.dimension.runCommand("tag @e remove hb_hr_undead_golem_target");
		pet.addTag("hb_hr_undead_golem_target");

		// Summon the golem with the goal to kill the pet
		const golem = UndeadGolemManager.summon(
			player,
			pet.dimension,
			spawnLocation,
			GolemState.DEFAULT_SPAWNING_STATE
		);
		if (golem === undefined) {
			return false;
		}
		golem.setGoal(GolemGoal.KILL_TARGET);
		log(`Spawned golem will try to KILL PETS`);
		return true;
	}

	/**
	 * Private helper function to spawn a golem that will do nasty things to a player.
	 * @private
	 * @param {Player} player The player to target
	 * @param {GolemGoal} goal The goal for the golem (HIT_TARGET or KILL_TARGET)
	 * @param {Vector3} [spawnLocation]
	 * @returns {boolean} Success status
	 */
	static _spawnAndDoNastyThingsToPlayer(
		player,
		goal,
		spawnLocation = undefined
	) {
		if (spawnLocation === undefined) {
			spawnLocation = UndeadGolemManager.getSpawnLocationNearPlayer({
				player,
				minDistance: 7,
				maxDistance: 15,
				yOffset: 2,
				attempts: 3,
				wiggleRoom: 2,
			});
		}
		if (spawnLocation === undefined) {
			return false;
		}

		// Make sure only target is tagged for attacking
		player.dimension.runCommand("tag @e remove hb_hr_undead_golem_target");
		player.addTag("hb_hr_undead_golem_target");

		// Summon the golem with the appropriate goal
		const golem = UndeadGolemManager.summon(
			player,
			player.dimension,
			spawnLocation,
			GolemState.DEFAULT_SPAWNING_STATE
		);
		if (golem === undefined) {
			return false;
		}
		golem.setGoal(goal);
		return true;
	}

	/**
	 * Spawns a golem that attacks the player. Returns success status.
	 * @param {Player} player
	 * @param {Vector3} [spawnLocation]
	 */
	static spawnAndAttackPlayerOnce(player, spawnLocation = undefined) {
		log(`Trying to spawn a golem to attack ${player.name} once`);
		const success = UndeadGolemManager._spawnAndDoNastyThingsToPlayer(
			player,
			GolemGoal.HIT_TARGET,
			spawnLocation
		);
		if (success) {
			log(`Spawned golem will try to HIT PLAYER ONCE`);
		}
		return success;
	}

	/**
	 * Spawns a golem that will try to kill the player. Returns success status.
	 * @param {Player} player
	 * @param {Vector3} [spawnLocation]
	 */
	static spawnAndKillPlayer(player, spawnLocation = undefined) {
		log(`Trying to spawn a golem to kill ${player.name}`);
		const success = UndeadGolemManager._spawnAndDoNastyThingsToPlayer(
			player,
			GolemGoal.KILL_TARGET,
			spawnLocation
		);
		if (success) {
			log(`Spawned golem will try to KILL PLAYER`);
		}
		return success;
	}

	/**
	 * Runs summonAndMarkTarget in front of the player view direction.
	 * @param {Player} player
	 * @param {GolemState} spawnState MARK_TARGET_STATE or DEFAULT_SPAWNING_STATE
	 */
	static checkSpaceAndSummonInFront(player, spawnState) {
		const viewDirection = player.getViewDirection();
		const { location, dimension } = player;
		const targetLocation = {
			x: location.x + viewDirection.x * 4,
			y: location.y + viewDirection.y * 4,
			z: location.z + viewDirection.z * 4,
		};
		const spawnLocation = UndeadGolemManager.getExactSpawnLocation(
			dimension,
			targetLocation,
			3
		);
		if (spawnLocation === undefined) {
			return false;
		}

		// Make sure only target is tagged for attacking
		player.dimension.runCommand("tag @e remove hb_hr_undead_golem_target");
		player.addTag("hb_hr_undead_golem_target");

		const golem = UndeadGolemManager.summon(
			player,
			dimension,
			spawnLocation,
			spawnState
		);
		return golem !== undefined;
	}

	/**
	 * Summons undead golem with the initial animation that shows the player they're being
	 * marked as a target. Returns the UndeadGolem instance or undefined if it fails to spawn.
	 *
	 * Function can fail if it fails to spawn the entity.
	 * @param {Player} player
	 * @param {Dimension} dimension
	 * @param {Vector3} location
	 * @param {GolemState} spawnState MARK_TARGET_STATE or DEFAULT_SPAWNING_STATE
	 */
	static summon(player, dimension, location, spawnState) {
		/** @type {Entity | undefined} */
		let entity;
		try {
			entity = dimension.spawnEntity("hb_hr:undead_golem", location);
		} catch (error) {
			return undefined;
		}
		// Summon golem with correct state
		const golem = new UndeadGolem(entity);
		switch (spawnState) {
			case GolemState.DEFAULT_SPAWNING_STATE:
				golem.syncStateWithStalkedPlayer(player.id);
				break;
			case GolemState.CREEPY_STATE:
				golem.syncStateWithStalkedPlayer(player.id);
				golem.setState(GolemState.CREEPY_STATE);
				golem.setRotationModeVariable("locked_to_camera");
				break;
			case GolemState.MARK_TARGET_STATE:
				golem.syncStateWithStalkedPlayer(player.id);
				// Mark target is a state for new golems. It doesn't syncs, it sets the
				// values
				golem.setState(GolemState.MARK_TARGET_STATE);
				// Set and lock rotation
				const rotation = getRotationPointingAt(
					entity.location,
					player.location
				);
				golem.setRotationVariable(rotation);
				golem.setRotationModeVariable("locked");

				// Send message
				world.sendMessage({
					translate: "hb_hr:undead_golem.stalking.start.message",
					with: [player.name],
				});
				break;
			default:
				// Should never happen if coded carefully
				throw new Error("Invalid spawn state");
		}
		// Tick the golem
		golem.tick();
		return golem;
	}

	// VALIDATING AND FINDING SPAWN LOCATIONS
	/**
	 * Given a location and dimension and available wiggle room, returns the coordinates
	 * for a valid spawn location or undefined if no such location exists.
	 * @param {Dimension} dimension The dimension to check
	 * @param {Vector3} location The location to check
	 * @param {number} wiggleRoom how much the hasSpawnSpace() can wiggle to find a spawn
	 * location.
	 * @returns {Vector3 | undefined} The coordinates for a valid spawn location or
	 * undefined if no such location exists.
	 */
	static getExactSpawnLocation(dimension, location, wiggleRoom = 0) {
		const x = Math.floor(location.x);
		const y = Math.floor(location.y);
		const z = Math.floor(location.z);

		// Size of the entity (3x4x3)
		const entitySizeX = 3;
		const entitySizeY = 4;
		const entitySizeZ = 3;

		// Cached function of quering whether a block is safe or not for better
		// performance (reduces getBlock() calls)
		const blockCache = new Map();
		const blockStatus = {
			air: 0, // Safe blocks
			solid: 1, // Unsafe blocks (we assume they're solid)
			unknown: 2, // Unknown blocks are always assumed to be bad
		};

		/**
		 * @param {number} bx
		 * @param {number} by
		 * @param {number} bz
		 */
		const getBlockStatus = (bx, by, bz) => {
			const key = `${bx},${by},${bz}`;

			// Return cached result if available
			if (blockCache.has(key)) {
				return blockCache.get(key);
			}

			// Otherwise check the block
			let block;
			try {
				block = dimension.getBlock({ x: bx, y: by, z: bz });
			} catch {
				blockCache.set(key, blockStatus.unknown);
				return blockStatus.unknown;
			}

			if (block === undefined) {
				blockCache.set(key, blockStatus.unknown);
				return blockStatus.unknown;
			}

			const isSafe =
				UndeadGolemManager.SPAWN_SAFE_BLOCKS.has(block.typeId) ||
				block
					.getTags()
					.some((tag) => UndeadGolemManager.SPAWN_SAFE_BLOCK_TAGS.has(tag));

			// Cache the result
			const status = isSafe ? blockStatus.air : blockStatus.solid;
			blockCache.set(key, status);
			return status;
		};

		// Check if a volume is safe for the entity
		/**
		 * @param {number} startX
		 * @param {number} startY
		 * @param {number} startZ
		 */
		const isVolumeSafe = (startX, startY, startZ) => {
			// Below at least one of the blocks must be solid
			let foundSolidBlock = false;
			solidBlockLoop: for (let dz = 0; dz < entitySizeZ; dz++) {
				for (let dx = 0; dx < entitySizeX; dx++) {
					const status = getBlockStatus(startX + dx, startY - 1, startZ + dz);
					if (status === blockStatus.unknown) {
						return false;
					}
					if (status === blockStatus.solid) {
						foundSolidBlock = true;
						break solidBlockLoop;
					}
				}
			}
			if (!foundSolidBlock) {
				return false;
			}

			// Spawn area must be clear
			for (let dy = 0; dy < entitySizeY; dy++) {
				for (let dz = 0; dz < entitySizeZ; dz++) {
					for (let dx = 0; dx < entitySizeX; dx++) {
						const status = getBlockStatus(
							startX + dx,
							startY + dy,
							startZ + dz
						);
						if (status !== blockStatus.air) {
							return false;
						}
					}
				}
			}
			return true;
		};

		// First check the original position, both for wiggleRoom=0 case and as optimization for wiggleRoom>0
		const halfSizeX = Math.floor(entitySizeX / 2);
		const halfSizeZ = Math.floor(entitySizeZ / 2);
		const startX = x - halfSizeX; // Adjust for entity center
		const startZ = z - halfSizeZ; // Adjust for entity center
		if (isVolumeSafe(startX, y, startZ)) {
			return { x: startX + halfSizeX, y, z: startZ + halfSizeZ };
		}

		// Scan in expanding "shells" from inner to outer
		let counter = 0;
		for (let shell = 1; shell <= wiggleRoom; shell++) {
			// For each shell, we need to check all possible positions
			// where the entity volume might fit
			for (let offsetY = -shell; offsetY <= shell; offsetY++) {
				for (let offsetZ = -shell; offsetZ <= shell; offsetZ++) {
					for (let offsetX = -shell; offsetX <= shell; offsetX++) {
						// Skip checking positions that aren't on the shell's surface
						// This ensures we only check new positions at each iteration
						if (
							Math.abs(offsetX) !== shell &&
							Math.abs(offsetY) !== shell &&
							Math.abs(offsetZ) !== shell
						) {
							continue;
						}

						const startX = x - halfSizeX + offsetX;
						const startY = y + offsetY;
						const startZ = z - halfSizeZ + offsetZ;

						counter++;
						if (isVolumeSafe(startX, startY, startZ)) {
							return {
								x: startX + halfSizeX,
								y: startY,
								z: startZ + halfSizeZ,
							};
						}
					}
				}
			}
		}
		return undefined;
	}

	/**
	 * Finds a location to spawn the golem at near the player in range between min distance
	 * and max distance.
	 * @param {Object} options The options for finding a spawn location
	 * @param {Player} options.player The player to find a spawn location near
	 * @param {number} options.minDistance The minimum distance from the player
	 * @param {number} options.maxDistance The maximum distance from the player
	 * @param {number} options.yOffset How much the spawn location can be different from the player on the Y axis
	 * @param {number} options.attempts How many attempts to make to find a location
	 * @param {number} [options.wiggleRoom=0] How much the hasSpawnSpace() can wiggle to find a spawn location
	 * @returns {Vector3 | undefined}
	 */
	static getSpawnLocationNearPlayer({
		player,
		minDistance,
		maxDistance,
		yOffset,
		attempts,
		wiggleRoom = 0,
	}) {
		const { location, dimension } = player;

		for (let i = 0; i < attempts; i++) {
			// Get random angle and distance
			const angle = Math.random() * Math.PI * 2;
			const distance =
				minDistance + Math.random() * (maxDistance - minDistance);

			// Get x and z
			const x = location.x + Math.cos(angle) * distance;
			const z = location.z + Math.sin(angle) * distance;

			// Get y
			const y = location.y + (Math.random() * 2 - 1) * yOffset;

			// Create potential spawn location
			const spawnLocation = { x, y, z };

			// Check if the space is suitable for spawning
			const exactSpawnLocation = UndeadGolemManager.getExactSpawnLocation(
				dimension,
				spawnLocation,
				wiggleRoom
			);
			if (exactSpawnLocation !== undefined) {
				return exactSpawnLocation;
			}
		}
		// No suitable location found after all attempts
		return undefined;
	}

	// FINDING TARGETS
	/**
	 * Looks for a pet (ideally) belongs to the player. Unfortunately in vanilla
	 * entities, the temeable component is removed once the entity is tamed (and it's
	 * also the only way to check the owner using the API). If going through the tameable
	 * entities fails, other (approximate) methods are used. They don't guarantee that
	 * the entity is actually owned by the player, but it's the best we can do for now.
	 * @param {Player} player
	 */
	static findPlayersPet(player) {
		const { dimension, location } = player;
		const entities = dimension.getEntities({
			location,
			maxDistance: 32,
			families: ["mob"],
			excludeFamilies: ["monster", "inanimate"],
		});
		for (const entity of entities) {
			if (!entity.isValid()) {
				continue;
			}
			// Try with tameable and tame mountcomponent
			for (const tameableComponent of [
				entity.getComponent(EntityComponentTypes.Tameable),
				entity.getComponent(EntityComponentTypes.TameMount),
			]) {
				if (tameableComponent !== undefined) {
					if (tameableComponent.tamedToPlayerId !== player.id) {
						continue;
					}
					// Exclude entities that have a lot of health
					const healthComponent = entity.getComponent(
						EntityComponentTypes.Health
					);
					if (
						healthComponent === undefined ||
						healthComponent.currentValue > 50
					) {
						continue;
					}
					return entity;
				}
			}
			// Try alternative approximate methods
			if (
				[
					"minecraft:cat",
					"minecraft:donkey",
					"minecraft:horse",
					"minecraft:llama",
					"minecraft:mule",
					"minecraft:ocelot", // Very rare, only for backwards compatibility
					"minecraft:parrot",
					"minecraft:wolf",
					// Following entities ALWAYS have is_tamed component:
					// "minecraft:camel",
					// "minecraft:skeleton_horse",
					// "minecraft:zombie_horse",
				].includes(entity.typeId)
			) {
				const isTamedComponent = entity.getComponent(
					EntityComponentTypes.IsTamed
				);
				if (isTamedComponent === undefined) {
					continue;
				}
				return entity;
			}
		}
		return undefined;
	}

	// ENTITY REGISTRY ACCESS
	/**
	 * Adds this object to the entity registry. Used in the constructor.
	 * @param {UndeadGolem} golem
	 */
	static setInRegistry(golem) {
		UndeadGolemManager.entityRegistry.set(golem.entity.id, golem);
	}

	/**
	 * Returns the UndeadGolem instance from the entity registry.
	 * @param {string} entityId
	 * @returns {UndeadGolem | undefined}
	 */
	static getFromRegistry(entityId) {
		return UndeadGolemManager.entityRegistry.get(entityId);
	}

	/**
	 * Removes the UndeadGolem instance from the entity registry.
	 * @param {string} entityId
	 */
	static removeFromRegistry(entityId) {
		UndeadGolemManager.entityRegistry.delete(entityId);
	}

	/**
	 * Checks if there is at least one valid golem entity stalking a player on the world.
	 * This is used to limit the number of golem entities spawned in the world at given
	 * time.
	 */
	static isAnyStalkingGolemValid() {
		for (const golem of UndeadGolemManager.entityRegistry.values()) {
			if (
				golem.entity.isValid() &&
				golem.getStalkedPlayer() !== undefined &&
				golem.getState() !== GolemState.DESPAWNED
			) {
				return true;
			}
		}
		return false;
	}
}
