/*
 * 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.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.logging.Level;
import java.util.logging.Logger;

import org.w3c.dom.Document;

import org.l2jmobius.commons.database.DatabaseFactory;
import org.l2jmobius.commons.threads.ThreadPool;
import org.l2jmobius.commons.util.IXmlReader;
import org.l2jmobius.commons.util.Rnd;
import org.l2jmobius.gameserver.ai.Intention;
import org.l2jmobius.gameserver.config.custom.FakePlayersConfig;
import org.l2jmobius.gameserver.data.SpawnTable;
import org.l2jmobius.gameserver.managers.CastleManager;
import org.l2jmobius.gameserver.data.xml.FakePlayerData;
import org.l2jmobius.gameserver.data.xml.NpcData;
import org.l2jmobius.gameserver.entity.Location;
import org.l2jmobius.gameserver.entity.actor.Attackable;
import org.l2jmobius.gameserver.entity.actor.Npc;
import org.l2jmobius.gameserver.entity.actor.templates.NpcTemplate;
import org.l2jmobius.gameserver.entity.World;
import org.l2jmobius.gameserver.entity.spawns.Spawn;
import org.l2jmobius.gameserver.network.enums.ChatType;
import org.l2jmobius.gameserver.network.serverpackets.CreatureSay;
import org.l2jmobius.gameserver.util.StatSet;

/**
 * Real progression for fake players.
 * Each fake "character" has a persistent level and exp stored in database.
 * Killing monsters grants the monster's exp reward (scaled by config). When a
 * character outgrows its tier it "goes to town" (despawns) and comes back in a
 * higher tier template (better gear, higher class) inside a level appropriate zone.
 * Template id convention: TEMPLATE_BASE + (charId - 1) * TIERS + tier.
 * @author Mobius SPP
 */
public class FakePlayerProgressionManager implements IXmlReader
{
	private static final Logger LOGGER = Logger.getLogger(FakePlayerProgressionManager.class.getName());
	
	public static final int TEMPLATE_BASE = 82000;
	public static final int SELL_TEMPLATE_BASE = 86000;
	public static final int BUY_TEMPLATE_BASE = 90000;
	public static final int TIERS = 8;
	private static final int[] TIER_MIN_LEVEL = { 1, 15, 25, 35, 45, 55, 64, 72 };
	private static final int MAX_LEVEL = 76;
	private static final long MOVE_INTERVAL = 10000;
	private static final int SAVE_EVERY_TICKS = 30;
	private static final int MAX_MOVE_STEP = 3000;
	

	
	private static class ZoneInfo
	{
		String name;
		String race = "";
		int minLevel;
		int maxLevel;
		final List<Location> points = new ArrayList<>();
	}
	
	private static class CharacterInfo
	{
		int id;
		String name;
		String personality;
		String race = "";
		int variant = 0;
		int clanId = 0;
		int startLevel;
	}
	
	private static class CharacterState
	{
		CharacterInfo info;
		int level;
		long exp;
		int tier = -1;
		String zoneName;
		Spawn spawn;
		Npc npc;
		long lastMove = 0;
		boolean dirty = false;
		boolean migrating = false;
		boolean trading = false;
		boolean raiding = false;
		long raidUntil = 0;
		int squadLeaderId = 0;
		long nextTradeAt = 0;
		long tradeEndsAt = 0;
		long lastShoutAt = 0;
		long expAtLastTrade = 0;
		int levelAtLastTrade = 0;
		String shoutLine = null;
	}
	
	private final List<ZoneInfo> _zones = new ArrayList<>();
	private final List<ZoneInfo> _towns = new ArrayList<>();
	private final Map<Integer, CharacterInfo> _characters = new HashMap<>();
	private final long[] _expToNext = new long[MAX_LEVEL + 2];
	private final Map<Integer, CharacterState> _states = new ConcurrentHashMap<>();
	private final Map<Integer, CharacterState> _byNpcObjectId = new ConcurrentHashMap<>();
	private final Map<Integer, Long> _castleMoodAt = new ConcurrentHashMap<>();
	private long _nextRaidAt = System.currentTimeMillis() + 1800000;
	private long _raidEndsAt = 0;
	private int _raidBossObjectId = 0;
	private int _tick = 0;
	

	
	protected FakePlayerProgressionManager()
	{
		if (!FakePlayersConfig.FAKE_PLAYERS_ENABLED)
		{
			LOGGER.info(getClass().getSimpleName() + ": Disabled.");
			return;
		}
		
		// Zones, towns and seed characters are shared data: headless players need them
		// even when the NPC bot population is turned off.
		load();
		
		if (!FakePlayersConfig.FAKE_PLAYER_PROGRESSION)
		{
			LOGGER.info(getClass().getSimpleName() + ": NPC bots disabled, loaded " + _zones.size() + " zones and " + _characters.size() + " seed characters for headless players.");
			return;
		}
		
		ensureTable();
		loadStates();
		buildSquads();
		spawnAll();
		ThreadPool.scheduleAtFixedRate(this::tick, MOVE_INTERVAL, MOVE_INTERVAL);
		LOGGER.info(getClass().getSimpleName() + ": Managing " + _states.size() + " characters in " + _zones.size() + " zones.");
	}
	
	@Override
	public void load()
	{
		_zones.clear();
		_characters.clear();
		parseDatapackFile("data/FakePlayerProgression.xml");
	}
	
