'use strict';

const mineflayer = require('mineflayer');
const minecraftData = require('minecraft-data');
const { pathfinder, Movements, goals: { GoalNear } } = require('mineflayer-pathfinder');
const Vec3 = require('vec3');
const {sleep} = require('../utils');


class CJbot{
	isReady = false;
	listeners = {};

	// Holds the minimum cool down time for chat messages
	nextChatTime = 1500;
	
	// Prevents executing commands while one is running
	commandLock = false;
	commandCollDownTime = 1500;

	// Holds the list of commands this bot can execute
	commands = {};


	combatTag = false;
	doLogOut = false;

	// Map of all the bots currently in use
	static bots = {};

	// Forces the code to wait while until the bot is loaded before moving on to
	// the next
	static __addLock = false;

	// Holds a list of bots that still needs to be connect
	static __toConnect = [];


	constructor(args){

		// Friendly name to access the bot in `CJbot.bots`
		this.name = args.name || args.username;
		this.username = args.username;
		this.password = args.password;
		this.host = args.host;
		this.auth = args.auth || 'microsoft';
		this.version = args.version || '1.20.1';
		this.hasAi = args.hasAi;

		//
		this.pluginsWanted = args.plugins || {};

		// States if the bot should connect when its loaded
		this.autoReConnect = 'autoConnect' in args ? args.autoReConnect : true;
		this.autoConnect = 'autoConnect' in args  ? args.autoConnect : true;

		// On-demand mode: bot stays offline until ensureConnected() is called
		this.onDemand = args.onDemand || false;
		this._idleTimer = null;
		this._taskQueue = [];
		this._connecting = false;
		this._idleTimeout = args.idleTimeout || 30000;

		// If we want the be always connected, kick off the function to auto
		// reconnect
		if(this.autoReConnect && !this.onDemand) this.__autoReConnect()
	}

	connect(){
		console.log('CJbot.connect');
		return new Promise((resolve, reject) =>{

			try{
				this.bot = mineflayer.createBot({
					host: this.host,
					username: this.username,
					password: this.password,
					version: this.version,
					auth: this.auth,
				});

				// If an error happens before the login event, toss an error back to
				// the caller of the function
				let onError = this.bot.on('error', (m)=>{
					console.log('ERROR CJbot.connect on error:', this.name, m.toString());
					reject(m);
				})

				// If the connection ends before the login event, toss an error back
				// to the caller of the function 
				this.bot.on('end', (reason, ...args)=>{
					console.log(this.name, 'Connection ended:', reason, ...args);
					this.pluginUnloadAll(this.onDemand); // keepDb=true for on-demand
					this.isReady = false;
					reject(reason);
				});

				// When the bot is ready, return to the caller success
				this.bot.on('spawn', async()=>{
					console.log('CJbot.connect on spawn')
					await sleep(2000);
					this.__onReady();
					resolve();
					this._pluginsReady = this.pluginLoadAll();
				});

							}catch(error){
				console.log('CJbot.connect Error', error);
				reject(error);
			}

		});
	}

	// Wrap the once method so it works correctly with await
	once(event){
		return new Promise((resolve, reject)=> this.bot.once(event, resolve));
	}

	// Internal method to kick off the functionality after its loaded in the
	// server
	async __onReady(){try{

		this.bot.loadPlugin(pathfinder);
		this.mcData = minecraftData(this.bot.version);
		this.defaultMove = new Movements(this.bot, this.mcData);
		this.defaultMove.canDig = false;
		this.defaultMove.allowSprinting = false;

		// Make pathfinder avoid routing through chests/shulkers
		if (!this.defaultMove.blocksCost) this.defaultMove.blocksCost = {};
		const avoidBlocks = ['chest', 'trapped_chest', 'ender_chest',
			'shulker_box', 'white_shulker_box', 'orange_shulker_box',
			'magenta_shulker_box', 'light_blue_shulker_box', 'yellow_shulker_box',
			'lime_shulker_box', 'pink_shulker_box', 'gray_shulker_box',
			'light_gray_shulker_box', 'cyan_shulker_box', 'purple_shulker_box',
			'blue_shulker_box', 'brown_shulker_box', 'green_shulker_box',
			'red_shulker_box', 'black_shulker_box'];
		for (const name of avoidBlocks) {
			const block = this.mcData.blocksByName[name];
			if (block) this.defaultMove.blocksCost[block.id] = 100;
		}

		this.bot.pathfinder.setMovements(this.defaultMove);
		this._setupAntiStuck();

		// Add the listeners to the bot. We do this so if the bot loses
		// connection, the mineflayer instance will also be lost. 
		this.isReady = true;

		this.__error();
		this.__startListeners();

		// Call the internal listeners when the bot is ready
		for(let callback of this.listeners.onReady || []){
			callback.call(this);
		}

		console.log('Bot is ready', this.bot.entity.username, this.username);
		
		// Start chat listeners
		this.__listen();

		this.bot.on('title', (...args)=>console.log('on title', args))


	}catch(error){
		console.error('minecraft.js | __onReady | ', this.name, ' ', error);
	}}

