/*
 * Copyright (c) 2013 L2jMobius
 * 
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 * 
 * The above copyright notice and this permission notice shall be
 * included in all copies or substantial portions of the Software.
 * 
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
 * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
 * IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 */
package org.l2jmobius.gameserver.managers;

import java.io.File;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.logging.Logger;

import org.w3c.dom.Document;

import org.l2jmobius.commons.threads.ThreadPool;
import org.l2jmobius.commons.util.IXmlReader;
import org.l2jmobius.commons.util.Rnd;
import org.l2jmobius.gameserver.config.custom.FakePlayersConfig;
import org.l2jmobius.gameserver.data.SpawnTable;
import org.l2jmobius.gameserver.data.holders.FakePlayerChatHolder;
import org.l2jmobius.gameserver.data.xml.FakePlayerData;
import org.l2jmobius.gameserver.entity.World;
import org.l2jmobius.gameserver.entity.actor.Npc;
import org.l2jmobius.gameserver.entity.actor.holders.npc.FakePlayerHolder;
import org.l2jmobius.gameserver.entity.actor.Player;
import org.l2jmobius.gameserver.entity.spawns.Spawn;
import org.l2jmobius.gameserver.geoengine.GeoEngine;
import org.l2jmobius.gameserver.network.enums.ChatType;
import org.l2jmobius.gameserver.network.serverpackets.CreatureSay;
import org.l2jmobius.gameserver.util.StatSet;

/**
 * @author Mobius
 */
public class FakePlayerChatManager implements IXmlReader
{
	private static final Logger LOGGER = Logger.getLogger(FakePlayerChatManager.class.getName());
	
	private static final List<FakePlayerChatHolder> MESSAGES = new ArrayList<>();
	private static final int MIN_DELAY = 5000;
	private static final int MAX_DELAY = 15000;
	private static final int LOCAL_CHAT_RADIUS = 1250;
	
	protected FakePlayerChatManager()
	{
		load();
	}
	
	@Override
	public void load()
	{
		if (FakePlayersConfig.FAKE_PLAYERS_ENABLED)
		{
			FakePlayerData.getInstance().report();
			if (FakePlayersConfig.FAKE_PLAYER_CHAT)
			{
				MESSAGES.clear();
				parseDatapackFile("data/FakePlayerChatData.xml");
				LOGGER.info(getClass().getSimpleName() + ": Loaded " + MESSAGES.size() + " chat templates.");
				if (FakePlayersConfig.FAKE_PLAYER_LLM_CHAT)
				{
					LOGGER.info(getClass().getSimpleName() + ": LLM chat enabled (" + FakePlayersConfig.FAKE_PLAYER_LLM_MODEL + " at " + FakePlayersConfig.FAKE_PLAYER_LLM_URL + ").");
				}
			}
		}
	}
	
	@Override
	public void parseDocument(Document document, File file)
	{
		forEach(document, "list", listNode -> forEach(listNode, "fakePlayerChat", fakePlayerChatNode ->
		{
			// Parse attributes of the "fakePlayerChat" element.
			final StatSet set = new StatSet(parseAttributes(fakePlayerChatNode));
			
			// Add a new FakePlayerChatHolder to the MESSAGES collection.
			MESSAGES.add(new FakePlayerChatHolder(set.getString("fpcName"), set.getString("searchMethod"), set.getString("searchText"), set.getString("answers")));
		}));
	}
	
	public void manageChat(Player player, String fpcName, String message)
	{
		ThreadPool.schedule(() -> manageResponce(player, fpcName, message), Rnd.get(MIN_DELAY, MAX_DELAY));
	}
	
	public void manageChat(Player player, String fpcName, String message, int minDelay, int maxDelay)
	{
		ThreadPool.schedule(() -> manageResponce(player, fpcName, message), Rnd.get(minDelay, maxDelay));
	}
	