	@Override
	public void parseDocument(Document document, File file)
	{
		forEach(document, "list", listNode ->
		{
			forEach(listNode, "expTable", expNode -> forEach(expNode, "level", levelNode ->
			{
				final StatSet set = new StatSet(parseAttributes(levelNode));
				final int level = set.getInt("value");
				if ((level > 0) && (level < _expToNext.length))
				{
					_expToNext[level] = set.getLong("expToNext");
				}
			}));
			
			forEach(listNode, "zone", zoneNode ->
			{
				final StatSet set = new StatSet(parseAttributes(zoneNode));
				final ZoneInfo zone = new ZoneInfo();
				zone.name = set.getString("name");
				zone.race = set.getString("race", "");
				zone.minLevel = set.getInt("minLevel");
				zone.maxLevel = set.getInt("maxLevel");
				forEach(zoneNode, "point", pointNode ->
				{
					final StatSet point = new StatSet(parseAttributes(pointNode));
					zone.points.add(new Location(point.getInt("x"), point.getInt("y"), point.getInt("z")));
				});
				if (!zone.points.isEmpty())
				{
					_zones.add(zone);
				}
			});
			
			forEach(listNode, "town", townNode ->
			{
				final StatSet set = new StatSet(parseAttributes(townNode));
				final ZoneInfo town = new ZoneInfo();
				town.name = set.getString("name");
				town.race = set.getString("race", "");
				town.minLevel = set.getInt("minLevel");
				town.maxLevel = set.getInt("maxLevel");
				forEach(townNode, "point", pointNode ->
				{
					final StatSet point = new StatSet(parseAttributes(pointNode));
					town.points.add(new Location(point.getInt("x"), point.getInt("y"), point.getInt("z")));
				});
				if (!town.points.isEmpty())
				{
					_towns.add(town);
				}
			});
			
			forEach(listNode, "character", characterNode ->
			{
				final StatSet set = new StatSet(parseAttributes(characterNode));
				final CharacterInfo info = new CharacterInfo();
				info.id = set.getInt("id");
				info.name = set.getString("name");
				info.personality = set.getString("personality", "neutral");
				info.race = set.getString("race", "");
				info.variant = set.getInt("variant", 0);
				info.clanId = set.getInt("clanId", 0);
				info.startLevel = set.getInt("startLevel", 1);
				_characters.put(info.id, info);
			});
		});
	}
	
	private void ensureTable()
	{
		try (Connection con = DatabaseFactory.getConnection();
			Statement statement = con.createStatement())
		{
			statement.execute("CREATE TABLE IF NOT EXISTS fake_player_progress (char_id INT NOT NULL PRIMARY KEY, level INT NOT NULL, exp BIGINT NOT NULL DEFAULT 0)");
		}
		catch (Exception e)
		{
			LOGGER.log(Level.WARNING, getClass().getSimpleName() + ": Could not create table.", e);
		}
	}
	
	private void loadStates()
	{
		final Map<Integer, int[]> stored = new HashMap<>();
		final Map<Integer, Long> storedExp = new HashMap<>();
		try (Connection con = DatabaseFactory.getConnection();
			Statement statement = con.createStatement();
			ResultSet rset = statement.executeQuery("SELECT char_id, level, exp FROM fake_player_progress"))
		{
			while (rset.next())
			{
				stored.put(rset.getInt(1), new int[] { rset.getInt(2) });
				storedExp.put(rset.getInt(1), rset.getLong(3));
			}
		}
		catch (Exception e)
		{
			LOGGER.log(Level.WARNING, getClass().getSimpleName() + ": Could not read progress.", e);
		}
		
		int maxCharacterId = 0;
		for (CharacterInfo info : _characters.values())
		{
			maxCharacterId = Math.max(maxCharacterId, info.id);
		}
		final int headlessFrom = maxCharacterId - FakePlayersConfig.FAKE_PLAYER_HEADLESS_COUNT;
		
		for (CharacterInfo info : _characters.values())
		{
			if (info.id > headlessFrom)
			{
				continue; // Reserved for headless players.
			}
			final CharacterState state = new CharacterState();
			state.info = info;
			if (stored.containsKey(info.id))
			{
				state.level = Math.min(MAX_LEVEL, Math.max(1, stored.get(info.id)[0]));
				state.exp = storedExp.getOrDefault(info.id, 0L);
			}
			else
			{
				state.level = Math.min(MAX_LEVEL, Math.max(1, info.startLevel));
				state.exp = 0;
				state.dirty = true;
			}
			state.expAtLastTrade = state.exp;
			state.levelAtLastTrade = state.level;
			state.nextTradeAt = System.currentTimeMillis() + Rnd.get(600000, FakePlayersConfig.FAKE_PLAYER_TRADE_COOLDOWN * 60000);
			_states.put(info.id, state);
		}
		saveDirty();
	}
	
	private void saveDirty()
	{
		try (Connection con = DatabaseFactory.getConnection();
			PreparedStatement statement = con.prepareStatement("REPLACE INTO fake_player_progress (char_id, level, exp) VALUES (?, ?, ?)"))
		{
			for (CharacterState state : _states.values())
			{
				if (state.dirty)
				{
					statement.setInt(1, state.info.id);
					statement.setInt(2, state.level);
					statement.setLong(3, state.exp);
					statement.addBatch();
					state.dirty = false;
				}
			}
			statement.executeBatch();
		}
		catch (Exception e)
		{
			LOGGER.log(Level.WARNING, getClass().getSimpleName() + ": Could not save progress.", e);
		}
	}
	