	__startListeners(){
		for(let event in this.listeners){
			console.log('__adding listeners', event)
			for(let callback of this.listeners[event]){
				this.bot.on(event, callback);
			}
		}
	}

	// Wrap the .on method so we can hold the listeners internally
	on(event, callback){
		if(!this.listeners[event]) this.listeners[event] = [];

		this.listeners[event].push(callback);

		// If the bot is already loaded, add the passed listener to the
		// mineflayer instance
		if(this.isReady){
			if(event === 'onReady') callback(this);
			else this.bot.on(event, callback);
		}

		return ()=> this.off(event, callback);
	}

	// Remove listener for events
	off(event, callback) {
		console.log('off', event, callback)
		if (!this.listeners[event]) return false;

		const index = this.listeners[event].indexOf(callback);
		if (index === -1) return false;

		this.listeners[event].splice(index, 1);

		// If bot is ready, also remove from the Mineflayer bot
		if (this.isReady) {
			this.bot.off(event, callback);
		}

		return true;
	}

	// Listen for ending events and call connect again
	__autoReConnect(){
		try{
			console.log('auto re-connect function')
			this.on('kicked', (...args)=>console.log('CJbot.__autoReConnect on kick', args))
			
			this.on('end', async (...args)=>{
				console.error('CJbot.__autoReConnect on end', args)

				await sleep(30000)
				this.connect()
			});


			this.on('error', (error)=>{
				console.error('MC on error', error);

				// this.connect();
			});
		}catch(error){
			console.error('error in __autoReConnect', error);
		}
	}

	__error(message){
		this.bot.on('error', (error)=>{
			console.error(`ERROR!!! MC bot ${this.username} on ${this.host} had an error:\n`, error)
		});
	}


	quit(force){
		if(!this.combatTag || force){
			this.bot.quit();
		}else{
			console.log('Logout prevented due to combatTag');
			this.doLogOut = true;
		}
	}

	/* Plugins */

	static plungins = {};

	static pluginAdd(cls){
		this.plungins[cls.name] = cls;
		// Queue for web registration if plugin has web support
		if (typeof cls.createRouter === 'function' || cls.webUI) {
			const webServer = require('../controller/web-server');
			webServer.queuePlugin(cls);
		}
	}

	plunginsLoaded = {};

	async pluginLoadAll(){
		for(let pluginName in this.pluginsWanted){
			await this.pluginLoad(pluginName, this.pluginsWanted[pluginName]);
		}
	}

	async pluginLoad(pluginName, opts){
		console.log('CJbot.pluginLoad', pluginName)
		let plugin = new this.constructor.plungins[pluginName]({...opts, bot:this})
		await plugin.init();
		this.plunginsLoaded[pluginName] = plugin;
	}

	async pluginUnload(name){
		console.log('CJbot.pluginUnload', name)
		if(this.plunginsLoaded[name]){
			this.plunginsLoaded[name].unload();
			delete this.plunginsLoaded[name];
			console.log('CJbot.pluginUnload', name, 'done');
			return true;
		}
	}

