/*
 * 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.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import java.util.logging.Logger;

import org.w3c.dom.Document;

import org.l2jmobius.commons.util.IXmlReader;
import org.l2jmobius.commons.util.Rnd;
import org.l2jmobius.gameserver.util.StatSet;

/**
 * Chat "seam" engine for fake players (Seams 2.0). One seam = one situation;
 * a reply is chosen by a weighted rule lottery over context facts, assembled
 * from morph templates ("[optional]", "(a|b)", "#pool#" recursion, trait
 * variants), grounded through {slot} substitution, personalized by the bot's
 * idiolect and guarded by a template-level anti-repeat with a global cooldown.
 * Prototyped and tested in the sandbox artifact, ported 1:1.
 * @author Mobius SPP
 */
public class FakePlayerChatLines implements IXmlReader
{
	private static final Logger LOGGER = Logger.getLogger(FakePlayerChatLines.class.getName());
	
	private static final int TEMPLATE_COOLDOWN_MESSAGES = 25;
	private static final int SAID_MEMORY = 12;
	private static final int MORPH_DEPTH_LIMIT = 8;
	private static final String[] TRAITS =
	{
		"mat",
		"eng",
		"old"
	};
	
	/** One line of a seam or pool: text plus an optional trait gate. */
	private static class LineDef
	{
		final String text;
		final String trait;
		
		LineDef(String text, String trait)
		{
			this.text = text;
			this.trait = ((trait == null) || trait.isEmpty()) ? null : trait;
		}
	}
	
	/** A weighted context rule inside a seam. */
	private static class Rule
	{
		final List<String[]> need = new ArrayList<>(); // Each: {fact, value} - all must hold.
		final List<Object[]> like = new ArrayList<>(); // Each: {fact, value, weight} - matched ones add weight.
		final List<LineDef> lines = new ArrayList<>();
		String label = "";
	}
	
	private static class Seam
	{
		final List<LineDef> base = new ArrayList<>();
		final List<Rule> rules = new ArrayList<>();
	}
	
	private final Map<String, Seam> _seams = new HashMap<>();
	private final Map<String, List<LineDef>> _pools = new HashMap<>();
	
	// Anti-repeat state: per bot said memory (template + surface), global template cooldown.
	private final Map<Integer, ArrayDeque<String>> _said = new ConcurrentHashMap<>();
	private final Map<String, Long> _lastTemplateUse = new ConcurrentHashMap<>();
	private final AtomicLong _messageIndex = new AtomicLong();
	
	protected FakePlayerChatLines()
	{
		load();
	}
	
	@Override
	public void load()
	{
		_seams.clear();
		_pools.clear();
		parseDatapackFile("data/FakePlayerChatLines.xml");
		parseDatapackFile("data/FakePlayerPools.xml");
		int lineCount = 0;
		for (Seam seam : _seams.values())
		{
			lineCount += seam.base.size();
			for (Rule rule : seam.rules)
			{
				lineCount += rule.lines.size();
			}
		}
		int poolLines = 0;
		for (List<LineDef> lines : _pools.values())
		{
			poolLines += lines.size();
		}
		LOGGER.info(getClass().getSimpleName() + ": Loaded " + lineCount + " lines in " + _seams.size() + " seams, " + poolLines + " lines in " + _pools.size() + " pools.");
	}
	
	@Override
	public void parseDocument(Document document, File file)
	{
		forEach(document, "pools", listNode -> forEach(listNode, "pool", poolNode ->
		{
			final StatSet set = new StatSet(parseAttributes(poolNode));
			final List<LineDef> lines = _pools.computeIfAbsent(set.getString("key"), k -> new ArrayList<>());
			forEach(poolNode, "line", lineNode ->
			{
				final StatSet lineSet = new StatSet(parseAttributes(lineNode));
				lines.add(new LineDef(lineNode.getTextContent(), lineSet.getString("trait", "")));
			});
		}));
		
		forEach(document, "lines", listNode -> forEach(listNode, "seam", seamNode ->
		{
			final StatSet set = new StatSet(parseAttributes(seamNode));
			final Seam seam = _seams.computeIfAbsent(set.getString("key"), k -> new Seam());
			forEach(seamNode, "line", lineNode ->
			{
				final StatSet lineSet = new StatSet(parseAttributes(lineNode));
				seam.base.add(new LineDef(lineNode.getTextContent(), lineSet.getString("trait", "")));
			});
			forEach(seamNode, "rule", ruleNode ->
			{
				final StatSet ruleSet = new StatSet(parseAttributes(ruleNode));
				final Rule rule = new Rule();
				final String needString = ruleSet.getString("need", "");
				final String likeString = ruleSet.getString("like", "");
				rule.label = (needString.isEmpty() ? "" : ("need:" + needString + " ")) + (likeString.isEmpty() ? "" : ("like:" + likeString));
				if (!needString.isEmpty())
				{
					for (String part : needString.split(","))
					{
						final String[] kv = part.trim().split("=");
						if (kv.length == 2)
						{
							rule.need.add(kv);
						}
					}
				}
				if (!likeString.isEmpty())
				{
					for (String part : likeString.split(","))
					{
						final String[] cw = part.trim().split(":");
						final String[] kv = cw[0].split("=");
						if (kv.length == 2)
						{
							rule.like.add(new Object[]
							{
								kv[0],
								kv[1],
								(cw.length > 1) ? Integer.parseInt(cw[1]) : 1
							});
						}
					}
				}
				forEach(ruleNode, "line", lineNode ->
				{
					final StatSet lineSet = new StatSet(parseAttributes(lineNode));
					rule.lines.add(new LineDef(lineNode.getTextContent(), lineSet.getString("trait", "")));
				});
				seam.rules.add(rule);
			});
		}));
	}
	
