// @ts-check
import {
	Entity,
	EntityComponentTypes,
	EntityDamageCause,
	GameMode,
	Player,
	system,
	TicksPerSecond,
	world,
} from "@minecraft/server";
import {
	isBeingWatchedBy,
	isCloseToAny,
	shuffle,
} from "../../utilities/helpers";
import { DEBUG, log } from "./debug";
import {
	StalkedPlayerManager,
	StalkerData,
} from "./stalked_player_manager";
import { UndeadGolemManager } from "./undead_golem_manager";
import { GolemState, GolemGoal } from "./enums";
import {
	ANGER_PROBABILITIES,
	DESPAWN_AGE,
	ENCOUNTER_TIMING,
	HEALTH_ANGER_THRESHOLDS,
} from "./config.js";

/**
 * Identifiers used for the job types
 * @readonly
 * @enum {number}
 */
const JobType = {
	STATE_TRANSITION: 0,
	DESPAWN: 1,
};

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

export class UndeadGolem {
	/**
	 * The ID of currently scheduled job.
	 * @private
	 * @type {{id: number, type: JobType} | undefined}
	 */
	job;

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

	/**
	 * The cached state of the golem to avoid API calls.
	 * @type {GolemState | undefined}
	 */
	stateCache;

	/**
	 * The player stalked by this undead golem.
	 * @private
	 * @type {string | undefined}
	 */
	stalkedPlayerId;

	// CONSTRUCTOR
	/**
	 * Creates UndeadGolem instance and registers it to the entityRegistry map.
	 * @param {Entity} entity
	 */
	constructor(entity) {
		this.entity = entity;
		UndeadGolemManager.setInRegistry(this);
		// Warning! Don't add tick() here. The UndeadGolem entities often need
		// to perform some additional actions before it ticks. Ticking schedules
		// jobs based on the state of the golem but initially the state is always 0,
		// which might not match to what you're doing at given time.
	}