	public static int tierOf(int level)
	{
		int tier = 0;
		for (int i = 0; i < TIER_MIN_LEVEL.length; i++)
		{
			if (level >= TIER_MIN_LEVEL[i])
			{
				tier = i;
			}
		}
		return tier;
	}
	
	private ZoneInfo pickZone(int level, String race)
	{
		final List<ZoneInfo> matches = new ArrayList<>();
		for (ZoneInfo zone : _zones)
		{
			if ((level >= zone.minLevel) && (level <= zone.maxLevel))
			{
				// Racial starting areas host only their own race - a bot cannot plausibly travel there otherwise.
				if (!zone.race.isEmpty() && !zone.race.equals(race))
				{
					continue;
				}
				matches.add(zone);
			}
		}
		if (matches.isEmpty())
		{
			ZoneInfo best = null;
			int bestDistance = Integer.MAX_VALUE;
			for (ZoneInfo zone : _zones)
			{
				if (!zone.race.isEmpty() && !zone.race.equals(race))
				{
					continue;
				}
				final int distance = (level < zone.minLevel) ? (zone.minLevel - level) : (level - zone.maxLevel);
				if (distance < bestDistance)
				{
					bestDistance = distance;
					best = zone;
				}
			}
			return best;
		}
		return matches.get(Rnd.get(matches.size()));
	}
	
	/**
	 * Groups gankers and pk hunters of the same personality and level band into small squads
	 * that roam together. Followers spawn and move near their leader.
	 */
	private void buildSquads()
	{
		final Map<String, List<CharacterState>> buckets = new HashMap<>();
		for (CharacterState state : _states.values())
		{
			final String personality = state.info.personality;
			if (!"ganker".equals(personality) && !"pkk".equals(personality))
			{
				continue;
			}
			buckets.computeIfAbsent(personality + "_" + tierOf(state.level) + "_" + state.info.race + "_" + state.info.clanId, key -> new ArrayList<>()).add(state);
		}
		
		int squads = 0;
		for (List<CharacterState> bucket : buckets.values())
		{
			int index = 0;
			while ((bucket.size() - index) >= 2)
			{
				final int size = Math.min(2 + Rnd.get(3), bucket.size() - index);
				final CharacterState leader = bucket.get(index);
				for (int i = 1; i < size; i++)
				{
					bucket.get(index + i).squadLeaderId = leader.info.id;
				}
				index += size;
				squads++;
				// Leave some loners between squads.
				index += Rnd.get(2);
			}
		}
		LOGGER.info(getClass().getSimpleName() + ": Formed " + squads + " roaming squads.");
	}
	
	private void spawnAll()
	{
		// Leaders and loners first, followers second so they can join their leader.
		for (CharacterState state : _states.values())
		{
			if (state.squadLeaderId == 0)
			{
				try
				{
					spawnCharacter(state);
				}
				catch (Exception e)
				{
					LOGGER.log(Level.WARNING, getClass().getSimpleName() + ": Could not spawn " + state.info.name, e);
				}
			}
		}
		for (CharacterState state : _states.values())
		{
			if (state.squadLeaderId != 0)
			{
				try
				{
					spawnCharacter(state);
				}
				catch (Exception e)
				{
					LOGGER.log(Level.WARNING, getClass().getSimpleName() + ": Could not spawn " + state.info.name, e);
				}
			}
		}
	}
	
	private void spawnCharacter(CharacterState state) throws Exception
	{
		final int tier = tierOf(state.level);
		final int templateId = TEMPLATE_BASE + (state.info.variant * TIERS) + tier;
		final NpcTemplate template = NpcData.getInstance().getTemplate(templateId);
		if (template == null)
		{
			return;
		}
		
		ZoneInfo zone = null;
		Location point = null;
		final CharacterState leader = (state.squadLeaderId != 0) ? _states.get(state.squadLeaderId) : null;
		if ((leader != null) && !leader.trading && !leader.raiding && (leader.spawn != null) && (leader.zoneName != null))
		{
			for (ZoneInfo z : _zones)
			{
				if (z.name.equals(leader.zoneName))
				{
					zone = z;
					break;
				}
			}
			if (zone != null)
			{
				point = new Location(leader.spawn.getX() + Rnd.get(-150, 150), leader.spawn.getY() + Rnd.get(-150, 150), leader.spawn.getZ());
			}
		}
		if (zone == null)
		{
			zone = pickZone(state.level, state.info.race);
		}
		if (zone == null)
		{
			return;
		}
		if (point == null)
		{
			point = zone.points.get(Rnd.get(zone.points.size()));
		}
		final Spawn spawn = new Spawn(template);
		spawn.setXYZ(point);
		spawn.setAmount(1);
		spawn.setHeading(Rnd.get(65535));
		spawn.setRespawnDelay(60 + Rnd.get(120));
		SpawnTable.getInstance().addSpawn(spawn);
		spawn.init();
		
		state.tier = tier;
		state.zoneName = zone.name;
		state.spawn = spawn;
		state.npc = spawn.getLastSpawn();
		state.migrating = false;
		state.trading = false;
		state.raiding = false;
		if (state.npc != null)
		{
			_byNpcObjectId.put(state.npc.getObjectId(), state);
			applyIdentity(state.npc, state);
		}
		
		// Point whisper lookups at the currently spawned tier template.
		registerIdentity(state.info.name, templateId);
	}
	
