/*
 * 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.List;
import java.util.logging.Logger;

import org.w3c.dom.Document;

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

/**
 * Understands incoming player messages without an LLM: normalizes the text
 * (lowercase, translit decode, repeat squashing), tokenizes it and matches it
 * against the intent lexicon (data/FakePlayerIntents.xml). An intent matches
 * when every alternative group has at least one prefix hit among the tokens.
 * @author Mobius SPP
 */
public class FakePlayerIntentParser implements IXmlReader
{
	private static final Logger LOGGER = Logger.getLogger(FakePlayerIntentParser.class.getName());
	
	public static class Intent
	{
		public String key;
		public String seam;
		public int maxTokens;
		final List<String[]> groups = new ArrayList<>();
	}
	
	private final List<Intent> _intents = new ArrayList<>();
	
	protected FakePlayerIntentParser()
	{
		load();
	}
	
	@Override
	public void load()
	{
		_intents.clear();
		parseDatapackFile("data/FakePlayerIntents.xml");
		LOGGER.info(getClass().getSimpleName() + ": Loaded " + _intents.size() + " intents.");
	}
	
	@Override
	public void parseDocument(Document document, File file)
	{
		forEach(document, "intents", listNode -> forEach(listNode, "intent", intentNode ->
		{
			final StatSet set = new StatSet(parseAttributes(intentNode));
			final Intent intent = new Intent();
			intent.key = set.getString("key");
			intent.seam = set.getString("seam", "");
			intent.maxTokens = set.getInt("maxTokens", 99);
			forEach(intentNode, "m", groupNode -> intent.groups.add(groupNode.getTextContent().split("\\|")));
			_intents.add(intent);
		}));
	}
	
	/**
	 * Normalizes a message: lowercase, latin-leet to russian, squashed repeats.
	 * @param message raw text.
	 * @return normalized text.
	 */
	public String normalize(String message)
	{
		String text = message.toLowerCase();
		// Latin leet used by russian players -> russian letters.
		text = text.replace("ii", "п").replace("ji", "л").replace("bi", "ы").replace("io", "ю");
		final StringBuilder sb = new StringBuilder(text.length());
		char previous = 0;
		int repeat = 0;
		for (char c : text.toCharArray())
		{
			// Digits used as letters inside words.
			if (c == '4')
			{
				c = 'ч';
			}
			else if (c == '3')
			{
				c = 'з';
			}
			else if (c == '6')
			{
				c = 'б';
			}
			if (c == previous)
			{
				repeat++;
				if (repeat >= 2)
				{
					continue; // Squash "приииивет".
				}
			}
			else
			{
				repeat = 0;
			}
			previous = c;
			sb.append(c);
		}
		return sb.toString();
	}
	
	/**
	 * Phonetic latin-to-russian transliteration ("privet" - "привет", "xelp" - "хелп").
	 * @param text normalized text.
	 * @return transliterated text.
	 */
	public String phonetic(String text)
	{
		String result = text.replace("sh", "ш").replace("ch", "ч").replace("zh", "ж").replace("yu", "ю").replace("ya", "я").replace("kh", "х").replace("ts", "ц");
		final StringBuilder sb = new StringBuilder(result.length());
		for (char c : result.toCharArray())
		{
			final int index = "abvgdezijklmnoprstufhxcyq".indexOf(c);
			sb.append((index >= 0) ? "абвгдезийклмнопрстуфхxцык".charAt(index) : c);
		}
		return sb.toString().replace('x', 'х');
	}
	