	// ======================== Public API ========================
	
	/**
	 * Full pipeline: rule lottery over facts, morph, slots, idiolect style, anti-repeat.
	 * @param botId bot character id (0 = generic style, no repeat tracking).
	 * @param key seam key.
	 * @param facts context facts (personality, asker, plan, event...; may be null).
	 * @param slots slot values; missing slots drop the line (may be null).
	 * @param optional true for unsolicited background chatter: a repeat is never
	 *            accepted and null is returned instead (silence beats repetition).
	 * @param mirror incoming message analysis for style mirroring (may be null).
	 * @return the styled line, or null.
	 */
	public String speak(int botId, String key, Map<String, String> facts, Map<String, String> slots, boolean optional, FakePlayerIdiolect.Mirror mirror)
	{
		return speak(botId, key, facts, slots, optional, mirror, false);
	}
	
	/**
	 * Private-channel variant (whisper): the global template cooldown models
	 * "it was just said in the air nearby", which whispers are not part of -
	 * so it is neither checked nor written. Per-bot anti-repeat still applies.
	 */
	public String speakPrivate(int botId, String key, Map<String, String> facts, Map<String, String> slots, FakePlayerIdiolect.Mirror mirror)
	{
		return speak(botId, key, facts, slots, false, mirror, true);
	}
	
	private String speak(int botId, String key, Map<String, String> facts, Map<String, String> slots, boolean optional, FakePlayerIdiolect.Mirror mirror, boolean privateReply)
	{
		final Seam seam = _seams.get(key);
		if (seam == null)
		{
			return null;
		}
		
		final FakePlayerIdiolect idiolect = FakePlayerIdiolect.of(botId);
		final Map<String, String> effectiveFacts;
		if (botId > 0)
		{
			// The bot's own situation (plan, recent event) feeds the rule lottery automatically.
			effectiveFacts = FakePlayerBotContext.facts(botId);
			if (facts != null)
			{
				effectiveFacts.putAll(facts);
			}
		}
		else
		{
			effectiveFacts = (facts == null) ? new HashMap<>(1) : facts;
		}
		effectiveFacts.putIfAbsent("daytime", daytimeNow());
		final Map<String, String> effectiveSlots = withDefaults(slots, idiolect, effectiveFacts);
		
		List<LineDef> group = filterTraits(chooseGroup(seam, effectiveFacts), idiolect);
		if (group.isEmpty())
		{
			group = filterTraits(seam.base, idiolect);
			if (group.isEmpty())
			{
				return null;
			}
		}
		
		double chattiness = idiolect.chattiness;
		if ((mirror != null) && mirror.shortMessage)
		{
			chattiness *= 0.4;
		}
		
		for (int attempt = 0; attempt < 14; attempt++)
		{
			final LineDef raw = group.get(Rnd.get(group.size()));
			final String morphed = morph(raw.text, idiolect, chattiness, 0);
			final String filled = fillSlots(morphed, effectiveSlots);
			if (filled == null)
			{
				if (attempt == 6)
				{
					final List<LineDef> reroll = filterTraits(chooseGroup(seam, effectiveFacts), idiolect);
					if (!reroll.isEmpty())
					{
						group = reroll;
					}
				}
				continue;
			}
			if (filled.matches("[\\d\\s]+"))
			{
				continue; // Degenerate line: all words were optional, only a number left.
			}
			// Pool-reference templates are identified by the expanded string, plain ones by the raw template.
			final String template = key + "|" + (raw.text.indexOf('#') >= 0 ? morphed : raw.text);
			final String repeat = isRepeat(botId, template, filled, privateReply);
			if (repeat != null)
			{
				if (optional || (attempt < 10))
				{
					if ((attempt == 4) || (attempt == 8))
					{
						final List<LineDef> reroll = filterTraits(chooseGroup(seam, effectiveFacts), idiolect);
						if (!reroll.isEmpty())
						{
							group = reroll;
						}
					}
					continue;
				}
			}
			remember(botId, template, filled, privateReply);
			return idiolect.stylePass(filled, mirror);
		}
		return null;
	}
	