	// INITIALIZATION
	/**
	 * Registers all handlers needed by undead_golem. This function should run only
	 * once.
	 */
	static init() {
		// Deregistration to 'entityRegistry' map
		world.afterEvents.entityRemove.subscribe((event) => {
			const entityId = event.removedEntityId;
			const golem = UndeadGolemManager.getFromRegistry(entityId);
			if (golem === undefined) {
				return; // Nothing to do
			}
			if (golem.job != undefined) {
				system.clearJob(golem.job.id);
				golem.job = undefined;
			}
			UndeadGolemManager.removeFromRegistry(entityId);
		});

		// Killing golems unstalk their targets
		world.afterEvents.entityDie.subscribe(
			(event) => {
				const golem = UndeadGolemManager.getFromRegistry(event.deadEntity.id);
				if (golem === undefined) {
					return;
				}
				const stalkedPlayer = golem.getStalkedPlayer();
				golem.stopStalkingPlayer();
				if (stalkedPlayer !== undefined && stalkedPlayer.isValid()) {
					world.sendMessage({
						translate:
							"hb_hr:undead_golem.stalking.stop.golem_dead.player_known.message",
						with: [stalkedPlayer.name],
					});
				} else {
					world.sendMessage({
						translate:
							"hb_hr:undead_golem.stalking.stop.golem_dead.player_unknown.message",
					});
				}
			},
			{ entityTypes: ["hb_hr:undead_golem"] }
		);

		// If golem kills their target player, it stops stalking them
		world.afterEvents.entityDie.subscribe((event) => {
			const damagingEntity = event.damageSource.damagingEntity;
			if (
				damagingEntity === undefined ||
				!damagingEntity.isValid() ||
				damagingEntity.typeId !== "hb_hr:undead_golem"
			) {
				return;
			}
			const golem = UndeadGolemManager.getFromRegistry(damagingEntity.id);
			if (golem === undefined) {
				return;
			}
			const stalkedPlayerId = golem.getStalkedPlayerId();
			const deadEntity = event.deadEntity;
			if (deadEntity.typeId === "minecraft:player") {
				const killedPlayer = /** @type {Player} */ (event.deadEntity);
				if (stalkedPlayerId !== killedPlayer.id) {
					return;
				}
				golem.stopStalkingPlayer();
				world.sendMessage({
					translate: "hb_hr:undead_golem.stalking.stop.player_dead.message",
					with: [killedPlayer.name],
				});
			}
			// Golems always teleport away when they kill something
			golem.setState(GolemState.TELEPORT_STATE);
			golem.tick();
		});

		// Handle tickinng golems
		system.afterEvents.scriptEventReceive.subscribe(
			(event) => {
				if (event.id !== "hb_hr:undead_golem_tick") {
					return;
				}
				const mcEntity = event.sourceEntity;
				if (mcEntity?.typeId !== "hb_hr:undead_golem") {
					return;
				}
				// Get the entity or register a new one if it doesn't exist.
				const entity =
					UndeadGolemManager.getFromRegistry(mcEntity.id) ??
					new UndeadGolem(mcEntity);
				entity.tick();
			},
			{
				namespaces: ["hb_hr"],
			}
		);
		// Handle golems taking damage
		world.afterEvents.entityHurt.subscribe(
			(event) => {
				const golem = UndeadGolemManager.getFromRegistry(event.hurtEntity.id);
				if (golem === undefined) {
					return;
				}
				// Check if the golem is dead, skip everything if it's already dead
				const healthComponent = event.hurtEntity.getComponent(
					EntityComponentTypes.Health
				);
				if (
					healthComponent === undefined ||
					healthComponent.currentValue <= 0
				) {
					return;
				}

				// Try to increase the anger level
				if (golem.tryIncreaseAngerLevel(ANGER_PROBABILITIES.DAMAGE_TAKEN)) {
					log(`Golem anger increased after taking damage`);
				}

				// Check for specific damage types that should cause teleportation
				const damageSource = event.damageSource;
				if (
					damageSource.cause === EntityDamageCause.projectile ||
					damageSource.cause === EntityDamageCause.suffocation ||
					damageSource.cause === EntityDamageCause.drowning ||
					damageSource.cause === EntityDamageCause.wither ||
					damageSource.cause === EntityDamageCause.lava ||
					damageSource.cause === EntityDamageCause.sonicBoom
				) {
					log(`Golem teleporting away due to ${damageSource.cause} damage`);
					golem.emergencyTeleport();
					return;
				}

				// Creepy golems always teleport away
				const state = golem.getState();
				if (state === GolemState.CREEPY_STATE) {
					// Creepy golems teleport away when they take damage
					golem.emergencyTeleport();
					return;
				}

				// It's easy to scare the golem away. It won't escape only if it's being
				// attacked by its target. Any other
				const damagingEntity = event.damageSource.damagingEntity;
				if (damagingEntity === undefined) {
					golem.emergencyTeleport();
					return;
				}
				const isDamagingEntityTarget = damagingEntity
					.getTags()
					.includes("hb_hr_undead_golem_target");
				if (!isDamagingEntityTarget) {
					golem.emergencyTeleport();
					return;
				}
			},
			{
				entityTypes: ["hb_hr:undead_golem"],
			}
		);

		// Handle golem hitting other entities
		world.afterEvents.entityHitEntity.subscribe((event) => {
			const damagingEntity = event.damagingEntity;
			if (damagingEntity.typeId !== "hb_hr:undead_golem") {
				return;
			}
			const golem = UndeadGolemManager.getFromRegistry(damagingEntity.id);
			if (golem === undefined) {
				return;
			}
			const goal = golem.getGoal();
			if (goal === GolemGoal.HIT_TARGET) {
				// If the golem's goal was to just attack, it's reached and the golem
				// should teleport away
				golem.emergencyTeleport();
			}
		});

		// Handle ticking random encounters:
		const encounterPeriod = ENCOUNTER_TIMING.CHECK_PERIOD;
		// Calculate the per-check encounter probability based on the target probability
		// and reference period from the configuration
		const encounterChance = (() => {
			// Target probability of encounter within the reference period
			const p = ENCOUNTER_TIMING.TARGET_PROBABILITY;
			// Number of checks within the reference period
			const n = Math.floor(ENCOUNTER_TIMING.REFERENCE_PERIOD / encounterPeriod);

			// 1-(1-x)**n = p
			// 1-p = (1-x)**n
			// 1-x = Math.pow(1-p, 1/n)
			// x = 1 - Math.pow(1-p, 1/n)
			// Formula: 1-(1-x)**n = p
			// Where:
			// x = per-check probability we're calculating
			// n = number of checks in the reference period
			// p = target probability within the reference period
			return 1 - Math.pow(1 - p, 1 / n);
		})();
		system.runInterval(() => {
			if (!world.gameRules.doMobSpawning) {
				log("Mob spawning is disabled. Skipping random stalking encounter.");
				return;
			}
			if (UndeadGolemManager.isAnyStalkingGolemValid()) {
				// We don't want to have more than one golem stalking a player(s) at a
				// time.
				log(
					`There is already a golem stalking a player. Skipping encounter initialization.`
				);
				return;
			}
			// Shuffle players so that none of them is prioritiezed when it comes
			// to being stalked.
			const players = world
				.getAllPlayers()
				.filter(
					(player) =>
						StalkedPlayerManager.getStalkedPlayer(player.id) !== undefined
				);
			if (players.length === 0) {
				log(
					`No players are currently being stalked. Skipping random stalking encounter.`
				);
				return;
			}
			shuffle(players);
			for (const player of players) {
				if (Math.random() > encounterChance) {
					// Failed the random chance check. Skip.
					log(
						`Failed the random chance check for ${player.name}` +
							` random chance > ${encounterChance.toFixed(2)}`
					);
					continue;
				}
				log(`Successfully passed the random chance check for ${player.name}`);
				if (UndeadGolemManager.spawnAndDoDeviousPlans(player)) {
					// Only one golem at a time.
					log(`Successfully initiated a stalking event for ${player.name}`);
					break;
				}
			}
		}, encounterPeriod * TicksPerSecond);

		// Debug
		if (!DEBUG) {
			return;
		}
		system.afterEvents.scriptEventReceive.subscribe(
			(event) => {
				const player = /** @type {Player | undefined} */ (event.sourceEntity);
				if (player === undefined) {
					return;
				}
				if (event.id === "debug:summon_undead_golem") {
					let success = false;
					if (event.message === "mark_target") {
						success = UndeadGolemManager.checkSpaceAndSummonInFront(
							player,
							GolemState.MARK_TARGET_STATE
						);
					} else if (event.message === "default") {
						success = UndeadGolemManager.checkSpaceAndSummonInFront(
							player,
							GolemState.DEFAULT_SPAWNING_STATE
						);
					} else {
						log(
							"The debug:summon_undead_golem command requries an argument " +
								"either 'mark_target' or 'default'"
						);
						return;
					}
					if (success) {
						log("Successfully summoned and marked target for stalking!");
					} else {
						log("Failed to summon golem.");
					}
				}
			},
			{
				namespaces: ["debug"],
			}
		);
		// system.runInterval(() => {
		// 	StalkedPlayerManager.printStalkedPlayers();
		// }, 5 * TicksPerSecond);
	}