	private void manageResponce(Player player, String fpcName, String message)
	{
		if (player == null)
		{
			return;
		}
		
		final Npc fpcNpc = findNpc(fpcName);
		final Understanding understanding = (fpcNpc != null) ? understand(player, fpcNpc, message) : null;
		if ((understanding != null) && understanding.silent)
		{
			return; // The bot ignores the stranger, like real players sometimes do.
		}
		if ((fpcNpc != null) && FakePlayerLlmService.getInstance().isEnabled())
		{
			final int botId = FakePlayerProgressionManager.getInstance().getCharacterId(fpcNpc.getObjectId());
			final String hint = ((understanding != null) && !understanding.llmHint.isEmpty()) ? " (суть его сообщения: " + understanding.llmHint + ")" : "";
			FakePlayerLlmService.getInstance().generate(buildPersonaFor(fpcNpc, player.getName()), "Игрок " + player.getName() + " написал тебе в личные сообщения: \"" + message + "\"." + hint, reply ->
			{
				sendChat(player, fpcName, reply);
				FakePlayerMemoryManager.getInstance().addNote(botId, player.getName(), "он писал: " + message + " / я ответил: " + reply);
			}, () ->
			{
				if ((understanding != null) && (understanding.responseLine != null))
				{
					sendChat(player, fpcName, understanding.responseLine);
				}
				else
				{
					managePatternResponce(player, fpcName, message);
				}
			});
			return;
		}
		
		if ((understanding != null) && (understanding.responseLine != null))
		{
			sendChat(player, fpcName, understanding.responseLine);
			return;
		}
		
		managePatternResponce(player, fpcName, message);
	}
	
	private void managePatternResponce(Player player, String fpcName, String message)
	{
		if (player == null)
		{
			return;
		}
		
		final String text = message.toLowerCase();
		
		// tricky question
		if (text.contains("can you see me"))
		{
			final Spawn spawn = SpawnTable.getInstance().getAnySpawn(FakePlayerData.getInstance().getNpcIdByName(fpcName));
			if (spawn != null)
			{
				final Npc npc = spawn.getLastSpawn();
				if (npc != null)
				{
					if (npc.calculateDistance2D(player) < 3000)
					{
						if (GeoEngine.getInstance().canSeeTarget(npc, player) && !player.isInvisible())
						{
							sendChat(player, fpcName, Rnd.nextBoolean() ? "i am not blind" : Rnd.nextBoolean() ? "of course i can" : "yes");
						}
						else
						{
							sendChat(player, fpcName, Rnd.nextBoolean() ? "i know you are around" : Rnd.nextBoolean() ? "not at the moment :P" : "no, where are you?");
						}
					}
					else
					{
						sendChat(player, fpcName, Rnd.nextBoolean() ? "nope, can't see you" : Rnd.nextBoolean() ? "nope" : "no");
					}
					return;
				}
			}
		}
		
		final String answer = findPatternAnswer(fpcName, message);
		if (answer != null)
		{
			sendChat(player, fpcName, answer);
			return;
		}
		
		// No pattern matched: generic whisper answer seam.
		final String fallback = FakePlayerChatLines.getInstance().pick("whisper_default", null);
		if (fallback != null)
		{
			sendChat(player, fpcName, fallback);
		}
	}
	
	/**
	 * Reaction of a fake player to a general chat message said nearby.
	 * @param player the player who spoke.
	 * @param npc the fake player npc that will answer.
	 * @param message the said text.
	 */
	public void manageLocalChat(Player player, Npc npc, String message)
	{
		if ((player == null) || (npc == null))
		{
			return;
		}
		
		final Understanding understanding = understand(player, npc, message, false);
		if ((understanding != null) && understanding.silent)
		{
			return;
		}
		final Runnable patternFallback = () ->
		{
			if ((understanding != null) && (understanding.responseLine != null))
			{
				broadcastToNearby(npc, understanding.responseLine);
				return;
			}
			final String answer = findPatternAnswer(npc.getName(), message);
			if (answer != null)
			{
				broadcastToNearby(npc, answer);
			}
		};
		
		if (FakePlayerLlmService.getInstance().isEnabled())
		{
			final int botId = FakePlayerProgressionManager.getInstance().getCharacterId(npc.getObjectId());
			final String hint = ((understanding != null) && !understanding.llmHint.isEmpty()) ? " (суть: " + understanding.llmHint + ")" : "";
			ThreadPool.schedule(() -> FakePlayerLlmService.getInstance().generate(buildPersonaFor(npc, player.getName()), "Игрок " + player.getName() + " сказал рядом с тобой в общий чат: \"" + message + "\"." + hint, reply ->
			{
				broadcastToNearby(npc, reply);
				FakePlayerMemoryManager.getInstance().addNote(botId, player.getName(), "он сказал: " + message + " / я: " + reply);
			}, patternFallback), Rnd.get(4000, 10000));
		}
		else
		{
			ThreadPool.schedule(patternFallback, Rnd.get(3000, 8000));
		}
	}
	
