/*
 * 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.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import org.l2jmobius.commons.threads.ThreadPool;
import org.l2jmobius.commons.util.Rnd;
import org.l2jmobius.gameserver.config.custom.FakePlayersConfig;
import org.l2jmobius.gameserver.data.xml.MapRegionData;
import org.l2jmobius.gameserver.entity.World;
import org.l2jmobius.gameserver.entity.WorldObject;
import org.l2jmobius.gameserver.entity.actor.Creature;
import org.l2jmobius.gameserver.entity.actor.Player;
import org.l2jmobius.gameserver.entity.actor.instance.Monster;
import org.l2jmobius.gameserver.entity.clan.Clan;
import org.l2jmobius.gameserver.entity.groups.Party;
import org.l2jmobius.gameserver.entity.groups.PartyDistributionType;
import org.l2jmobius.gameserver.managers.FakePlayerChatManager.Understanding;
import org.l2jmobius.gameserver.network.enums.ChatType;
import org.l2jmobius.gameserver.network.serverpackets.AskJoinParty;
import org.l2jmobius.gameserver.network.serverpackets.CreatureSay;

/**
 * The social life of headless bots: everything that is perception and talk
 * rather than combat. Runs every tick BEFORE the combat brain, so a bot keeps
 * noticing players, clan enemies and neighbours while it farms - real players
 * type while fighting too.
 * <ul>
 * <li>personality triggers: gankers hunt players, pk hunters hunt flagged/red ones, helpers assist, clan wars;</li>
 * <li>calls for help when losing a fight, clanmates answer;</li>
 * <li>ambient talk near real players (memory, rumors, invitations, party search);</li>
 * <li>bot to bot talk: greetings when meeting, small talk openers, gossip - every reply goes through the same
 * understanding pipeline players get, so bots answer each other's questions;</li>
 * <li>real parties between clanmates, party invitations to players;</li>
 * <li>grounding slots for every line the bot says: zone, level, town, mob, crowd, last drop.</li>
 * </ul>
 * @author Mobius SPP
 */
public class FakePlayerSocial
{
	private static final java.util.logging.Logger LOGGER = java.util.logging.Logger.getLogger(FakePlayerSocial.class.getName());
	private static final int NOTICE_RANGE = 1000;
	private static final int AMBIENT_RANGE = 700;
	private static final int WITNESS_RANGE = 1600; // A real player who can see bots talking to each other.
	private static final int PEER_RANGE = 600;
	private static final long AMBIENT_COOLDOWN = 90000;
	private static final long TAUNT_COOLDOWN = 20000;
	private static final long MEET_MEMORY = 30 * 60000L;
	private static final int MAX_DIALOGUE_DEPTH = 3;

	/** Pending party invitations from bots to players: bot objectId - player objectId + deadline. */
	private final Map<Integer, long[]> _pendingInvites = new ConcurrentHashMap<>();

	protected FakePlayerSocial()
	{
	}