	// --- Compatibility wrappers (existing call sites keep working unchanged) ---
	
	public String pick(String key, Map<String, String> slots)
	{
		return speak(0, key, null, slots, false, null);
	}
	
	public String pick(String key, String slotName, String slotValue)
	{
		return speak(0, key, null, singleSlot(slotName, slotValue), false, null);
	}
	
	public String pickFor(int botId, String key, Map<String, String> slots)
	{
		return speak(botId, key, null, slots, false, null);
	}
	
	public String pickFor(int botId, String key, String slotName, String slotValue)
	{
		return speak(botId, key, null, singleSlot(slotName, slotValue), false, null);
	}
	
	public String compose(int botId, String key, String personality, Map<String, String> slots)
	{
		final Map<String, String> facts = new HashMap<>(1);
		facts.put("personality", (personality == null) || personality.isEmpty() ? "neutral" : personality);
		return speak(botId, key, facts, slots, false, null);
	}
	
	public String compose(int botId, String key, String personality, String slotName, String slotValue)
	{
		return compose(botId, key, personality, singleSlot(slotName, slotValue));
	}
	
	/**
	 * @return seam key - base line texts view (dashboard).
	 */
	public Map<String, List<String>> seams()
	{
		final Map<String, List<String>> view = new LinkedHashMap<>();
		for (Map.Entry<String, Seam> entry : _seams.entrySet())
		{
			final List<String> texts = new ArrayList<>(entry.getValue().base.size());
			for (LineDef line : entry.getValue().base)
			{
				texts.add(line.text);
			}
			view.put(entry.getKey(), texts);
		}
		return Collections.unmodifiableMap(view);
	}
	
	// ======================== Rule lottery ========================
	
	private List<LineDef> chooseGroup(Seam seam, Map<String, String> facts)
	{
		final List<Object[]> groups = new ArrayList<>(); // {score, lines}
		if (!seam.base.isEmpty())
		{
			groups.add(new Object[]
			{
				1,
				seam.base
			});
		}
		for (Rule rule : seam.rules)
		{
			boolean needFailed = false;
			for (String[] condition : rule.need)
			{
				if (!condition[1].equals(facts.get(condition[0])))
				{
					needFailed = true;
					break;
				}
			}
			if (needFailed)
			{
				continue;
			}
			int likeHits = 0;
			int score = rule.need.isEmpty() ? 1 : 2;
			for (Object[] condition : rule.like)
			{
				if (condition[1].equals(facts.get(condition[0])))
				{
					likeHits++;
					score += (Integer) condition[2];
				}
			}
			if (!rule.like.isEmpty() && rule.need.isEmpty() && (likeHits == 0))
			{
				continue; // A like-only rule needs at least one matching fact.
			}
			if (!rule.need.isEmpty() && rule.like.isEmpty())
			{
				score = 100; // A pure need rule overrides the base.
			}
			groups.add(new Object[]
			{
				score,
				rule.lines
			});
		}
		if (groups.isEmpty())
		{
			return seam.base;
		}
		// A matched pure-need rule is absolute: the lottery runs only among such rules.
		final List<Object[]> needOnly = new ArrayList<>(1);
		for (Object[] group : groups)
		{
			if (((Integer) group[0]) >= 100)
			{
				needOnly.add(group);
			}
		}
		final List<Object[]> pool = needOnly.isEmpty() ? groups : needOnly;
		int total = 0;
		for (Object[] group : pool)
		{
			total += (Integer) group[0];
		}
		int roll = Rnd.get(total);
		for (Object[] group : pool)
		{
			roll -= (Integer) group[0];
			if (roll < 0)
			{
				@SuppressWarnings("unchecked")
				final List<LineDef> lines = (List<LineDef>) group[1];
				return lines;
			}
		}
		@SuppressWarnings("unchecked")
		final List<LineDef> lines = (List<LineDef>) pool.get(pool.size() - 1)[1];
		return lines;
	}
	