	/**
	 * Makes a fake player comment an event in local chat. LLM only, silent when unavailable.
	 * @param npc the fake player npc.
	 * @param instruction event description for the LLM.
	 */
	public void manageEventChat(Npc npc, String instruction)
	{
		manageEventChat(npc, instruction, (String) null);
	}
	
	/**
	 * Makes a fake player comment an event in local chat.
	 * Uses the LLM when available, otherwise falls back to one of the given template lines.
	 * @param npc the fake player npc.
	 * @param instruction event description for the LLM.
	 * @param fallbackLines template lines used when the LLM is unavailable (may be null).
	 */
	public void manageEventChat(Npc npc, String instruction, String[] fallbackLines)
	{
		manageEventChat(npc, instruction, ((fallbackLines == null) || (fallbackLines.length == 0)) ? null : fallbackLines[Rnd.get(fallbackLines.length)]);
	}
	
	/**
	 * Event comment with a single prepared fallback line (usually from FakePlayerChatLines).
	 * @param npc the fake player npc.
	 * @param instruction LLM instruction.
	 * @param fallbackLine line to say when the LLM is unavailable (may be null).
	 */
	public void manageEventChat(Npc npc, String instruction, String fallbackLine)
	{
		manageEventChat(npc, instruction, fallbackLine, "");
	}
	
	/**
	 * Event comment with a known seam - the seam travels with the line so the
	 * heard system can classify it without guessing.
	 * @param npc the fake player npc.
	 * @param instruction LLM instruction.
	 * @param fallbackLine line to say when the LLM is unavailable (may be null).
	 * @param seam the seam the fallback line came from.
	 */
	public void manageEventChat(Npc npc, String instruction, String fallbackLine, String seam)
	{
		if ((npc == null) || !FakePlayersConfig.FAKE_PLAYER_CHAT)
		{
			return;
		}
		
		final Runnable fallback = (fallbackLine == null) ? null : () -> broadcastToNearby(npc, fallbackLine, seam);
		if (FakePlayerLlmService.getInstance().isEnabled())
		{
			FakePlayerLlmService.getInstance().generate(buildPersona(npc), instruction, reply -> broadcastToNearby(npc, reply, seam), fallback);
		}
		else if (fallback != null)
		{
			// A human needs a moment to type even a battle cry.
			ThreadPool.schedule(fallback, Rnd.get(800, 2500));
		}
	}
	
	/**
	 * Persona including the bot's memory about a specific player.
	 * @param npc the bot.
	 * @param playerName the player it interacts with.
	 * @return persona prompt.
	 */
	public String buildPersonaFor(Npc npc, String playerName)
	{
		final String base = buildPersona(npc);
		final int botId = FakePlayerProgressionManager.getInstance().getCharacterId(npc.getObjectId());
		final String memory = FakePlayerMemoryManager.getInstance().describe(botId, playerName);
		final String rumors = FakePlayerRumorManager.getInstance().describeFor(botId, playerName);
		final StringBuilder sb = new StringBuilder(base);
		if (!memory.isEmpty())
		{
			sb.append(' ').append(memory);
		}
		if (!rumors.isEmpty())
		{
			sb.append(' ').append(rumors);
		}
		return sb.toString();
	}
	