	private ZoneInfo pickTown(int level, String race)
	{
		final List<ZoneInfo> matches = new ArrayList<>();
		for (ZoneInfo town : _towns)
		{
			if ((level >= town.minLevel) && (level <= town.maxLevel) && (town.race.isEmpty() || town.race.equals(race)))
			{
				matches.add(town);
			}
		}
		if (matches.isEmpty())
		{
			for (ZoneInfo town : _towns)
			{
				if (town.race.isEmpty() || town.race.equals(race))
				{
					matches.add(town);
				}
			}
		}
		return matches.isEmpty() ? null : matches.get(Rnd.get(matches.size()));
	}
	
	private void spawnTrader(CharacterState state, boolean sell) throws Exception
	{
		final int tier = tierOf(state.level);
		final int templateId = (sell ? SELL_TEMPLATE_BASE : BUY_TEMPLATE_BASE) + (state.info.variant * TIERS) + tier;
		final NpcTemplate template = NpcData.getInstance().getTemplate(templateId);
		final ZoneInfo town = pickTown(state.level, state.info.race);
		if ((template == null) || (town == null))
		{
			return;
		}
		
		final Location point = town.points.get(Rnd.get(town.points.size()));
		final Spawn spawn = new Spawn(template);
		spawn.setXYZ(point);
		spawn.setAmount(1);
		spawn.setHeading(Rnd.get(65535));
		spawn.setRespawnDelay(60);
		SpawnTable.getInstance().addSpawn(spawn);
		spawn.init();
		
		state.tier = tier;
		state.spawn = spawn;
		state.npc = spawn.getLastSpawn();
		state.trading = true;
		state.tradeEndsAt = System.currentTimeMillis() + (FakePlayersConfig.FAKE_PLAYER_TRADE_DURATION * 60000L);
		FakePlayerBotContext.setPlan(state.info.id, "trade", FakePlayersConfig.FAKE_PLAYER_TRADE_DURATION * 60000L);
		FakePlayerBotContext.setEvent(state.info.id, "opened_store");
		state.lastShoutAt = 0;
		final String storeMessage = (template.getFakePlayerInfo() != null) ? template.getFakePlayerInfo().getPrivateStoreMessage() : "";
		final java.util.HashMap<String, String> slots = new java.util.HashMap<>();
		slots.put("town", town.name);
		slots.put("msg", storeMessage);
		final String seamLine = FakePlayerChatLines.getInstance().pick("trade_shout", slots);
		state.shoutLine = (seamLine != null) ? seamLine : (storeMessage + " - " + town.name);
		if (state.npc != null)
		{
			_byNpcObjectId.put(state.npc.getObjectId(), state);
			applyIdentity(state.npc, state);
			final Npc npc = state.npc;
			FakePlayerLlmService.getInstance().generate(FakePlayerChatManager.getInstance().buildPersona(npc), "Ты открыл в городе " + town.name + " торговую лавку с сообщением \"" + storeMessage + "\". Придумай короткую зазывалку для торгового чата (без кавычек).", reply -> state.shoutLine = reply, null);
		}
		registerIdentity(state.info.name, templateId);
		LOGGER.info(getClass().getSimpleName() + ": " + state.info.name + " opened a " + (sell ? "sell" : "buy") + " shop in " + town.name + ".");
	}
	
	private void handleTrade(CharacterState state)
	{
		if (!FakePlayersConfig.FAKE_PLAYER_TRADING)
		{
			return;
		}
		
		final long now = System.currentTimeMillis();
		
		if (state.trading)
		{
			final Npc npc = (state.spawn != null) ? state.spawn.getLastSpawn() : null;
			if ((npc == null) || (now >= state.tradeEndsAt))
			{
				// Close shop and go back to farming.
				state.trading = false;
				state.expAtLastTrade = state.exp;
				state.levelAtLastTrade = state.level;
				state.nextTradeAt = now + (FakePlayersConfig.FAKE_PLAYER_TRADE_COOLDOWN * 60000L) + Rnd.get(0, 1800000);
				LOGGER.info(getClass().getSimpleName() + ": " + state.info.name + " closed the shop.");
				despawnCharacter(state);
				try
				{
					spawnCharacter(state);
				}
				catch (Exception e)
				{
					// Try again next trade cycle.
				}
				return;
			}
			
			// Trade channel shout.
			if ((now - state.lastShoutAt) >= (FakePlayersConfig.FAKE_PLAYER_TRADE_SHOUT_INTERVAL * 1000L))
			{
				state.lastShoutAt = now;
				if (state.shoutLine != null)
				{
					World.broadcastToVisiblePlayersInRange(npc, new CreatureSay(npc, ChatType.TRADE, npc.getName(), state.shoutLine), 9000);
				}
			}
			return;
		}
		
		if (now < state.nextTradeAt)
		{
			return;
		}
		
		final Npc npc = (state.spawn != null) ? state.spawn.getLastSpawn() : null;
		if ((npc == null) || npc.isDead() || npc.isInCombat() || state.raiding || FakePlayerAiTaskManager.getInstance().isCompanion(npc.getObjectId()))
		{
			state.nextTradeAt = now + 300000;
			return;
		}
		
		if (Rnd.get(100) >= FakePlayersConfig.FAKE_PLAYER_TRADE_CHANCE)
		{
			state.nextTradeAt = now + 600000;
			return;
		}
		
		// Sell when the bot has farmed since the last trade (loot to sell), buy otherwise.
		final long threshold = ((state.level > 0) && (state.level < _expToNext.length)) ? (_expToNext[state.level] / 5) : 10000;
		final boolean sell = (state.level > state.levelAtLastTrade) || (state.exp > (state.expAtLastTrade + threshold));
		despawnCharacter(state);
		try
		{
			spawnTrader(state, sell);
		}
		catch (Exception e)
		{
			state.trading = false;
			state.nextTradeAt = now + 600000;
			try
			{
				spawnCharacter(state);
			}
			catch (Exception ignored)
			{
				// Respawn on next tick refresh.
			}
		}
	}
	