	/**
	 * Perception and talk for one bot. Cheap: every branch is cooldown gated.
	 * @param bot the bot.
	 * @param state its state.
	 * @param now current time.
	 * @return true when the bot started an exclusive action (fight, assist) and the tick should end.
	 */
	public boolean tick(Player bot, FakePlayerBotState state, long now)
	{
		if (now > state.nextSlotRefresh)
		{
			state.nextSlotRefresh = now + 30000;
			refreshSlots(bot, state);
		}

		final FakePlayerBotRef ref = FakePlayerBotRef.ofHeadless(bot);
		final Player human = humanPartyAnchor(bot);

		// Losing a PvP fight: shout for help, clanmates nearby join in.
		final WorldObject target = bot.getTarget();
		if (bot.isInCombat() && (target instanceof Player attacker) && (attacker != bot) && !attacker.isDead() && (attacker.getTarget() == bot) && (bot.getCurrentHp() < (bot.getMaxHp() * 0.6)) && ((now - state.lastHelpCall) > 45000))
		{
			state.lastHelpCall = now;
			final String cry = FakePlayerUnderstanding.eventLine(ref, "call_help", attacker, null);
			if (cry != null)
			{
				FakePlayerHeadlessManager.getInstance().shout(bot, cry, "call_help");
			}
			final int clanId = bot.getClanId();
			if (clanId > 0)
			{
				World.forEachVisibleObjectInRange(bot, Player.class, 1500, ally ->
				{
					if (ally.isHeadlessBot() && (ally != bot) && !ally.isDead() && !ally.isInCombat() && (ally.getClanId() == clanId))
					{
						FakePlayerHeadlessManager.getInstance().forceTarget(ally, attacker, 40);
					}
				});
			}
		}

		// Credit players helping this bot with its monster.
		if (bot.isInCombat() && (target instanceof Monster monsterTarget) && ((now - state.lastHelpCredit) > 300000))
		{
			final Player helper = World.getNearestVisibleObjectInRange(bot, Player.class, 900, other -> !other.isDead() && !other.isHeadlessBot() && other.isInCombat() && (other.getTarget() == monsterTarget));
			if (helper != null)
			{
				state.lastHelpCredit = now;
				FakePlayerMemoryManager.getInstance().onHelpedBy(state.seedId, helper.getName());
			}
		}

		// Personality actions against real players (not while escorting a human party).
		if ((human == null) && (state.forcedTargetId == 0) && !FakePlayerSiegeManager.getInstance().isBusy(state))
		{
			if (FakePlayersConfig.FAKE_PLAYER_AGGRO_PLAYERS)
			{
				Player victim = null;
				String seam = null;
				String instruction = null;
				if ("ganker".equals(state.personality))
				{
					victim = World.getNearestVisibleObjectInRange(bot, Player.class, NOTICE_RANGE, other -> !other.isDead() && !other.isHeadlessBot() && !other.isInvisible() && !other.isGM() && (Math.abs(other.getLevel() - bot.getLevel()) <= 12));
					seam = "gank_start";
					instruction = "Ты нападаешь на игрока. Крикни что-нибудь дерзкое.";
				}
				else if ("pkk".equals(state.personality))
				{
					victim = World.getNearestVisibleObjectInRange(bot, Player.class, NOTICE_RANGE, other -> !other.isDead() && !other.isInvisible() && (other != bot) && ((other.getKarma() > 0) || ((other.getPvpFlag() != 0) && !other.isHeadlessBot())));
					seam = "pkk_start";
					instruction = "Ты охотник на ПК и нападаешь на нарушителя. Крикни про правосудие.";
				}
				if ((victim != null) && (victim.getObjectId() != state.lastPvpTargetId))
				{
					state.lastPvpTargetId = victim.getObjectId();
					state.lastChat = now;
					state.action("pvp:" + victim.getName());
					FakePlayerHeadlessManager.getInstance().forceTarget(bot, victim, 60);
					if (Rnd.get(100) < FakePlayersConfig.FAKE_PLAYER_COMBAT_CHAT_CHANCE)
					{
						eventChat(bot, state, instruction, seam, Map.of("target", victim.getName()), victim);
					}
					return true;
				}
			}

			// Clan war: attack players and bots of clans at war with ours.
			final Clan clan = bot.getClan();
			if ((clan != null) && !bot.isInCombat() && (Rnd.get(100) < 40))
			{
				final Player enemy = World.getNearestVisibleObjectInRange(bot, Player.class, 1200, other -> !other.isDead() && (other != bot) && !other.isInvisible() && (other.getClan() != null) && (clan.isAtWarWith(other.getClanId()) || other.getClan().isAtWarWith(clan.getId())));
				if (enemy != null)
				{
					state.action("clanwar:" + enemy.getName());
					FakePlayerHeadlessManager.getInstance().forceTarget(bot, enemy, 45);
					if (((now - state.lastChat) > TAUNT_COOLDOWN) && (Rnd.get(100) < FakePlayersConfig.FAKE_PLAYER_COMBAT_CHAT_CHANCE))
					{
						state.lastChat = now;
						final Map<String, String> slots = new HashMap<>(2);
						slots.put("target", enemy.getName());
						slots.put("enemyclan", enemy.getClan().getName());
						eventChat(bot, state, "Клан игрока " + enemy.getName() + " воюет с твоим кланом, ты его атакуешь. Крикни боевую фразу.", "war_start", slots, enemy);
					}
					return true;
				}
			}

			// Helpers assist real players fighting monsters.
			if ("helper".equals(state.personality) && !bot.isInCombat())
			{
				final Player ally = World.getNearestVisibleObjectInRange(bot, Player.class, NOTICE_RANGE, other -> !other.isDead() && !other.isHeadlessBot() && other.isInCombat());
				if (ally != null)
				{
					final WorldObject allyTarget = ally.getTarget();
					if ((allyTarget instanceof Monster monster) && !monster.isDead() && (bot.calculateDistance2D(monster) < 1500))
					{
						bot.setTarget(monster);
						bot.getAI().setIntentionAttack(monster);
						state.action("assist:" + ally.getName());
						if ((now - state.lastChat) > TAUNT_COOLDOWN)
						{
							state.lastChat = now;
							if (Rnd.get(100) < FakePlayersConfig.FAKE_PLAYER_COMBAT_CHAT_CHANCE)
							{
								eventChat(bot, state, "Ты помогаешь игроку " + ally.getName() + " убить монстра. Скажи что-то дружелюбное.", "assist", Map.of("player", ally.getName()), ally);
							}
						}
						return true;
					}
				}
			}
		}

		// Companion mode with a real player (they asked for help / party): escort is handled by the party
		// logic when a real party exists; a plain escort ends after its time.
		if ((state.companionPlayerId != 0) && (now > state.companionUntil))
		{
			state.companionPlayerId = 0;
			if (bot.getParty() == null)
			{
				eventChat(bot, state, "Ты пофармил с игроком в паре и уходишь по своим делам. Попрощайся.", "companion_done", null, null);
			}
		}

		// Squads: headless clanmates form REAL parties with each other.
		if ((bot.getParty() == null) && (now > state.partyCooldown) && (bot.getClanId() > 0) && (Rnd.get(100) < 40))
		{
			state.partyCooldown = now + 180000;
			final Player mate = World.getNearestVisibleObjectInRange(bot, Player.class, 1200, other -> other.isHeadlessBot() && (other != bot) && !other.isDead() && (other.getClanId() == bot.getClanId()) && (Math.abs(other.getLevel() - bot.getLevel()) <= 10) && ((other.getParty() == null) || ((other.getParty().getMemberCount() < 6) && (humanPartyAnchor(other) == null))));
			if (mate != null)
			{
				final Party squad;
				if (mate.getParty() != null)
				{
					squad = mate.getParty();
					bot.joinParty(squad);
				}
				else
				{
					squad = new Party(bot, bot.getPartyDistributionType());
					bot.setParty(squad);
					mate.joinParty(squad);
				}
				state.partiesJoined.incrementAndGet();
				final FakePlayerBotState mateState = FakePlayerHeadlessManager.getInstance().stateOf(mate);
				if (mateState != null)
				{
					mateState.partiesJoined.incrementAndGet();
				}
				state.action("squad:" + mate.getName());
				final String invite = FakePlayerUnderstanding.eventLine(ref, "invite_player", mate, null);
				if (invite != null)
				{
					converse(bot, mate, invite, "invite_player", 1);
				}
			}
		}
		// Everything below is talk - only worth it when a real player can see it.
		final Player witness = World.getNearestVisibleObjectInRange(bot, Player.class, WITNESS_RANGE, other -> !other.isDead() && !other.isHeadlessBot() && !other.isInvisible());
		if (witness == null)
		{
			return false;
		}

		// Ambient talk when a real player is close (memory and rumors aware).
		if (((now - state.lastChat) > AMBIENT_COOLDOWN) && (Rnd.get(100) < FakePlayersConfig.FAKE_PLAYER_AMBIENT_CHAT_CHANCE))
		{
			final Player nearby = World.getNearestVisibleObjectInRange(bot, Player.class, AMBIENT_RANGE, other -> !other.isDead() && !other.isHeadlessBot() && !other.isInvisible());
			if (nearby != null)
			{
				state.lastChat = now;
				ambientTalk(bot, state, ref, nearby, now);
				return false;
			}
		}

		// Bot to bot: greet a neighbour met for the first time in a while.
		if (FakePlayersConfig.FAKE_PLAYER_BOT_DIALOGUES && (now > state.nextBotTalk))
		{
			final Player peer = World.getNearestVisibleObjectInRange(bot, Player.class, PEER_RANGE, other -> other.isHeadlessBot() && (other != bot) && !other.isDead() && !other.isInStoreMode());
			if (peer != null)
			{
				final Long lastMet = state.met.get(peer.getObjectId());
				if ((lastMet == null) || ((now - lastMet) > MEET_MEMORY))
				{
					state.met.put(peer.getObjectId(), now);
					if (state.met.size() > 64)
					{
						state.met.entrySet().removeIf(entry -> (now - entry.getValue()) > MEET_MEMORY);
					}
					state.nextBotTalk = now + Rnd.get(40000, 120000);
					if (Rnd.get(100) < 45)
					{
						final String hello = FakePlayerUnderstanding.eventLine(ref, "greeting", peer, null);
						if (hello != null)
						{
							state.action("greet:" + peer.getName());
							converse(bot, peer, hello, "greeting", 1);
						}
					}
				}
				else if (Rnd.get(100) < 35)
				{
					// Small talk opener addressed to the neighbour; it answers through the understanding pipeline.
					state.nextBotTalk = now + Rnd.get(120000, 300000);
					final String opener = FakePlayerUnderstanding.optionalLine(ref, "bot_talk", peer, null);
					if (opener != null)
					{
						state.action("talk:" + peer.getName());
						converse(bot, peer, opener, "bot_talk", 1);
					}
				}
				else
				{
					state.nextBotTalk = now + Rnd.get(30000, 90000);
				}
			}
		}

		// Gossip: stories travel between bots when they meet, players nearby overhear.
		if ((now > state.nextGossip) && !bot.isInCombat())
		{
			state.nextGossip = now + Rnd.get(180000, 420000);
			final Player listener = World.getNearestVisibleObjectInRange(bot, Player.class, 400, other -> other.isHeadlessBot() && (other != bot) && !other.isDead());
			if (listener != null)
			{
				final FakePlayerRumorManager.Story passed = FakePlayerRumorManager.getInstance().gossip(state.seedId, FakePlayerHeadlessManager.getInstance().seedIdOf(listener));
				if (passed != null)
				{
					final String story = FakePlayerRumorManager.getInstance().tell(passed).replace(" ты ", " " + passed.subject + " ");
					FakePlayerHeadlessManager.getInstance().say(bot, "слышал? " + story, "gossip");
					state.action("gossip");
				}
			}
		}

		// Party search in the trade channel now and then (real level and zone).
		if ((now > state.nextLfp) && (bot.getParty() == null) && !bot.isInCombat())
		{
			state.nextLfp = now + Rnd.get(600000, 1800000);
			if (Rnd.get(100) < 35)
			{
				final String lfp = FakePlayerUnderstanding.optionalLine(ref, "lfp", null, null);
				if (lfp != null)
				{
					FakePlayerHeadlessManager.getInstance().trade(bot, lfp, "lfp");
					state.action("lfp");
				}
			}
		}

		return false;
	}