	async pluginUnloadAll(keepDb = false){
		console.log('CJbot.pluginUnloadAll', keepDb ? '(keepDb)' : '');
		for(let pluginName in this.plunginsLoaded){
			console.log('CJbot.pluginUnloadAll loop', pluginName)
			try{
				await this.plunginsLoaded[pluginName].unload(keepDb);
				delete this.plunginsLoaded[pluginName];
			}catch(error){
				console.log('CJbot.pluginUnload loop error:', error)
			}
		}
	}

	/* On-demand lifecycle */

	async ensureConnected(taskFn) {
		this._resetIdleTimer();
		if (this.isReady) return await taskFn();

		return new Promise((resolve, reject) => {
			this._taskQueue.push({ fn: taskFn, resolve, reject });
			if (this._connecting) return;

			this._connecting = true;
			this.connect().then(async () => {
				this._connecting = false;
				// Wait for plugins to finish loading (pluginLoadAll runs after spawn)
				await this._pluginsReady;
				await this._drainTaskQueue();
			}).catch((error) => {
				this._connecting = false;
				const queue = this._taskQueue.splice(0);
				for (const task of queue) task.reject(error);
			});
		});
	}

	async _drainTaskQueue() {
		while (this._taskQueue.length > 0) {
			const task = this._taskQueue.shift();
			try {
				task.resolve(await task.fn());
			} catch (error) {
				task.reject(error);
			}
			this._resetIdleTimer();
		}
	}

	_resetIdleTimer() {
		if (!this.onDemand) return;
		if (this._idleTimer) clearTimeout(this._idleTimer);
		this._idleTimer = setTimeout(() => this._idleDisconnect(), this._idleTimeout);
	}

	_idleDisconnect() {
		if (!this.isReady) return;
		console.log(`${this.name}: Idle timeout, disconnecting on-demand bot`);
		this.quit(true);
	}

	/* Chat and messaging*/

	__listen(){
		this.bot.on('chat', (from, message)=>{

			// Ignore messages from this bot
			if(from === this.bot.entity.username) return;

			// Filter messages to this bot
			if(message.startsWith(this.bot.entity.username)){
				this.__doCommand(
					from,
					message.replace(`${this.bot.entity.username} ` , ' ').trim()
				)
			}
		});

		this.bot.on('whisper', (from, message)=>{
			this.__doCommand(
				from,
				message.replace(`${this.bot.entity.username} ` , ' ').trim()
			)
		});

		this.bot.on('message', (message, type)=>{
			if(message.toString().includes(' invited you to teleport to him.')){
				// teleport invite

				console.log('found teleport', message.toString().split(' ')[0])
				this.__doCommand(message.toString().split(' ')[0], '.invite');
			}

			if(message.toString().includes(' wants to trade with you!')){
				// teleport invite

				console.log('found Trade', message.toString().split(' ')[0])
				this.__doCommand(message.toString().split(' ')[0], '.trade');
			}


			if(message.toString().includes('You are combat tagged by')){
				try{
					this.combatTag = true;
					console.log('was attacked by')
					let attacker = message.toString().split('. ')[0].replace('You are combat tagged by ', '')
					console.log('was attacked by', attacker)
					// teleport invite
					this.whisper(attacker, 'Please do not attack me, I am a bot.')

				}catch(error){
					console.log('error!!!!!!', error)
				}

			}

			if(message.toString().includes('You are no longer in combat. You may now logout.')){
				this.combatTag = false
				if(this.doLogOut){
					this.quit()
				}
			}
		});
	}

	__chatCoolDown(){
		return Math.floor(Math.random() * (3000 - 2000) + 2000);
	}

	async say(...messages){
		for(let message of messages){
			(async (message)=>{
				if(this.nextChatTime > Date.now()){
					await sleep(this.nextChatTime-Date.now()+1)
				}
				this.bot.chat(message);
			})(message);

			this.nextChatTime = Date.now() + this.__chatCoolDown();
		}
	}

	async sayAiSafe(...messages){
		for(let message of messages){
			if(message.startsWith('/') && !(message.startsWith('/msg') || message.startsWith('/help'))){
				console.log('bot tried to execute bad command', message);
				message = '.'+message;
			}
			await this.say(message);
		}
	}

	async whisper(to, ...messages){
		await this.say(...messages.map(message=>`/msg ${to} ${message}`));
	}