	// SETTING STALKING PROPERTIES
	/**
	 * Sets the target player and data to the golems dynamic property and
	 * StalkedPlayerManager.
	 * @param {string} playerId
	 * @param {StalkerData} data
	 */
	setStalkedPlayer(playerId, data) {
		StalkedPlayerManager.setStalkedPlayer(playerId, data);
		this.entity.setDynamicProperty("stalked_player_id", playerId);
		this.stalkedPlayerId = playerId;
	}

	/**
	 * Stops stalking the target player. If there is no target, nothing happens.
	 */
	stopStalkingPlayer() {
		const stalkedPlayer = this.getStalkedPlayer();
		if (stalkedPlayer === undefined) {
			return;
		}
		StalkedPlayerManager.removeStalkedPlayer(stalkedPlayer);
		this.entity.setDynamicProperty("stalked_player_id", undefined);
		this.stalkedPlayerId = undefined;
	}

	// SYNCING STATE WITH STALKING PROPERTIES
	/**
	 * Syncs the state of the golem with the saved data of the stalked player based on
	 *@param {string} stalkedPlayerId
	 */
	syncStateWithStalkedPlayer(stalkedPlayerId) {
		// Get health component
		const healthComponent = this.entity.getComponent(
			EntityComponentTypes.Health
		);
		if (healthComponent === undefined) {
			// Shouldn't happen (unless no isValid() check was assured before)
			throw new Error("Health component is undefined");
		}

		// Get the player data and set it to default if it doesn't exist
		let data = StalkedPlayerManager.getStalkedPlayer(stalkedPlayerId);
		if (data === undefined) {
			// Set the defaults. Hopefully this should never happen.
			data = new StalkerData(0, -1);
		}
		// Sync the health value
		healthComponent.setCurrentValue(
			data.health == -1 ? healthComponent.effectiveMax : data.health
		);
		// Sync the stalker data and make sure that stalkedPlayerId is set.
		// This is kind of wasteful because we're writting the property again but
		// it also sets other things and doesn't run very often so it's fine.
		this.setStalkedPlayer(stalkedPlayerId, data);
	}