	/**
	 * @param message the raw player message.
	 * @return the matched intent, or null.
	 */
	public Intent parse(String message)
	{
		final String normalizedText = normalize(message);
		final String[] rawTokens = normalizedText.split("[^a-zа-яё0-9]+");
		final String[] phoneticTokens = phonetic(normalizedText).split("[^a-zа-яё0-9]+");
		final String[] tokens = new String[rawTokens.length + phoneticTokens.length];
		System.arraycopy(rawTokens, 0, tokens, 0, rawTokens.length);
		System.arraycopy(phoneticTokens, 0, tokens, rawTokens.length, phoneticTokens.length);
		int tokenCount = 0;
		for (String token : rawTokens)
		{
			if (!token.isEmpty())
			{
				tokenCount++;
			}
		}
		if (tokenCount == 0)
		{
			return null;
		}
		
		for (Intent intent : _intents)
		{
			if (tokenCount > intent.maxTokens)
			{
				continue;
			}
			// Each group needs its own token: "слышь" alone must not satisfy both halves of "что нового".
			final boolean[] used = new boolean[tokens.length];
			boolean all = true;
			for (String[] group : intent.groups)
			{
				boolean any = false;
				for (int index = 0; index < tokens.length; index++)
				{
					final String token = tokens[index];
					if (token.isEmpty() || used[index])
					{
						continue;
					}
					for (String stem : group)
					{
						if (matches(token, stem))
						{
							any = true;
							used[index] = true;
							// The same word appears twice (raw and phonetic): consume both copies.
							final int twin = (index < rawTokens.length) ? (index + rawTokens.length) : (index - rawTokens.length);
							if ((twin >= 0) && (twin < tokens.length) && tokens[twin].equals(token))
							{
								used[twin] = true;
							}
							break;
						}
					}
					if (any)
					{
						break;
					}
				}
				if (!any)
				{
					all = false;
					break;
				}
			}
			if (all)
			{
				return intent;
			}
		}
		return null;
	}
	
	/**
	 * @param token message token.
	 * @param stem lexicon stem; a leading "=" means exact prefix only (no typo tolerance).
	 * @return true when the token matches the stem.
	 */
	private static boolean matches(String token, String stem)
	{
		if (stem.startsWith("="))
		{
			return token.startsWith(stem.substring(1));
		}
		if (token.startsWith(stem))
		{
			return true;
		}
		// Typo tolerance only for longer stems and for tokens that could be the same word:
		// "молодая" is not a typo of "соло", "думаешь" is not "дурак".
		return (stem.length() >= 5) && (token.length() <= (stem.length() + 3)) && fuzzyPrefix(token, stem);
	}
	
	/**
	 * Typo tolerant prefix match: the token's prefix is within Damerau-Levenshtein
	 * distance 1 of the stem ("помгите" matches stem "помо").
	 * @param token message token.
	 * @param stem lexicon stem.
	 * @return true when the prefix is close enough.
	 */
	private static boolean fuzzyPrefix(String token, String stem)
	{
		final int length = stem.length();
		if (token.length() >= length)
		{
			if (withinOneEdit(token.substring(0, length), stem))
			{
				return true;
			}
		}
		if (token.length() >= (length + 1))
		{
			return withinOneEdit(token.substring(0, length + 1), stem);
		}
		return false;
	}
	
	private static boolean withinOneEdit(String a, String b)
	{
		if (a.equals(b))
		{
			return true;
		}
		final int la = a.length();
		final int lb = b.length();
		if (Math.abs(la - lb) > 1)
		{
			return false;
		}
		if (la == lb)
		{
			int differences = 0;
			boolean swap = false;
			for (int i = 0; i < la; i++)
			{
				if (a.charAt(i) != b.charAt(i))
				{
					differences++;
					if ((differences == 2) && (i > 0) && (a.charAt(i) == b.charAt(i - 1)) && (a.charAt(i - 1) == b.charAt(i)))
					{
						swap = true;
					}
				}
			}
			return (differences <= 1) || ((differences == 2) && swap);
		}
		final String shorter = (la < lb) ? a : b;
		final String longer = (la < lb) ? b : a;
		int i = 0;
		int j = 0;
		int differences = 0;
		while ((i < shorter.length()) && (j < longer.length()))
		{
			if (shorter.charAt(i) == longer.charAt(j))
			{
				i++;
				j++;
			}
			else
			{
				differences++;
				if (differences > 1)
				{
					return false;
				}
				j++;
			}
		}
		return true;
	}
	
	public static FakePlayerIntentParser getInstance()
	{
		return SingletonHolder.INSTANCE;
	}
	
	private static class SingletonHolder
	{
		protected static final FakePlayerIntentParser INSTANCE = new FakePlayerIntentParser();
	}
}