	/* Commands */

	async __unLockCommand(time){
		await sleep(this.commandCollDownTime);
		this.commandLock = false;
	}

	__reduceCommands(from){
		return Object.keys(this.commands).filter(command =>{
			if (this.commands[command].allowed && !this.commands[command].allowed.includes(from)) return false;
			return true;
		});
	}

	async __doCommand(from, command){try{
		let [cmd, ...parts] = command.split(/\s+/);

		if(!this.__reduceCommands(from).includes(cmd)) return;

		const cmdDef = this.commands[cmd];

		if(this.commandLock && !cmdDef.ignoreLock){
			this.whisper(from, `cool down, try again in ${this.commandCollDownTime/1000} seconds...`);
			return ;
		}

		if(!cmdDef.ignoreLock) this.commandLock = true;
		try{
			await cmdDef.function.call(this, from, ...parts);
		}catch(error){
			this.whisper(from, `The command encountered an error.`);
			this.whisper(from, `ERROR: ${error}`);
			console.error(`Chat command error on ${cmd} from ${from}\n`, error);
		}
		if(!cmdDef.ignoreLock) this.__unLockCommand();
	}catch(error){
		console.error('minecraft.js | __doCommand |', this.name, ' ', error)
	}}

	addCommand(name, obj){
		if(this.commands[name]) return false;

		this.commands[name] = obj;
	}

	getPlayers(){
		for (let [username, value] of Object.entries(this.bot.players)){
			value.lvl = Number(value.displayName.extra[0].text)
		}

		return this.bot.players;
	}

	/* Actions */

	__blockOrVec(thing){
		if(thing instanceof Vec3.Vec3) return this.bot.blockAt(thing);
		if(thing.constructor && thing.constructor.name === 'Block') return thing;

		throw new Error('Not supported block identifier');
	}

	findBlockBySign(text){
		return this.bot.findBlock({
			useExtraInfo: true,
			maxDistance: 64,
			matching: (block)=> {
				if(block.name.includes('sign') && block.signText.includes(text)){
					return true;
				}
			}
		});
	}

	findChestBySign(text){
		return this.bot.findBlock({
			point: this.findBlockBySign(text).position,
			// maxDistance: 1,
			useExtraInfo: true,
			matching: block => block.name === 'chest'
		});
	}

	isWithinRange(target, range=2){
		const botPos = this.bot.entity.position;
		const distance = botPos.distanceTo(target);

		return distance <= range+.9;
	}

	playerWithinBlock(player, block, range){
		let playerData = this.bot.players[player];
		if(!playerData || !playerData.entity) return; // Skip if no entity info

		// Calculate the distance between the player and the block
		let distance = playerData.entity.position.distanceTo(block.position);

		console.log('CJbot.playerWithinBlock', distance, range, distance < range)
		if(!range){
			return distance;
		}

		return distance < range;
	}

	// Global anti-stuck system: monitors every physics tick while pathfinder is
	// moving. If the bot hasn't moved for ~600ms (12 ticks), it stops the
	// pathfinder and nudges the bot in an alternating direction (back/left/right)
	// to free it from corners. This works for ALL pathfinder movement globally.
	_antiStuckNudging = false;

	_setupAntiStuck() {
		let lastPos = null;
		let stuckTicks = 0;
		let nudgeTicks = 0;
		let nudgeCount = 0;
		let idleTicks = 0;

		this.bot.on('physicsTick', () => {
			// During a nudge, count ticks then clear controls
			if (this._antiStuckNudging) {
				nudgeTicks++;
				if (nudgeTicks >= 8) { // ~400ms of nudge movement
					this.bot.clearControlStates();
					this._antiStuckNudging = false;
					nudgeTicks = 0;
				}
				return;
			}

			// Track position regardless of pathfinder state — goTo clears
			// the goal between retries which would reset our counter
			const pos = this.bot.entity.position;

			if (!this.bot.pathfinder.isMoving()) {
				// Not pathfinding — only reset if we've been idle a while
				// (short gaps between goTo retries shouldn't reset)
				idleTicks++;
				if (idleTicks > 40) { // ~2 seconds of no pathfinding = truly idle
					stuckTicks = 0;
					lastPos = null;
					nudgeCount = 0;
				}
				return;
			}

			idleTicks = 0;

			if (!lastPos) {
				lastPos = pos.clone();
				return;
			}

			if (lastPos.distanceTo(pos) < 0.05) {
				stuckTicks++;

				if (stuckTicks >= 12) { // ~600ms with no movement
					nudgeCount++;
					const directions = ['back', 'left', 'right'];
					const dir = directions[nudgeCount % 3];
					console.log(`AntiStuck: No movement for ${stuckTicks} ticks, nudging ${dir}`);

					// Stop pathfinder so our controls take effect
					this.bot.pathfinder.stop();

					// Apply nudge direction
					this.bot.setControlState(dir, true);
					this._antiStuckNudging = true;
					nudgeTicks = 0;
					stuckTicks = 0;
					lastPos = null;
				}
			} else {
				stuckTicks = 0;
				lastPos = pos.clone();
			}
		});
	}