	private void applyIdentity(Npc npc, CharacterState state)
	{
		npc.setName(state.info.name);
		npc.setFakePlayerClanId(state.info.clanId);
		npc.broadcastInfo();
	}
	
	private void registerIdentity(String name, int templateId)
	{
		FakePlayerData.getInstance().addFakePlayerId(name, templateId);
		FakePlayerData.getInstance().addFakePlayerName(name.toLowerCase(), name);
		FakePlayerData.getInstance().addTalkableFakePlayerName(name.toLowerCase());
	}
	
	private void despawnCharacter(CharacterState state)
	{
		if (state.npc != null)
		{
			_byNpcObjectId.remove(state.npc.getObjectId());
		}
		if (state.spawn != null)
		{
			state.spawn.stopRespawn();
			final Npc npc = state.spawn.getLastSpawn();
			if (npc != null)
			{
				npc.deleteMe();
			}
			SpawnTable.getInstance().removeSpawn(state.spawn);
		}
		state.spawn = null;
		state.npc = null;
	}
	
	/**
	 * Called when a fake player kills a monster.
	 * @param killer the fake player npc.
	 * @param victim the killed monster.
	 */
	public void onKill(Npc killer, Attackable victim)
	{
		if (!FakePlayersConfig.FAKE_PLAYER_PROGRESSION)
		{
			return;
		}
		
		final CharacterState state = _byNpcObjectId.get(killer.getObjectId());
		if ((state == null) || state.migrating || (state.level >= MAX_LEVEL))
		{
			return;
		}
		
		final long reward = (long) (victim.getTemplate().getExp() * FakePlayersConfig.FAKE_PLAYER_XP_RATE);
		if (reward <= 0)
		{
			return;
		}
		
		state.exp += reward;
		state.dirty = true;
		
		boolean leveled = false;
		while ((state.level < MAX_LEVEL) && (_expToNext[state.level] > 0) && (state.exp >= _expToNext[state.level]))
		{
			state.exp -= _expToNext[state.level];
			state.level++;
			leveled = true;
		}
		
		if (leveled)
		{
			final int newTier = tierOf(state.level);
			if (newTier != state.tier)
			{
				migrate(state);
			}
			else if (Rnd.get(100) < 40)
			{
				FakePlayerBotContext.setEvent(state.info.id, "levelup");
				FakePlayerChatManager.getInstance().manageEventChat(state.npc, "Ты только что взял " + state.level + " уровень. Порадуйся в чат одной короткой фразой.", FakePlayerChatLines.getInstance().pick("levelup", "level", String.valueOf(state.level)), "levelup");
			}
			saveDirty();
		}
	}
	
	private void migrate(CharacterState state)
	{
		state.migrating = true;
		final Npc npc = state.npc;
		if (npc != null)
		{
			FakePlayerBotContext.setPlan(state.info.id, "migrate", 180000);
			FakePlayerChatManager.getInstance().manageEventChat(npc, "Ты апнул " + state.level + " уровень и перерос эту локацию. Попрощайся: уходишь в город за новым шмотом.", FakePlayerChatLines.getInstance().pick("migrate", null), "migrate");
		}
		
		// Leave in a moment, come back somewhere better dressed.
		ThreadPool.schedule(() -> despawnCharacter(state), 8000 + Rnd.get(7000));
		ThreadPool.schedule(() ->
		{
			try
			{
				spawnCharacter(state);
			}
			catch (Exception e)
			{
				LOGGER.log(Level.WARNING, getClass().getSimpleName() + ": Migration failed for " + state.info.name, e);
			}
		}, 60000 + Rnd.get(120000));
	}
	
	private void tick()
	{
		try
		{
			_tick++;
			for (CharacterState state : _states.values())
			{
				try
				{
					handleTrade(state);
					if (!state.trading)
					{
						handleMovement(state);
					}
				}
				catch (Exception e)
				{
					// Keep the loop alive.
				}
			}
			
			if ((_tick % SAVE_EVERY_TICKS) == 0)
			{
				saveDirty();
			}
			
			handleCastleMood();
			handleRaidEvent();
		}
		catch (Exception e)
		{
			// Task must survive.
		}
	}
	