	private void ambientTalk(Player bot, FakePlayerBotState state, FakePlayerBotRef ref, Player nearby, long now)
	{
		final FakePlayerMemoryManager.Memory memory = FakePlayerMemoryManager.getInstance().get(state.seedId, nearby.getName());
		final FakePlayerRumorManager.Story rumor = FakePlayerRumorManager.getInstance().bestAbout(state.seedId, nearby.getName());
		if ((memory != null) && (memory.score <= -2))
		{
			eventChat(bot, state, "Рядом игрок " + nearby.getName() + ", который тебя убивал. Скажи что-то злопамятное.", "revenge_meet", Map.of("player", nearby.getName()), nearby);
		}
		else if ((memory != null) && (memory.score >= 2))
		{
			eventChat(bot, state, "Рядом игрок " + nearby.getName() + ", который тебе помогал. Поприветствуй тепло.", "friendly_meet", Map.of("player", nearby.getName()), nearby);
		}
		else if ((rumor != null) && (Rnd.get(100) < 50))
		{
			FakePlayerHeadlessManager.getInstance().say(bot, FakePlayerRumorManager.getInstance().tell(rumor), "rumor");
		}
		else if ((("helper".equals(state.personality)) || ("neutral".equals(state.personality))) && (bot.getParty() == null) && (nearby.getParty() == null) && (Rnd.get(100) < 25))
		{
			// Invite the player to team up; a "yes" within a minute makes it a real party.
			_pendingInvites.put(bot.getObjectId(), new long[]
			{
				nearby.getObjectId(),
				now + 60000
			});
			eventChat(bot, state, "Рядом игрок " + nearby.getName() + ". Позови его в пати.", "invite_player", Map.of("player", nearby.getName()), nearby);
		}
		else if ((memory == null) && (Rnd.get(100) < 20))
		{
			eventChat(bot, state, "Рядом незнакомый игрок " + nearby.getName() + ". Поздоровайся.", "greeting", Map.of("player", nearby.getName()), nearby);
		}
		else
		{
			eventChat(bot, state, "Рядом игрок. Скажи что-нибудь бытовое про фарм, дроп или спот.", "ambient", null, null);
		}
	}