	private static List<LineDef> filterTraits(List<LineDef> lines, FakePlayerIdiolect idiolect)
	{
		final List<LineDef> result = new ArrayList<>(lines.size());
		for (LineDef line : lines)
		{
			if ((line.trait == null) || idiolect.hasTrait(line.trait))
			{
				result.add(line);
			}
		}
		return result;
	}
	
	// ======================== Morph engine ========================
	
	/**
	 * Expands a morph template: "[optional]" / "[30:optional]" chunks,
	 * "(a|b|mat:c)" alternations and "#pool#" recursion.
	 */
	private String morph(String template, FakePlayerIdiolect idiolect, double chattiness, int depth)
	{
		if (depth > MORPH_DEPTH_LIMIT)
		{
			return "";
		}
		final StringBuilder out = new StringBuilder(template.length());
		int i = 0;
		while (i < template.length())
		{
			final char ch = template.charAt(i);
			if (ch == '[')
			{
				final int close = findClose(template, i + 1, '[', ']');
				if (close < 0)
				{
					out.append(ch);
					i++;
					continue;
				}
				String inner = template.substring(i + 1, close);
				int chance = 50;
				final int colon = inner.indexOf(':');
				if ((colon > 0) && inner.substring(0, colon).chars().allMatch(Character::isDigit))
				{
					chance = Integer.parseInt(inner.substring(0, colon));
					inner = inner.substring(colon + 1);
				}
				final double probability = Math.min(0.95, (chance / 100.0) * chattiness);
				if (Rnd.get(1000) < (probability * 1000))
				{
					out.append(morph(inner, idiolect, chattiness, depth + 1));
				}
				i = close + 1;
			}
			else if (ch == '(')
			{
				final int close = findClose(template, i + 1, '(', ')');
				if (close < 0)
				{
					out.append(ch);
					i++;
					continue;
				}
				final List<String> alternatives = new ArrayList<>();
				for (String alternative : splitTop(template.substring(i + 1, close)))
				{
					final int prefixEnd = alternative.indexOf(':');
					if (prefixEnd > 0)
					{
						final String prefix = alternative.substring(0, prefixEnd);
						if (isTrait(prefix))
						{
							if (idiolect.hasTrait(prefix))
							{
								alternatives.add(alternative.substring(prefixEnd + 1));
							}
							continue;
						}
					}
					alternatives.add(alternative);
				}
				if (!alternatives.isEmpty())
				{
					out.append(morph(alternatives.get(Rnd.get(alternatives.size())), idiolect, chattiness, depth + 1));
				}
				i = close + 1;
			}
			else if (ch == '#')
			{
				final int close = template.indexOf('#', i + 1);
				if (close < 0)
				{
					out.append(ch);
					i++;
					continue;
				}
				final String poolKey = template.substring(i + 1, close);
				final String expanded = expandPool(poolKey, idiolect, chattiness, depth);
				if (expanded != null)
				{
					out.append(expanded);
				}
				i = close + 1;
			}
			else
			{
				out.append(ch);
				i++;
			}
		}
		return out.toString();
	}
	
	private String expandPool(String poolKey, FakePlayerIdiolect idiolect, double chattiness, int depth)
	{
		// A pool with the personality suffix would go here; personality is a fact,
		// kept simple: direct key then vocabulary partition + trait filter.
		final List<LineDef> pool = _pools.get(poolKey);
		if ((pool == null) || pool.isEmpty())
		{
			return null;
		}
		final List<LineDef> owned = new ArrayList<>(pool.size());
		for (int index = 0; index < pool.size(); index++)
		{
			final LineDef line = pool.get(index);
			if ((line.trait != null) && !idiolect.hasTrait(line.trait))
			{
				continue;
			}
			if (!idiolect.ownsPoolLine(poolKey, pool.size(), index))
			{
				continue;
			}
			owned.add(line);
		}
		if (owned.isEmpty())
		{
			return null;
		}
		return morph(owned.get(Rnd.get(owned.size())).text, idiolect, chattiness, depth + 1);
	}
	
	private static boolean isTrait(String prefix)
	{
		for (String trait : TRAITS)
		{
			if (trait.equals(prefix))
			{
				return true;
			}
		}
		return false;
	}
	
	private static int findClose(String text, int from, char open, char close)
	{
		int depth = 1;
		for (int i = from; i < text.length(); i++)
		{
			final char ch = text.charAt(i);
			if (ch == open)
			{
				depth++;
			}
			else if (ch == close)
			{
				depth--;
				if (depth == 0)
				{
					return i;
				}
			}
		}
		return -1;
	}
	