	private void handleMovement(CharacterState state)
	{
		final Npc npc = (state.spawn != null) ? state.spawn.getLastSpawn() : null;
		if ((npc == null) || npc.isDead())
		{
			return;
		}
		
		if (npc != state.npc)
		{
			// Respawned instance.
			if (state.npc != null)
			{
				_byNpcObjectId.remove(state.npc.getObjectId());
			}
			state.npc = npc;
			_byNpcObjectId.put(npc.getObjectId(), state);
			applyIdentity(npc, state);
		}
		
		if (npc.isInCombat() || (npc.getAI().getIntention() == Intention.ATTACK))
		{
			return;
		}
		
		// Do not interrupt loot pickup.
		if (!npc.getFakePlayerDrops().isEmpty())
		{
			return;
		}
		
		// Raiding bots stay with the boss.
		if (state.raiding)
		{
			return;
		}
		
		// Escorting a player: the companion logic owns movement.
		if (FakePlayerAiTaskManager.getInstance().isCompanion(npc.getObjectId()))
		{
			return;
		}
		
		// Squad followers stick to their leader.
		if (state.squadLeaderId != 0)
		{
			final CharacterState leader = _states.get(state.squadLeaderId);
			if ((leader != null) && !leader.trading && !leader.raiding && !leader.migrating && (leader.spawn != null) && leader.zoneName != null && leader.zoneName.equals(state.zoneName))
			{
				final long now2 = System.currentTimeMillis();
				if ((now2 - state.lastMove) < MOVE_INTERVAL)
				{
					return;
				}
				state.lastMove = now2;
				final Location follow = new Location(leader.spawn.getX() + Rnd.get(-120, 120), leader.spawn.getY() + Rnd.get(-120, 120), leader.spawn.getZ());
				if (npc.calculateDistance2D(follow.getX(), follow.getY(), follow.getZ()) > 4000)
				{
					npc.teleToLocation(follow);
				}
				state.spawn.setXYZ(follow);
				npc.setRunning();
				npc.getAI().setIntentionMoveTo(follow);
				return;
			}
		}
		
		final long now = System.currentTimeMillis();
		if ((now - state.lastMove) < (MOVE_INTERVAL * (1 + Rnd.get(3))))
		{
			return;
		}
		state.lastMove = now;
		
		ZoneInfo zone = null;
		for (ZoneInfo z : _zones)
		{
			if (z.name.equals(state.zoneName))
			{
				zone = z;
				break;
			}
		}
		if (zone == null)
		{
			return;
		}
		
		// Prefer a reachable next point, fall back to any zone point.
		Location next = null;
		for (int attempt = 0; attempt < 5; attempt++)
		{
			final Location candidate = zone.points.get(Rnd.get(zone.points.size()));
			if (npc.calculateDistance2D(candidate.getX(), candidate.getY(), candidate.getZ()) < MAX_MOVE_STEP)
			{
				next = candidate;
				break;
			}
		}
		if (next == null)
		{
			next = zone.points.get(Rnd.get(zone.points.size()));
			npc.teleToLocation(next);
		}
		
		// Move the leash anchor along so built-in AI does not drag the bot back.
		state.spawn.setXYZ(next);
		if (Rnd.nextBoolean())
		{
			npc.setRunning();
		}
		else
		{
			npc.setWalking();
		}
		npc.getAI().setIntentionMoveTo(next);
	}
	
	/**
	 * Periodically sends a squad of high level bots to hunt a live raid boss.
	 */
	private void handleRaidEvent()
	{
		final long now = System.currentTimeMillis();
		
		// Active raid: check end conditions.
		if (_raidBossObjectId != 0)
		{
			final org.l2jmobius.gameserver.entity.WorldObject bossObject = World.findObject(_raidBossObjectId);
			final boolean bossGone = (bossObject == null) || !(bossObject instanceof Npc bossNpc) || bossNpc.isDead();
			if (bossGone || (now >= _raidEndsAt))
			{
				for (CharacterState state : _states.values())
				{
					if (state.raiding)
					{
						state.raiding = false;
						FakePlayerBotContext.setPlan(state.info.id, "farm", 0);
						FakePlayerBotContext.setEvent(state.info.id, "raid_finished");
						despawnCharacter(state);
						try
						{
							spawnCharacter(state);
						}
						catch (Exception ignored)
						{
							// Next refresh will retry.
						}
					}
				}
				LOGGER.info(getClass().getSimpleName() + ": Raid event finished (" + (bossGone ? "boss dead" : "timeout") + ").");
				_raidBossObjectId = 0;
				_nextRaidAt = now + 1800000 + Rnd.get(0, 3600000);
			}
			return;
		}
		
		if (now < _nextRaidAt)
		{
			return;
		}
		
		// Pick a live raid boss of a reasonable level.
		final List<Npc> bosses = new ArrayList<>();
		for (Npc boss : RaidBossSpawnManager.getInstance().getBosses().values())
		{
			if ((boss != null) && !boss.isDead() && (boss.getTemplate().getLevel() >= 55) && (boss.getTemplate().getLevel() <= 80))
			{
				bosses.add(boss);
			}
		}
		if (bosses.isEmpty())
		{
			_nextRaidAt = now + 3600000;
			return;
		}
		final Npc boss = bosses.get(Rnd.get(bosses.size()));
		
		// Pick the raid squad: up to 5 high level bots that are free right now.
		final List<CharacterState> party = new ArrayList<>();
		for (CharacterState state : _states.values())
		{
			if ((state.level >= 60) && !state.trading && !state.migrating && !state.raiding && (state.spawn != null) && (state.spawn.getLastSpawn() != null) && !state.spawn.getLastSpawn().isDead())
			{
				party.add(state);
				if (party.size() >= 5)
				{
					break;
				}
			}
		}
		if (party.size() < 3)
		{
			_nextRaidAt = now + 1800000;
			return;
		}
		
		_raidBossObjectId = boss.getObjectId();
		_raidEndsAt = now + 1500000; // 25 minutes.
		boolean first = true;
		for (CharacterState state : party)
		{
			FakePlayerBotContext.setRaid(state.info.id, boss.getName(), 25 * 60000L);
			state.raiding = true;
			final Npc npc = state.spawn.getLastSpawn();
			final Location spot = new Location(boss.getX() + Rnd.get(-200, 200), boss.getY() + Rnd.get(-200, 200), boss.getZ());
			state.spawn.setXYZ(spot);
			npc.teleToLocation(spot);
			if (npc instanceof Attackable attackable)
			{
				attackable.addDamageHate(boss, 0, 1000);
				npc.setRunning();
				npc.getAI().setIntentionAttack(boss);
			}
			if (first)
			{
				first = false;
				FakePlayerChatManager.getInstance().manageEventChat(npc, "Вы с пати пришли убивать рейд-босса " + boss.getName() + ". Крикни боевой клич.", FakePlayerChatLines.getInstance().pick("raid_start", "boss", boss.getName()), "raid_start");
			}
		}
		LOGGER.info(getClass().getSimpleName() + ": Raid event started - " + party.size() + " bots vs " + boss.getName() + " (lvl " + boss.getTemplate().getLevel() + ").");
	}
	