	/**
	 * Refreshes the grounding slots every line of this bot can use.
	 * @param bot the bot.
	 * @param state its state.
	 */
	private void refreshSlots(Player bot, FakePlayerBotState state)
	{
		final int botId = state.seedId;
		if (botId <= 0)
		{
			return;
		}
		FakePlayerBotContext.setSlot(botId, "zone", state.zone.isEmpty() ? "поле" : state.zone);
		FakePlayerBotContext.setSlot(botId, "level", String.valueOf(bot.getLevel()));
		try
		{
			FakePlayerBotContext.setSlot(botId, "town", MapRegionData.getInstance().getClosestTownName(bot));
		}
		catch (Exception e)
		{
			// No region data - keep the old value.
		}
		final WorldObject target = bot.getTarget();
		FakePlayerBotContext.setSlot(botId, "mob", (target instanceof Monster monster) ? monster.getName() : null);
		int crowd = 0;
		for (Player other : World.getVisibleObjectsInRange(bot, Player.class, 1000))
		{
			if ((other != bot) && !other.isDead())
			{
				crowd++;
			}
		}
		FakePlayerBotContext.setSlot(botId, "crowd", String.valueOf(crowd));
		FakePlayerBotContext.setSlot(botId, "drop", state.lastDrop);
		final Clan clan = bot.getClan();
		if ((clan != null) && !clan.getWarList().isEmpty())
		{
			final Clan enemy = org.l2jmobius.gameserver.data.sql.ClanTable.getInstance().getClan(clan.getWarList().iterator().next());
			FakePlayerBotContext.setSlot(botId, "enemyclan", (enemy != null) ? enemy.getName() : null);
		}
	}