	/**
	 * Finds a template answer for the given text using FakePlayerChatData patterns.
	 * @param fpcName fake player name (or template name).
	 * @param message the text said to the bot.
	 * @return an answer or null when no pattern matches.
	 */
	public String findPatternAnswer(String fpcName, String message)
	{
		final String text = message.toLowerCase();
		for (FakePlayerChatHolder chatHolder : MESSAGES)
		{
			if (!chatHolder.getFpcName().equals(fpcName) && !chatHolder.getFpcName().equals("ALL"))
			{
				continue;
			}
			
			switch (chatHolder.getSearchMethod())
			{
				case "EQUALS":
				{
					if (text.equals(chatHolder.getSearchText().get(0)))
					{
						return chatHolder.getAnswers().get(Rnd.get(chatHolder.getAnswers().size()));
					}
					break;
				}
				case "STARTS_WITH":
				{
					if (text.startsWith(chatHolder.getSearchText().get(0)))
					{
						return chatHolder.getAnswers().get(Rnd.get(chatHolder.getAnswers().size()));
					}
					break;
				}
				case "CONTAINS":
				{
					boolean allFound = true;
					for (String word : chatHolder.getSearchText())
					{
						if (!text.contains(word))
						{
							allFound = false;
						}
					}
					if (allFound)
					{
						return chatHolder.getAnswers().get(Rnd.get(chatHolder.getAnswers().size()));
					}
					break;
				}
			}
		}
		return null;
	}
	
	public void broadcastToNearby(Npc npc, String message)
	{
		broadcastToNearby(npc, message, "");
	}
	
	public void broadcastToNearby(Npc npc, String message, String seam)
	{
		if ((npc == null) || npc.isDead() || !npc.isSpawned())
		{
			return;
		}
		
		World.broadcastToVisiblePlayersInRange(npc, new CreatureSay(npc, ChatType.GENERAL, npc.getName(), message), LOCAL_CHAT_RADIUS);
		FakePlayerHeardManager.getInstance().onSpeech(npc, message, seam);
	}
	
	/**
	 * Broadcast without the heard hook - used for pickup replies (depth 1).
	 * @param npc the bot.
	 * @param message the line.
	 */
	/**
	 * A dead bot's chat line (players type after dying - the corpse is still
	 * there and the chat works). No isDead guard, delivered while the corpse
	 * has not decayed yet.
	 * @param npc the dead bot.
	 * @param message the line.
	 * @param seam seam hint for the heard system.
	 */
	public void broadcastDeathCry(Npc npc, String message, String seam)
	{
		if ((npc == null) || (message == null) || message.isEmpty())
		{
			return;
		}
		World.broadcastToVisiblePlayersInRange(npc, new CreatureSay(npc, ChatType.GENERAL, npc.getName(), message), LOCAL_CHAT_RADIUS);
		FakePlayerHeardManager.getInstance().onSpeech(npc, message, seam);
	}
	
	public void broadcastRaw(Npc npc, String message)
	{
		if ((npc == null) || npc.isDead() || !npc.isSpawned())
		{
			return;
		}
		World.broadcastToVisiblePlayersInRange(npc, new CreatureSay(npc, ChatType.GENERAL, npc.getName(), message), LOCAL_CHAT_RADIUS);
	}
	
	/**
	 * Region-wide shout (call for help and similar).
	 * @param npc the bot.
	 * @param message the line.
	 */
	public void broadcastShout(Npc npc, String message)
	{
		if ((npc == null) || (message == null) || npc.isDead() || !npc.isSpawned())
		{
			return;
		}
		World.broadcastToVisiblePlayersInRange(npc, new CreatureSay(npc, ChatType.SHOUT, npc.getName(), message), 6000);
	}
	
	/**
	 * Trade channel line (party search, sales).
	 * @param npc the bot.
	 * @param message the line.
	 */
	public void broadcastTrade(Npc npc, String message)
	{
		if ((npc == null) || (message == null) || npc.isDead() || !npc.isSpawned())
		{
			return;
		}
		World.broadcastToVisiblePlayersInRange(npc, new CreatureSay(npc, ChatType.TRADE, npc.getName(), message), 9000);
	}
	