	/**
	 * Bot clan lords change their castle tax rate now and then, depending on "mood".
	 */
	private void handleCastleMood()
	{
		final long now = System.currentTimeMillis();
		for (org.l2jmobius.gameserver.mechanics.siege.Castle castle : CastleManager.getInstance().getCastles())
		{
			final int ownerId = castle.getOwnerId();
			if ((ownerId < 300000001) || (ownerId > 300000999))
			{
				continue; // Not a seeded bot clan.
			}
			
			final long next = _castleMoodAt.computeIfAbsent(castle.getResidenceId(), key -> now + Rnd.get(1800000, 14400000));
			if (now < next)
			{
				continue;
			}
			_castleMoodAt.put(castle.getResidenceId(), now + Rnd.get(3 * 3600000, 8 * 3600000));
			
			final int oldTax = castle.getTaxPercent();
			int newTax = Math.max(0, Math.min(15, oldTax + Rnd.get(-7, 7)));
			if (newTax == oldTax)
			{
				newTax = (oldTax == 0) ? Rnd.get(1, 5) : 0;
			}
			castle.setTaxPercent(newTax);
			final org.l2jmobius.gameserver.entity.clan.Clan owner = org.l2jmobius.gameserver.data.sql.ClanTable.getInstance().getClan(ownerId);
			LOGGER.info(getClass().getSimpleName() + ": Lord mood swing - " + ((owner != null) ? owner.getName() : ownerId) + " set " + castle.getName() + " tax to " + newTax + "% (was " + oldTax + "%).");
		}
	}
	
	/**
	 * @param npcObjectId object id of a fake player npc.
	 * @return the managed character id, or 0 when unknown.
	 */
	public int getCharacterId(int npcObjectId)
	{
		final CharacterState state = _byNpcObjectId.get(npcObjectId);
		return (state != null) ? state.info.id : 0;
	}
	
	/**
	 * @param npcObjectId npc object id.
	 * @return zone name the bot lives in, or empty string.
	 */
	public String getZoneName(int npcObjectId)
	{
		final CharacterState state = _byNpcObjectId.get(npcObjectId);
		return ((state != null) && (state.zoneName != null)) ? state.zoneName : "";
	}
	
	/**
	 * @param npcObjectId npc object id.
	 * @return the bot's tracked level, or 0.
	 */
	public int getLevel(int npcObjectId)
	{
		final CharacterState state = _byNpcObjectId.get(npcObjectId);
		return (state != null) ? state.level : 0;
	}
	
	/**
	 * Called when a fake player dies from a real player. Feeds bot memory and produces a death line.
	 * @param npc the dead bot.
	 * @param killer the player.
	 */
	public void onBotKilled(Npc npc, org.l2jmobius.gameserver.entity.actor.Player killer)
	{
		final CharacterState state = _byNpcObjectId.get(npc.getObjectId());
		if (state == null)
		{
			return;
		}
		FakePlayerMemoryManager.getInstance().onKilledBy(state.info.id, killer.getName());
		
		// Nearby bots witness the kill and remember the story (rumor seed).
		final String victimName = state.info.name;
		World.forEachVisibleObjectInRange(npc, Npc.class, 900, witness ->
		{
			if (witness.isFakePlayer() && (witness != npc))
			{
				final int witnessId = getCharacterId(witness.getObjectId());
				if (witnessId > 0)
				{
					FakePlayerRumorManager.getInstance().addWitnessed(witnessId, (killer.getKarma() > 0) ? "pk" : "kill", killer.getName(), victimName);
				}
			}
		});
		FakePlayerBotContext.setEvent(getCharacterId(npc.getObjectId()), "ganked");
		// Dying is salient: the victim reacts reliably (and always after a typing pause,
		// so the witness shouts may legitimately come first by a second).
		if (Rnd.get(100) < Math.max(FakePlayersConfig.FAKE_PLAYER_COMBAT_CHAT_CHANCE, 75))
		{
			final String deathCry = FakePlayerChatLines.getInstance().compose(getCharacterId(npc.getObjectId()), "killed_by", state.info.personality, "player", killer.getName());
			if (deathCry != null)
			{
				org.l2jmobius.commons.threads.ThreadPool.schedule(() -> FakePlayerChatManager.getInstance().broadcastDeathCry(npc, deathCry, "killed_by"), Rnd.get(1500, 4000));
			}
		}
	}
	