	// ======================== Talking ========================

	/**
	 * Event comment: seam line in the bot's voice; the LLM only when it has priority.
	 * @param bot the bot.
	 * @param state its state.
	 * @param instruction LLM instruction.
	 * @param seam seam key for the template line.
	 * @param slots extra slots.
	 * @param about player the event is about (memory context), may be null.
	 */
	public void eventChat(Player bot, FakePlayerBotState state, String instruction, String seam, Map<String, String> slots, Player about)
	{
		final FakePlayerBotRef ref = FakePlayerBotRef.ofHeadless(bot);
		final String line = FakePlayerUnderstanding.eventLine(ref, seam, about, slots);
		if (FakePlayerLlmService.getInstance().isEnabled() && "llm".equals(FakePlayersConfig.FAKE_PLAYER_LLM_PRIORITY))
		{
			final String persona = FakePlayerHeadlessManager.getInstance().persona(bot, (about != null) ? about.getName() : null);
			FakePlayerLlmService.getInstance().generate(persona, instruction, reply -> FakePlayerHeadlessManager.getInstance().say(bot, reply, seam), () -> FakePlayerHeadlessManager.getInstance().say(bot, line, seam));
			return;
		}
		if (line != null)
		{
			// A human needs a moment to type.
			ThreadPool.schedule(() -> FakePlayerHeadlessManager.getInstance().say(bot, line, seam), Rnd.get(800, 2500));
		}
	}