	private static List<String> splitTop(String text)
	{
		final List<String> parts = new ArrayList<>();
		int depth = 0;
		final StringBuilder current = new StringBuilder();
		for (int i = 0; i < text.length(); i++)
		{
			final char ch = text.charAt(i);
			if ((ch == '(') || (ch == '['))
			{
				depth++;
			}
			else if ((ch == ')') || (ch == ']'))
			{
				depth--;
			}
			if ((ch == '|') && (depth == 0))
			{
				parts.add(current.toString());
				current.setLength(0);
			}
			else
			{
				current.append(ch);
			}
		}
		parts.add(current.toString());
		return parts;
	}
	
	// ======================== Slots ========================
	
	private static Map<String, String> withDefaults(Map<String, String> slots, FakePlayerIdiolect idiolect, Map<String, String> facts)
	{
		final Map<String, String> result = (slots == null) ? new HashMap<>(4) : new HashMap<>(slots);
		// The bot's own shop assortment grounds trade lines when the caller has nothing better.
		if (idiolect.charId > 0)
		{
			result.putIfAbsent("item", idiolect.storeItem);
			result.putIfAbsent("price", idiolect.storePrice);
			for (Map.Entry<String, String> slot : FakePlayerBotContext.slots(idiolect.charId).entrySet())
			{
				result.putIfAbsent(slot.getKey(), slot.getValue());
			}
		}
		result.putIfAbsent("daytime", facts.getOrDefault("daytime", daytimeNow()));
		// The idiolect decides how the bot addresses people.
		final String player = result.get("player");
		if (player != null)
		{
			result.put("player", idiolect.addressForm(player));
		}
		final String target = result.get("target");
		if (target != null)
		{
			result.put("target", idiolect.addressForm(target));
		}
		return result;
	}
	
	public static String daytimeNow()
	{
		final int hour = java.time.LocalTime.now().getHour();
		if (hour < 6)
		{
			return "ночь";
		}
		if (hour < 12)
		{
			return "утро";
		}
		if (hour < 18)
		{
			return "день";
		}
		return "вечер";
	}
	
	private static String fillSlots(String line, Map<String, String> slots)
	{
		String result = line;
		for (Map.Entry<String, String> slot : slots.entrySet())
		{
			result = result.replace("{" + slot.getKey() + "}", slot.getValue());
		}
		return (result.indexOf('{') >= 0) ? null : result;
	}
	
	private static Map<String, String> singleSlot(String name, String value)
	{
		final Map<String, String> slots = new HashMap<>(1);
		if ((name != null) && (value != null))
		{
			slots.put(name, value);
		}
		return slots;
	}
	
	// ======================== Anti-repeat ========================
	
	private String isRepeat(int botId, String template, String surface, boolean privateReply)
	{
		if (botId > 0)
		{
			final ArrayDeque<String> said = _said.get(botId);
			if (said != null)
			{
				synchronized (said)
				{
					if (said.contains(template) || said.contains(surface))
					{
						return "self";
					}
				}
			}
		}
		if (!privateReply)
		{
			final Long lastUse = _lastTemplateUse.get(template);
			if ((lastUse != null) && ((_messageIndex.get() - lastUse) < TEMPLATE_COOLDOWN_MESSAGES))
			{
				return "cooldown";
			}
		}
		return null;
	}
	
	private void remember(int botId, String template, String surface, boolean privateReply)
	{
		if (botId > 0)
		{
			final ArrayDeque<String> said = _said.computeIfAbsent(botId, k -> new ArrayDeque<>());
			synchronized (said)
			{
				said.addLast(template);
				said.addLast(surface);
				while (said.size() > (SAID_MEMORY * 2))
				{
					said.removeFirst();
				}
			}
		}
		if (privateReply)
		{
			return;
		}
		_lastTemplateUse.put(template, _messageIndex.getAndIncrement());
		if (_lastTemplateUse.size() > 4000)
		{
			// Rare cleanup: drop entries far outside the cooldown window.
			final long now = _messageIndex.get();
			_lastTemplateUse.entrySet().removeIf(entry -> (now - entry.getValue()) > (TEMPLATE_COOLDOWN_MESSAGES * 4));
		}
	}
	
	public static FakePlayerChatLines getInstance()
	{
		return SingletonHolder.INSTANCE;
	}
	
	private static class SingletonHolder
	{
		protected static final FakePlayerChatLines INSTANCE = new FakePlayerChatLines();
	}
}