	public String buildPersona(Npc npc)
	{
		final StringBuilder sb = new StringBuilder(256);
		sb.append("Ты — ");
		sb.append(npc.getName());
		sb.append(", обычный игрок на сервере Lineage 2 Interlude");
		final FakePlayerHolder info = npc.getTemplate().getFakePlayerInfo();
		if (info != null)
		{
			sb.append(", класс: ");
			sb.append(info.getPlayerClass());
			sb.append(", уровень ");
			sb.append(npc.getTemplate().getLevel());
		}
		sb.append(". Сейчас ты фармишь мобов в поле.");
		final String extra = npc.getTemplate().getParameters().getString("fpcPersona", "");
		if (!extra.isEmpty())
		{
			sb.append(' ');
			sb.append(extra);
		}
		return sb.toString();
	}
	
	/**
	 * Result of understanding an incoming message.
	 */
	public static class Understanding
	{
		public String intentKey;
		public String responseLine;
		public String llmHint;
		public boolean silent; // The bot decided to ignore the message.
	}
	
	public static String intentHint(String key)
	{
		return INTENT_HINTS.getOrDefault(key, "");
	}
	
	private static final Map<String, String> INTENT_HINTS = Map.ofEntries(
		Map.entry("greeting", "он поздоровался"),
		Map.entry("bye", "он прощается"),
		Map.entry("insult", "он оскорбил тебя"),
		Map.entry("thanks", "он поблагодарил тебя"),
		Map.entry("botcheck", "он спрашивает, не бот ли ты - отнекивайся"),
		Map.entry("duel", "он вызывает тебя на дуэль"),
		Map.entry("ask_help", "он просит помощи"),
		Map.entry("ask_party", "он зовет тебя в пати"),
		Map.entry("ask_level", "он спрашивает твой уровень"),
		Map.entry("ask_price", "он спрашивает про цены или торговлю"),
		Map.entry("ask_rumors", "он спрашивает что нового"),
		Map.entry("ask_where", "он спрашивает где ты или куда идти"),
		Map.entry("yes_ok", "он согласился"),
		Map.entry("how_are_you", "он спрашивает как дела"));
	
	/**
	 * Understands an incoming message: matches an intent, applies world effects
	 * (memory, aggression, running to help) and prepares a template response.
	 * @param player the speaking player.
	 * @param npc the addressed bot.
	 * @param message the raw message.
	 * @return understanding or null when no intent matched.
	 */
	public Understanding understand(Player player, Npc npc, String message)
	{
		return understand(player, npc, message, true);
	}
	
	/**
	 * Understands an incoming message through the shared understanding layer
	 * (intents, memory facts, stance memory, silence, caps, mirroring, context).
	 * @param player the speaking player.
	 * @param npc the addressed bot.
	 * @param message the raw message.
	 * @param privateChannel true for whispers.
	 * @return understanding or null when no intent matched.
	 */
	public Understanding understand(Player player, Npc npc, String message, boolean privateChannel)
	{
		return FakePlayerUnderstanding.understand(FakePlayerBotRef.ofNpc(npc), player, message, privateChannel);
	}
	
	private Npc findNpc(String fpcName)
	{
		final Spawn spawn = SpawnTable.getInstance().getAnySpawn(FakePlayerData.getInstance().getNpcIdByName(fpcName));
		return (spawn != null) ? spawn.getLastSpawn() : null;
	}
	
	public void sendChat(Player player, String fpcName, String message)
	{
		final Spawn spawn = SpawnTable.getInstance().getAnySpawn(FakePlayerData.getInstance().getNpcIdByName(fpcName));
		if (spawn != null)
		{
			final Npc npc = spawn.getLastSpawn();
			if (npc != null)
			{
				player.sendPacket(new CreatureSay(npc, ChatType.WHISPER, fpcName, message));
			}
		}
	}
	
	public static FakePlayerChatManager getInstance()
	{
		return SingletonHolder.INSTANCE;
	}
	
	private static class SingletonHolder
	{
		protected static final FakePlayerChatManager INSTANCE = new FakePlayerChatManager();
	}
}