	/**
	 * A bot says something to another bot; the addressee answers through the
	 * understanding pipeline (intent, memory, stance, idiolect), and the two
	 * may exchange a couple more lines. Depth limited, never loops.
	 * @param speaker the speaking bot.
	 * @param listener the addressed bot.
	 * @param text the line.
	 * @param seam the seam of the line.
	 * @param depth dialogue depth of this line (1 = opener).
	 */
	public void converse(Player speaker, Player listener, String text, String seam, int depth)
	{
		FakePlayerHeadlessManager.getInstance().say(speaker, text, (depth == 1) ? seam : "dialogue");
		if ((depth >= MAX_DIALOGUE_DEPTH) || (listener == null) || listener.isDead())
		{
			return;
		}
		ThreadPool.schedule(() ->
		{
			try
			{
				if (listener.isDead() || speaker.isDead() || (listener.calculateDistance2D(speaker) > 1250))
				{
					return;
				}
				final FakePlayerBotRef ref = FakePlayerBotRef.ofHeadless(listener);
				final Understanding understanding = FakePlayerUnderstanding.understand(ref, speaker, text, false);
				String reply = ((understanding != null) && !understanding.silent) ? understanding.responseLine : null;
				if ((reply == null) && (depth == 1))
				{
					// No intent recognized: react to the topic like an overheard line.
					final Map<String, String> slots = new HashMap<>(2);
					slots.put("msg", FakePlayerHeardManager.echoOf(text));
					slots.put("player", speaker.getName());
					reply = FakePlayerUnderstanding.optionalLine(ref, FakePlayerHeardManager.isQuestionLine(text) ? "overheard_q" : "overheard", speaker, slots);
				}
				if (reply == null)
				{
					return;
				}
				final FakePlayerBotState listenerState = FakePlayerHeadlessManager.getInstance().stateOf(listener);
				if (listenerState != null)
				{
					listenerState.repliesGiven.incrementAndGet();
					listenerState.action("reply:" + speaker.getName());
				}
				// The opener's author answers back only sometimes, and the chain always ends.
				if ((depth + 1 < MAX_DIALOGUE_DEPTH) && (Rnd.get(100) < 40))
				{
					converse(listener, speaker, reply, "dialogue", depth + 1);
				}
				else
				{
					FakePlayerHeadlessManager.getInstance().say(listener, reply, "dialogue");
				}
			}
			catch (Exception e)
			{
				// A failed reply is just silence.
			}
		}, Rnd.get(2500, 7000));
	}