	async goTo(options) {
		let range = options.range || 2;
		let block = this.__blockOrVec(options.where);
		let retries = 0;
		let noPathCount = 0;
		const maxRetries = options.maxRetries || 5;
		const unjamDirections = ['back', 'left', 'right', 'forward'];

		while(!this.isWithinRange(this.__blockOrVec(options.where).position, range)){
			if(retries >= maxRetries){
				// All pathfinder attempts failed — clear goal, relocate, retry fresh
				console.log(`goTo: Pathfinder failed ${maxRetries} times, relocating and retrying`);

				this.bot.pathfinder.setGoal(null);
				await sleep(200);

				// Walk backward and randomly strafe to a new position
				const side = Math.random() < 0.5 ? 'left' : 'right';
				this.bot.setControlState('back', true);
				this.bot.setControlState(side, true);
				await sleep(600);
				this.bot.clearControlStates();
				await sleep(500);

				// Reset retries and try again
				retries = 0;
				continue;
			}

			try{
				// Timeout pathfinder after 30 seconds to prevent infinite hangs
				await Promise.race([
					this.bot.pathfinder.goto(
						new GoalNear(...block.position.toArray(), range)
					),
					new Promise((_, reject) =>
						setTimeout(() => {
							this.bot.pathfinder.stop();
							reject(new Error('goTo: Pathfinder timed out after 30s'));
						}, 30000)
					)
				]);
			}catch(error){
				retries++;
				const msg = error.message || String(error);
				const target = block.position;
				console.log(`goTo: Attempt ${retries}/${maxRetries} to ${target} error: ${msg}`);

				// "No path" = pathfinder searched full graph, route doesn't exist.
				// Walking around won't help — skip this target after 2 attempts.
				if(msg.includes('No path')){
					noPathCount++;
					if(noPathCount >= 2){
						console.log(`goTo: No path to ${target} after ${noPathCount} attempts, skipping`);
						this.bot.pathfinder.setGoal(null);
						return false;
					}
					// One retry: increase range in case we're just barely blocked
					range = Math.min(range + 1, 5);
					console.log(`goTo: No path, increasing range to ${range} and retrying`);
					this.bot.pathfinder.setGoal(null);
					await sleep(500);
					continue;
				}

				// Wait for any ongoing anti-stuck nudge to finish
				while(this._antiStuckNudging) await sleep(100);

				// Clear pathfinder state
				this.bot.pathfinder.setGoal(null);
				await sleep(200);

				// Unjam: cycle through different directions each retry
				const dir = unjamDirections[(retries - 1) % unjamDirections.length];
				const side = Math.random() < 0.5 ? 'left' : 'right';
				console.log(`goTo: Unjamming — walking ${dir} + ${side}`);
				this.bot.setControlState(dir, true);
				this.bot.setControlState(side, true);
				await sleep(800);
				this.bot.clearControlStates();
				await sleep(300);
			}
		}

		return true;
	}

	async goToReturn(options){
		let here = this.bot.entity.position;
		let hereYaw = this.bot.entity.yaw
		await this.goTo(options);
		
		return async () =>{
			await this.goTo({where: here, range: 0}, true);
			await sleep(500);
			await this.bot.look(Math.floor(hereYaw), 0);
		}
	}