	private static String json(String value)
	{
		if (value == null)
		{
			return "\"\"";
		}
		final StringBuilder sb = new StringBuilder("\"");
		for (char c : value.toCharArray())
		{
			switch (c)
			{
				case '"':
					sb.append("\\\"");
					break;
				case '\\':
					sb.append("\\\\");
					break;
				case '\n':
				case '\r':
					sb.append(' ');
					break;
				default:
					sb.append(c);
			}
		}
		return sb.append('"').toString();
	}
	
	/**
	 * @return JSON array with the live state of all managed bots (for the dashboard).
	 */
	public String statusJson()
	{
		final StringBuilder sb = new StringBuilder(1 << 20);
		sb.append('[');
		boolean first = true;
		for (CharacterState state : _states.values())
		{
			final Npc npc = (state.spawn != null) ? state.spawn.getLastSpawn() : null;
			if (!first)
			{
				sb.append(',');
			}
			first = false;
			String activity = "idle";
			String target = "";
			int x = 0, y = 0, z = 0, hp = 0;
			if (npc != null)
			{
				x = npc.getX();
				y = npc.getY();
				z = npc.getZ();
				hp = (int) (npc.getCurrentHp() * 100 / Math.max(1, npc.getMaxHp()));
				if (npc.isDead())
				{
					activity = "dead";
				}
				else if (state.trading)
				{
					activity = "trading";
				}
				else if (state.raiding)
				{
					activity = "raiding";
				}
				else if (FakePlayerAiTaskManager.getInstance().isCompanion(npc.getObjectId()))
				{
					activity = "companion";
				}
				else if (npc.isInCombat())
				{
					activity = "combat";
				}
				else if (npc.isMoving())
				{
					activity = "moving";
				}
				final org.l2jmobius.gameserver.entity.WorldObject targetObject = npc.getTarget();
				if (targetObject != null)
				{
					target = targetObject.getName();
				}
			}
			else
			{
				activity = state.migrating ? "migrating" : "despawned";
			}
			sb.append("{\"id\":").append(state.info.id)
				.append(",\"name\":").append(json(state.info.name))
				.append(",\"level\":").append(state.level)
				.append(",\"personality\":").append(json(state.info.personality))
				.append(",\"race\":").append(json(state.info.race))
				.append(",\"clanId\":").append(state.info.clanId)
				.append(",\"zone\":").append(json(state.zoneName != null ? state.zoneName : ""))
				.append(",\"activity\":").append(json(activity))
				.append(",\"target\":").append(json(target))
				.append(",\"hp\":").append(hp)
				.append(",\"x\":").append(x).append(",\"y\":").append(y).append(",\"z\":").append(z)
				.append('}');
		}
		return sb.append(']').toString();
	}
	
	/**
	 * Static description of a seeded character (used by headless players).
	 */
	public static class SeedInfo
	{
		public int id;
		public String name;
		public String personality;
		public String race;
		public int clanId;
		public int startLevel;
	}
	
	/**
	 * @param name character name.
	 * @return seed info or null.
	 */
	public SeedInfo getSeedInfo(String name)
	{
		for (CharacterInfo info : _characters.values())
		{
			if (info.name.equalsIgnoreCase(name))
			{
				final SeedInfo seed = new SeedInfo();
				seed.id = info.id;
				seed.name = info.name;
				seed.personality = info.personality;
				seed.race = info.race;
				seed.clanId = info.clanId;
				seed.startLevel = info.startLevel;
				return seed;
			}
		}
		return null;
	}
	
	/**
	 * Picks a farming spot for a level and race (shared zone table).
	 * @param level character level.
	 * @param race race name.
	 * @return location and zone name, or null.
	 */
	public Object[] pickSpot(int level, String race)
	{
		final ZoneInfo zone = pickZone(level, race);
		if (zone == null)
		{
			return null;
		}
		return new Object[]
		{
			zone.points.get(Rnd.get(zone.points.size())),
			zone.name
		};
	}
	
	/**
	 * Picks a town for a level and race (the town a character of that level would trade in).
	 * @param level character level.
	 * @param race race name.
	 * @return location and town name, or null.
	 */
	public Object[] pickTownSpot(int level, String race)
	{
		final ZoneInfo town = pickTown(level, race);
		if (town == null)
		{
			return null;
		}
		return new Object[]
		{
			town.points.get(Rnd.get(town.points.size())),
			town.name
		};
	}
	
	public static FakePlayerProgressionManager getInstance()
	{
		return SingletonHolder.INSTANCE;
	}
	
	private static class SingletonHolder
	{
		protected static final FakePlayerProgressionManager INSTANCE = new FakePlayerProgressionManager();
	}
}