	/**
	 * A real player said something in general chat: nearby bots may answer.
	 * A bot addressed by name always answers.
	 * @param player the speaker.
	 * @param text the message.
	 */
	public void onPlayerSaid(Player player, String text)
	{
		final List<Player> listeners = new ArrayList<>();
		World.forEachVisibleObjectInRange(player, Player.class, 1250, other ->
		{
			if (other.isHeadlessBot() && !other.isDead())
			{
				listeners.add(other);
			}
		});
		if (listeners.isEmpty())
		{
			return;
		}
		listeners.sort((a, b) -> Double.compare(a.calculateDistance2D(player), b.calculateDistance2D(player)));
		final String lower = text.toLowerCase();
		int answered = 0;
		for (Player listener : listeners)
		{
			final boolean addressed = lower.contains(listener.getName().toLowerCase()) || ((listener.getName().length() > 5) && lower.contains(listener.getName().substring(0, 5).toLowerCase()));
			if (addressed || ((answered < 2) && (Rnd.get(100) < FakePlayersConfig.FAKE_PLAYER_LOCAL_CHAT_REPLY_CHANCE)))
			{
				answered++;
				FakePlayerHeadlessManager.getInstance().onLocalChat(player, listener, text);
				if (addressed)
				{
					break;
				}
			}
		}
	}

	// ======================== Parties ========================

	/**
	 * Real party invitation from a bot to a player (the client shows the normal window).
	 * @param bot the bot.
	 * @param player the player.
	 */
	public void inviteToParty(Player bot, Player player)
	{
		try
		{
			if (bot.isDead() || player.isDead())
			{
				return;
			}
			final Party botParty = bot.getParty();
			final Party playerParty = player.getParty();
			if ((playerParty != null) && (botParty == null) && (playerParty.getMemberCount() < 9))
			{
				bot.joinParty(playerParty);
				FakePlayerHeadlessManager.getInstance().onJoinedParty(bot);
				return;
			}
			if ((playerParty != null) || player.isProcessingRequest() || player.isProcessingTransaction())
			{
				return;
			}
			if ((botParty != null) && (botParty.getMemberCount() >= 9))
			{
				return;
			}
			player.setActiveRequester(bot);
			bot.onTransactionRequest(player);
			bot.setPartyDistributionType(PartyDistributionType.FINDERS_KEEPERS);
			player.sendPacket(new AskJoinParty(bot.getName(), PartyDistributionType.FINDERS_KEEPERS));
			if (botParty != null)
			{
				botParty.setPendingInvitation(true);
			}
			_pendingInvites.remove(bot.getObjectId());
		}
		catch (Exception e)
		{
			// The invitation simply does not happen.
		}
	}

	/**
	 * @param bot the bot.
	 * @param player the player.
	 * @return true when the bot invited the player recently and the invite is still valid.
	 */
	public boolean hasPendingInvite(Player bot, Player player)
	{
		final long[] pending = _pendingInvites.get(bot.getObjectId());
		return (pending != null) && (pending[0] == player.getObjectId()) && (System.currentTimeMillis() < pending[1]);
	}

	/**
	 * @param bot the bot.
	 * @return the real player the bot's party is built around (any human member), or null.
	 */
	public static Player humanPartyAnchor(Player bot)
	{
		final Party party = bot.getParty();
		if (party == null)
		{
			return null;
		}
		final Player leader = party.getLeader();
		if ((leader != null) && (leader != bot) && !leader.isHeadlessBot())
		{
			return leader;
		}
		for (Player member : party.getMembers())
		{
			if ((member != null) && (member != bot) && !member.isHeadlessBot())
			{
				return member;
			}
		}
		return null;
	}

	// ======================== Events ========================

	/**
	 * Reaction to a real level up (event fact, clan brag or public joy - neighbours congratulate).
	 * @param bot the bot.
	 * @param state its state.
	 */
	public void onLevelUp(Player bot, FakePlayerBotState state)
	{
		FakePlayerBotContext.setEvent(state.seedId, "levelup");
		FakePlayerBotContext.setSlot(state.seedId, "level", String.valueOf(bot.getLevel()));
		final FakePlayerBotRef ref = FakePlayerBotRef.ofHeadless(bot);
		final Map<String, String> slots = Map.of("level", String.valueOf(bot.getLevel()));
		if ((bot.getClan() != null) && (Rnd.get(100) < 40))
		{
			final String clanLine = FakePlayerUnderstanding.eventLine(ref, "levelup", null, slots);
			if (clanLine != null)
			{
				bot.getClan().broadcastToOnlineMembers(new CreatureSay(bot, ChatType.CLAN, bot.getName(), clanLine));
				state.linesSaid.incrementAndGet();
			}
			return;
		}
		final Player witness = World.getNearestVisibleObjectInRange(bot, Player.class, WITNESS_RANGE, other -> !other.isDead() && !other.isHeadlessBot());
		if (witness != null)
		{
			eventChat(bot, state, "Ты только что взял " + bot.getLevel() + " уровень. Порадуйся одной фразой.", "levelup", slots, null);
		}
	}