	async __nextContainerSlot(window, item) {
		let firstEmptySlot = false;

		await window.containerItems();

		for(let slot in window.slots.slice(0, window.inventoryStart)){
			if(window.slots[slot] === null ){
				if(!Number.isInteger(firstEmptySlot)) firstEmptySlot = Number(slot);
				continue;
			}
			if(item.type === window.slots[slot].type && window.slots[slot].count < window.slots[slot].stackSize){
				return slot;
			}
		}

		return firstEmptySlot;
	}

	async openContainer(block){
		let count = 0;
		block = this.__blockOrVec(block);
		let window;

		while(!this.bot.currentWindow){
			try{
				window = await this.bot.openContainer(block);
			}catch(error){
				if(!error.message.includes('Event windowOpen did not fire within timeout')) throw error;
			}
			if(this.bot.currentWindow?.title){
				break;
			}
			this.bot.removeAllListeners('windowOpen');
			await sleep(1500);

			if(count++ == 3) throw 'Block wont open';
		}

		return this.bot.currentWindow;
	}

	async openCraftingTable(block){
		let count = 0;
		block = this.__blockOrVec(block);
		this.bot.activateBlock(block);
		let window = await this.once('windowOpen');

		return window;
	}

	async checkItemsFromContainer(containerBlock, itemName, count){
		let currentSlot = 0;
		let foundCount = 0;
		let window = await this.openContainer(containerBlock);

		for(let slot of window.slots){
			if(currentSlot++ === window.inventoryStart) break;currentSlot
			if(!slot) continue;
			if(slot.name === itemName) foundCount += slot.count;
		}

		await this.bot.closeWindow(window);
		if(foundCount >= count) return true;
	}

	async getItemsFromChest(containerBlock, itemName, count){
		let window = await this.openContainer(containerBlock);
		await sleep(500);
		// console.log('item id', this.mcData.itemsByName[itemName], this.mcData)
		await window.withdraw(this.mcData.itemsByName[itemName].id, null, count);
		await this.bot.closeWindow(window);
	}

	async getFullShulkersFromChest(chestBlock, item) {
		const fullShulkers = [];

		let window = await this.openContainer(chestBlock);

		let itemCount = 0
		let currentSlot = 0;
		for(let slot of window.slots){
			if(currentSlot++ === window.inventoryStart) break;

			if(!slot || slot.name !== 'shulker_box') continue;
			// console.log('slot:', slot)
			if(slot.nbt){
				// console.log('nbt', slot.nbt, slot.nbt.value.BlockEntityTag)
				// console.log('BlockEntityTag:', slot.nbt.value.BlockEntityTag.value.Items.value.value)

				for(let shulkerSlot of slot.nbt.value.BlockEntityTag.value.Items.value.value){
					console.log('shulkerSlot', shulkerSlot)
					if(shulkerSlot.id?.value !== `minecraft:${item}`) continue;
					itemCount += shulkerSlot.Count.value
				}
				if(this.bot.registry.itemsByName[item].stackSize * 27 === itemCount){
					console.log('found full shulker');
					this.bot.moveSlotItem(currentSlot, window.inventoryStart);
					break;
				}

			}
		}



		await window.close();
	}

	async dumpToChest(block, blockName, amount) {

		let window = await this.openContainer(block);

		let items = window.slots.slice(window.inventoryStart).filter(function(item){
			if(!item) return false;
			if(blockName && blockName !== item.name) return false;
			return true;
		});

		for(let item of items){
			await sleep(500);
			let currentSlot = Number(item.slot);
			if(!window.slots[currentSlot]) continue;

			try{
				await this.bot.transfer({
					window,
					itemType: this.mcData.itemsByName[item.name].id,
					sourceStart: currentSlot,
					sourceEnd: currentSlot+1,
					destStart: 0,
					destEnd: window.inventoryStart-1,
					count: amount || item.count,
				})

				if(amount) amount -= item.count
			}catch(error){
				console.log('error?', item.count, error.message, error);
			}
		}

		await sleep(1000);
		await this.bot.closeWindow(window);

		return amount ? amount : true;
	}
}

module.exports = {CJbot};