	// GETTING STALKING PROPERTIES
	/**
	 * Returns the ID of the stalked player.
	 */
	getStalkedPlayerId() {
		if (this.stalkedPlayerId !== undefined) {
			return this.stalkedPlayerId;
		}
		if (!this.entity.isValid()) {
			return undefined;
		}
		const stalkedPlayerProperty =
			this.entity.getDynamicProperty("stalked_player_id");
		if (
			stalkedPlayerProperty !== undefined &&
			typeof stalkedPlayerProperty === "string"
		) {
			// Check if StalkedPlayerManager also has that.
			if (
				StalkedPlayerManager.getStalkedPlayer(stalkedPlayerProperty) !==
				undefined
			) {
				this.stalkedPlayerId = stalkedPlayerProperty;
			} else {
				// The data of the entity was outdated and should be removed.
				this.entity.setDynamicProperty("stalked_player_id", undefined);
				this.stalkedPlayerId = undefined;
			}
		}
		return this.stalkedPlayerId;
	}

	/**
	 * Returns the stalked player.
	 * @returns {Player | undefined}
	 */
	getStalkedPlayer() {
		const id = this.getStalkedPlayerId();
		if (id !== undefined) {
			for (const player of world.getAllPlayers()) {
				if (player.id === id) {
					return player;
				}
			}
		}
		return undefined;
	}

	// ROTATION
	/**
	 * 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);
	}

	/**
	 * Whether the rotation of the golem should be locked using a special animation that
	 * rotates based on the rotation variable or not.
	 * @param {"locked" | "locked_to_camera" | "default"} value
	 */
	setRotationModeVariable(value) {
		this.entity.setProperty("hb_hr:rotation_mode", value);
	}

	// MANAGING STATE
	/**
	 * Sets the state of the golem.
	 * @param {GolemState} targetState
	 */
	setState(targetState) {
		if (targetState === GolemState.DESPAWNED) {
			this.stateCache = targetState;
			return;
		}
		const events = {
			[GolemState.DEFAULT_SPAWNING_STATE]: "hb_hr:default_spawning_state",
			[GolemState.DEFAULT_STATE]: "hb_hr:default_state",
			[GolemState.CREEPY_STATE]: "hb_hr:creepy_state",
			[GolemState.MARK_TARGET_STATE]: "hb_hr:mark_target_state",
			[GolemState.TELEPORT_STATE]: "hb_hr:teleport_state",
		};
		const event = events[targetState];
		this.entity.triggerEvent(event);
		this.stateCache = targetState;
	}

	/**
	 * Returns the state of the golem.
	 */
	getState() {
		if (this.stateCache !== undefined) {
			return this.stateCache;
		}
		const variantComponent = this.entity.getComponent(
			EntityComponentTypes.Variant
		);
		const result = /** @type {GolemState} */ (variantComponent?.value);
		this.stateCache = result;
		return result;
	}

	// MANAGING ANGER LEVEL
	/**
	 * Returns the anger level of the golem.
	 */
	getAngerLevel() {
		const stalkedPlayerId = this.getStalkedPlayerId();
		if (stalkedPlayerId === undefined) {
			return 0;
		}
		const data = StalkedPlayerManager.getStalkedPlayer(stalkedPlayerId);
		return data?.angerLevel ?? 0;
	}

