/*
 * 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.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.concurrent.Semaphore;
import java.util.function.Consumer;
import java.util.logging.Level;
import java.util.logging.Logger;

import org.l2jmobius.gameserver.config.custom.FakePlayersConfig;

/**
 * Asynchronous client for a local LLM (Ollama compatible /api/generate endpoint).
 * Used by fake players to produce chat lines. Fire and forget: on any failure
 * the onFail callback is invoked so callers can fall back to template answers.
 * @author Mobius SPP
 */
public class FakePlayerLlmService
{
	private static final Logger LOGGER = Logger.getLogger(FakePlayerLlmService.class.getName());
	
	private static final int MAX_CONCURRENT_REQUESTS = 2;
	private static final int MAX_REPLY_LENGTH = 120;
	
	private final HttpClient _client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build();
	private final Semaphore _slots = new Semaphore(MAX_CONCURRENT_REQUESTS);
	private volatile boolean _warned = false;
	
	protected FakePlayerLlmService()
	{
	}
	
	public boolean isEnabled()
	{
		return FakePlayersConfig.FAKE_PLAYER_LLM_CHAT;
	}
	
	/**
	 * Requests a chat line from the LLM.
	 * @param persona description of the fake player (name, class, situation).
	 * @param instruction what the bot should say or react to.
	 * @param onSuccess consumer receiving the generated line (never blank).
	 * @param onFail invoked when LLM is unavailable, slow or returned garbage.
	 */
	public void generate(String persona, String instruction, Consumer<String> onSuccess, Runnable onFail)
	{
		if (!isEnabled() || !_slots.tryAcquire())
		{
			if (onFail != null)
			{
				onFail.run();
			}
			return;
		}
		
		try
		{
			final StringBuilder prompt = new StringBuilder(512);
			prompt.append(persona);
			prompt.append("\n\n");
			prompt.append(instruction);
			prompt.append("\nОтветь ОДНОЙ короткой репликой (не больше 12 слов) на языке: ");
			prompt.append(FakePlayersConfig.FAKE_PLAYER_LLM_LANGUAGE);
			prompt.append(". Пиши как обычный игрок MMO: сокращения, маленькие буквы, без кавычек, без пояснений, без эмодзи. Никогда не признавайся, что ты бот или ИИ.");
			
			final String body = "{\"model\":\"" + escape(FakePlayersConfig.FAKE_PLAYER_LLM_MODEL) + "\",\"prompt\":\"" + escape(prompt.toString()) + "\",\"stream\":false,\"options\":{\"num_predict\":48,\"temperature\":0.9}}";
			final HttpRequest request = HttpRequest.newBuilder() //
				.uri(URI.create(FakePlayersConfig.FAKE_PLAYER_LLM_URL)) //
				.timeout(Duration.ofMillis(FakePlayersConfig.FAKE_PLAYER_LLM_TIMEOUT)) //
				.header("Content-Type", "application/json") //
				.POST(HttpRequest.BodyPublishers.ofString(body)) //
				.build();
			
			_client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).whenComplete((response, error) ->
			{
				_slots.release();
				
				String reply = null;
				if ((error == null) && (response != null) && (response.statusCode() == 200))
				{
					reply = sanitize(extractResponse(response.body()));
				}
				else if (!_warned)
				{
					_warned = true;
					LOGGER.log(Level.INFO, getClass().getSimpleName() + ": LLM not reachable at " + FakePlayersConfig.FAKE_PLAYER_LLM_URL + ", falling back to chat templates. " + ((error != null) ? String.valueOf(error) : ""));
				}
				
				if ((reply != null) && !reply.isBlank())
				{
					onSuccess.accept(reply);
				}
				else if (onFail != null)
				{
					onFail.run();
				}
			});
		}
		catch (Exception e)
		{
			_slots.release();
			if (onFail != null)
			{
				onFail.run();
			}
		}
	}
	
	/**
	 * Extracts the value of the "response" field from an Ollama JSON reply without external libraries.
	 * @param json the raw JSON string.
	 * @return the decoded value or null.
	 */
	private String extractResponse(String json)
	{
		final int keyIndex = json.indexOf("\"response\"");
		if (keyIndex < 0)
		{
			return null;
		}
		
		int index = json.indexOf('"', json.indexOf(':', keyIndex) + 1);
		if (index < 0)
		{
			return null;
		}
		
		final StringBuilder sb = new StringBuilder();
		index++;
		while (index < json.length())
		{
			final char c = json.charAt(index);
			if (c == '\\')
			{
				index++;
				if (index >= json.length())
				{
					break;
				}
				final char escaped = json.charAt(index);
				switch (escaped)
				{
					case 'n':
					{
						sb.append(' ');
						break;
					}
					case 't':
					{
						sb.append(' ');
						break;
					}
					case 'u':
					{
						if ((index + 4) < json.length())
						{
							try
							{
								sb.append((char) Integer.parseInt(json.substring(index + 1, index + 5), 16));
							}
							catch (NumberFormatException ignored)
							{
								// Skip malformed escape.
							}
							index += 4;
						}
						break;
					}
					default:
					{
						sb.append(escaped);
						break;
					}
				}
			}
			else if (c == '"')
			{
				break;
			}
			else
			{
				sb.append(c);
			}
			index++;
		}
		return sb.toString();
	}
	
	private String sanitize(String text)
	{
		if (text == null)
		{
			return null;
		}
		
		String result = text.trim();
		
		// Strip reasoning blocks some models produce.
		final int thinkEnd = result.lastIndexOf("</think>");
		if (thinkEnd >= 0)
		{
			result = result.substring(thinkEnd + "</think>".length()).trim();
		}
		
		// Strip wrapping quotes.
		if ((result.length() > 1) && ((result.charAt(0) == '"') || (result.charAt(0) == '«')))
		{
			result = result.substring(1);
		}
		if ((result.length() > 1) && ((result.charAt(result.length() - 1) == '"') || (result.charAt(result.length() - 1) == '»')))
		{
			result = result.substring(0, result.length() - 1);
		}
		
		// Keep single line.
		final int newLine = result.indexOf('\n');
		if (newLine > 0)
		{
			result = result.substring(0, newLine);
		}
		
		result = result.trim();
		if (result.length() > MAX_REPLY_LENGTH)
		{
			result = result.substring(0, MAX_REPLY_LENGTH);
		}
		return result;
	}
	
	private String escape(String text)
	{
		final StringBuilder sb = new StringBuilder(text.length() + 16);
		for (int i = 0; i < text.length(); i++)
		{
			final char c = text.charAt(i);
			switch (c)
			{
				case '"':
				{
					sb.append("\\\"");
					break;
				}
				case '\\':
				{
					sb.append("\\\\");
					break;
				}
				case '\n':
				{
					sb.append("\\n");
					break;
				}
				case '\r':
				{
					break;
				}
				case '\t':
				{
					sb.append("\\t");
					break;
				}
				default:
				{
					if (c < 0x20)
					{
						sb.append(String.format("\\u%04x", (int) c));
					}
					else
					{
						sb.append(c);
					}
					break;
				}
			}
		}
		return sb.toString();
	}
	
	public static FakePlayerLlmService getInstance()
	{
		return SingletonHolder.INSTANCE;
	}
	
	private static class SingletonHolder
	{
		protected static final FakePlayerLlmService INSTANCE = new FakePlayerLlmService();
	}
}