	/**
	 * Reaction to death: killed by a player - memory, rumor seed and a death cry;
	 * killed by a monster - a complaint now and then.
	 * @param bot the dead bot.
	 * @param state its state.
	 * @param killer the killer.
	 */
	public void onDied(Player bot, FakePlayerBotState state, Creature killer)
	{
		state.deaths.incrementAndGet();
		state.action("dead");
		if (FakePlayersConfig.FAKE_PLAYER_CHAT_LOG)
		{
			LOGGER.info("death: " + bot.getName() + " lvl" + bot.getLevel() + " " + bot.getPlayerClass() + " in " + state.zone + " killed by " + ((killer != null) ? (killer.getName() + " lvl" + killer.getLevel()) : "?") + " at " + bot.getX() + "," + bot.getY() + "," + bot.getZ());
		}
		final Player killerPlayer = (killer != null) ? killer.asPlayer() : null;
		if (killerPlayer != null)
		{
			if (!killerPlayer.isHeadlessBot())
			{
				FakePlayerMemoryManager.getInstance().onKilledBy(state.seedId, killerPlayer.getName());
			}
			else
			{
				final FakePlayerBotState killerState = FakePlayerHeadlessManager.getInstance().stateOf(killerPlayer);
				if (killerState != null)
				{
					killerState.pvpKills.incrementAndGet();
				}
			}
			FakePlayerBotContext.setEvent(state.seedId, "ganked");
			final String deathCry = FakePlayerUnderstanding.eventLine(FakePlayerBotRef.ofHeadless(bot), "killed_by", killerPlayer, null);
			if (deathCry != null)
			{
				// Dead players still type - the corpse lies there for a while.
				ThreadPool.schedule(() -> FakePlayerHeadlessManager.getInstance().sayDead(bot, deathCry, "killed_by"), Rnd.get(1500, 4000));
			}
			// Nearby bots witness the kill (rumor seed).
			final String victimName = bot.getName();
			World.forEachVisibleObjectInRange(bot, Player.class, 900, witness ->
			{
				if (witness.isHeadlessBot() && (witness != bot))
				{
					final FakePlayerBotState witnessState = FakePlayerHeadlessManager.getInstance().stateOf(witness);
					if (witnessState != null)
					{
						FakePlayerRumorManager.getInstance().addWitnessed(witnessState.seedId, (killerPlayer.getKarma() > 0) ? "pk" : "kill", killerPlayer.getName(), victimName);
					}
				}
			});
			return;
		}
		if ((killer != null) && (Rnd.get(100) < 50))
		{
			final Player witness = World.getNearestVisibleObjectInRange(bot, Player.class, WITNESS_RANGE, other -> !other.isDead() && !other.isHeadlessBot());
			if (witness != null)
			{
				final String complaint = FakePlayerUnderstanding.eventLine(FakePlayerBotRef.ofHeadless(bot), "died_mob", null, Map.of("mob", killer.getName()));
				if (complaint != null)
				{
					ThreadPool.schedule(() -> FakePlayerHeadlessManager.getInstance().sayDead(bot, complaint, "died_mob"), Rnd.get(2000, 5000));
				}
			}
		}
	}

	public static FakePlayerSocial getInstance()
	{
		return SingletonHolder.INSTANCE;
	}

	private static class SingletonHolder
	{
		protected static final FakePlayerSocial INSTANCE = new FakePlayerSocial();
	}
}