	// MANAGING GOALS
	/**
	 * Sets the goal of the golem.
	 * @param {GolemGoal} goal The goal to set from GolemGoal enum
	 */
	setGoal(goal) {
		this.entity.setProperty("hb_hr:goal", goal);
	}

	/**
	 * Returns the current goal of the golem.
	 * @returns {GolemGoal} The current goal from GolemGoal enum
	 */
	getGoal() {
		const goal = this.entity.getProperty("hb_hr:goal");
		// If goal is undefined or not a valid number, set it to NONE
		if (goal === undefined || typeof goal !== "number") {
			this.setGoal(GolemGoal.NONE);
			return GolemGoal.NONE;
		}
		return /** @type {GolemGoal} */ (goal);
	}

	/**
	 * Attempts to increase the anger level of the golem with a random chance.
	 * @param {number} probability The probability (0-1) of increasing the anger level
	 * @returns {boolean} Whether the anger level was increased
	 */
	tryIncreaseAngerLevel(probability) {
		const stalkedPlayerId = this.getStalkedPlayerId();
		if (stalkedPlayerId === undefined) {
			return false;
		}

		const data = StalkedPlayerManager.getStalkedPlayer(stalkedPlayerId);
		if (data === undefined) {
			return false;
		}

		// Random chance to increase anger
		if (Math.random() < probability) {
			const newAngerLevel = data.angerLevel + 1;
			StalkedPlayerManager.setStalkedPlayer(
				stalkedPlayerId,
				new StalkerData(newAngerLevel, data.health)
			);
			return true;
		}
		return false;
	}

	// TRANSITION CONDITIONS

	// TICKING
	tick() {
		// Despawning conditions for non custom spawned golems
		if (this.getStalkedPlayerId() !== undefined) {
			// Teleport away if there is no player to stalk
			const player = this.getStalkedPlayer();
			if (player === undefined) {
				this.emergencyTeleport(false); // Don't tick after to avoid stack overflow
				return;
			}
			// Teleport away if the player is too far away
			const { x: pX, y: pY, z: pZ } = player.location;
			const { x: gX, y: gY, z: gZ } = this.entity.location;
			if (Math.hypot(pX - gX, pY - gY, pZ - gZ) > 35) {
				this.emergencyTeleport(false); // Don't tick after to avoid stack overflow
				return;
			}
		}
		if (this.job !== undefined) {
			return;
		}
		const state = this.getState();
		if (state === GolemState.DESPAWNED) {
			// Entity should be despawned alreday. If it's not just despawn it
			// instantly and don't save its state.
			this.despawnJob(0);
			return;
		}
		if (state === GolemState.MARK_TARGET_STATE) {
			this.stateTransitionJob(7.42 * TicksPerSecond, GolemState.TELEPORT_STATE);
			return;
		}
		if (state === GolemState.DEFAULT_SPAWNING_STATE) {
			this.stateTransitionJob(7.42 * TicksPerSecond, GolemState.DEFAULT_STATE);
			return;
		}
		if (state === GolemState.TELEPORT_STATE) {
			// Teleport away ans save data. For golems that teleport after the
			// job is done there is no risk of saving the data because they
			// are not stalking anyone anymore.
			this.despawnJob(1.2 * TicksPerSecond, true);
			return;
		}

		const { location, dimension } = this.entity;
		const players = dimension.getPlayers({
			location,
			maxDistance: 20,
			excludeGameModes: [GameMode.spectator],
		});
		if (
			state === GolemState.CREEPY_STATE &&
			(isCloseToAny(players, location, 5) ||
				isBeingWatchedBy(players, location, 8))
		) {
			log(
				`Teleporting creepy golem away because of being to close to players or being watched.`
			);
			if (this.tryIncreaseAngerLevel(ANGER_PROBABILITIES.ENTITY_SEEN)) {
				log(`Golem anger increased after being seen or approached`);
			}
			this.emergencyTeleport();
			return;
		}

		// Despawning from age (only works for some states)
		const age = /** @type {number} */ (this.entity.getProperty("hb_hr:age"));
		if (
			(state === GolemState.CREEPY_STATE && age > DESPAWN_AGE.CREEPY_STATE) ||
			(state === GolemState.DEFAULT_STATE && age > DESPAWN_AGE.DEFAULT_STATE)
		) {
			log(`Golem is too old. Despawning.`);
			this.emergencyTeleport();
			return;
		}
	}

	// MANAGING JOBS
	/**
	 * Schedules a job to run after a delay. Handles adding and removing the job from the
	 * jobIds map.
	 * @private
	 * @param {function} jobFunc
	 * @param {{delay: number, jobType: JobType, tickAfter?: boolean}} [jobOptions]
	 */
	runJob(jobFunc, jobOptions = undefined) {
		if (jobOptions !== undefined && jobOptions.delay > 0) {
			const { delay, jobType, tickAfter } = jobOptions;
			const jobId = system.runTimeout(() => {
				jobFunc();
				this.job = undefined;
				if (tickAfter === true) {
					this.tick();
				}
			}, delay);
			this.job = { id: jobId, type: jobType };
		} else {
			jobFunc();
		}
	}

	/**
	 * Schedules a job to transition to the default state
	 * @param {number} delay
	 * @param {GolemState} state
	 */
	stateTransitionJob(delay, state) {
		const jobType = JobType.STATE_TRANSITION;
		this.runJob(
			() => {
				if (this.entity.isValid()) {
					this.setState(state);
				}
			},
			{ delay, jobType, tickAfter: true }
		);
	}

	/**
	 * Schedules a job to despawn the entity.
	 * @param {number} delay
	 * @param {boolean} [saveState]
	 */
	despawnJob(delay, saveState = false) {
		const jobType = JobType.DESPAWN;
		this.runJob(
			() => {
				if (this.entity.isValid()) {
					this.entity.triggerEvent("hb_hr:despawn");
				}
				this.setState(GolemState.DESPAWNED);
				if (saveState && this.stalkedPlayerId !== undefined) {
					const oldData = StalkedPlayerManager.getStalkedPlayer(
						this.stalkedPlayerId
					);
					// Chance to increase anger level when despawning based on configuration
					let angerLevel = oldData?.angerLevel ?? 0;
					if (Math.random() < ANGER_PROBABILITIES.DESPAWNING) {
						angerLevel += 1;
						log(`Golem anger increased after despawning`);
					}

					// Get the health component to check current health
					const healthComponent = this.entity.getComponent(
						EntityComponentTypes.Health
					);
					const health = healthComponent?.currentValue ?? oldData?.health ?? -1; // -1 is a flag for full health

					// Check if health-based anger level system is enabled
					if (healthComponent !== undefined) {
						// Calculate health percentage
						const healthPercentage = health / healthComponent.effectiveMax;
						log(`Golem health percentage: ${healthPercentage.toFixed(2)}`);

						// Check against thresholds and set minimum anger level if needed
						for (const threshold of HEALTH_ANGER_THRESHOLDS.THRESHOLDS) {
							if (
								healthPercentage <= threshold.healthPercentage &&
								angerLevel < threshold.minAngerLevel
							) {
								angerLevel = threshold.minAngerLevel;
								log(
									`Golem anger set to ${angerLevel} due to low health (${(
										healthPercentage * 100
									).toFixed(0)}%)`
								);
								break; // Only apply the first matching threshold
							}
						}
					}

					StalkedPlayerManager.setStalkedPlayer(
						this.stalkedPlayerId,
						new StalkerData(angerLevel, health)
					);
				}
			},
			{ delay, jobType }
		);
	}

	/**
	 * Transitions to the teleport state even if there is another job scheduled.
	 * unless it's a despawn job or golem is already in teleport state.
	 * @param {boolean} [tickAfter] Whether to tick after teleporting
	 */
	emergencyTeleport(tickAfter = true) {
		if (this.job !== undefined) {
			if (this.job.type === JobType.DESPAWN) {
				return;
			}
			const state = this.getState();
			if (
				state === GolemState.TELEPORT_STATE ||
				state === GolemState.DESPAWNED
			) {
				return; // Already in teleport state
			}
			system.clearJob(this.job.id);
		}
		this.setState(GolemState.TELEPORT_STATE);
		if (tickAfter) {
			this.tick();
		}
	}
}

UndeadGolem.init();
