/*
 * render.cpp, part of VCMI engine
 *
 * Authors: listed in file AUTHORS in main folder
 *
 * License: GNU General Public License v2.0 or later
 * Full text of license available in license.txt file, in main folder
 *
 */

#include "StdInc.h"
#include "battle/AccessibilityInfo.h"
#include "battle/BattleAttackInfo.h"
#include "battle/CObstacleInstance.h"
#include "battle/IBattleInfoCallback.h"
#include "constants/EntityIdentifiers.h"
#include "mapObjects/CGTownInstance.h"
#include "vcmi/spells/Caster.h"

#include "BAI/v13/hex.h"
#include "BAI/v13/hexactmask.h"
#include "BAI/v13/render.h"
#include "common.h"

#include <algorithm>

#include "schema/v13/constants.h"
#include "schema/v13/types.h"

namespace MMAI::BAI::V13
{

namespace S13 = Schema::V13;
using IHex = Schema::V13::IHex;
using IStack = Schema::V13::IStack;
using ISupplementaryData = Schema::V13::ISupplementaryData;
using GA = Schema::V13::GlobalAttribute;
using HA = Schema::V13::HexAttribute;
using PA = Schema::V13::PlayerAttribute;
using SA = StackAttribute;
using SF1 = StackFlag1;
using SF2 = StackFlag2;

namespace
{
	std::string PadLeft(const std::string & input, size_t desiredLength, char paddingChar)
	{
		std::ostringstream ss;
		ss << std::right << std::setfill(paddingChar) << std::setw(desiredLength) << input;
		return ss.str();
	}

	// Plain message overload: expect(cond, "expectation failed");
	inline void expect(bool exp, std::string_view message)
	{
		if(exp)
			return;

		throw std::runtime_error(std::string(message));
	}

	template<typename... Args>
	inline void expect(bool exp, std::string_view format, const Args &... args)
	requires(sizeof...(Args) > 0)
	{
		if(exp)
			return;

		boost::format f{std::string(format)};

		// Fold expression: expands to (f % arg1, f % arg2, ...)
		((f % args), ...);

		throw std::runtime_error(f.str());
	}
}

namespace
{
	struct Context
	{
		const CPlayerBattleCallback * battle{};
		std::vector<const CStack *> allstacks;
		std::array<const CStack *, 7> l_CStacks{};
		std::array<const CStack *, 7> r_CStacks{};
		std::vector<const CStack *> l_CStacksAll;
		std::vector<const CStack *> r_CStacksAll;
		std::vector<const CStack *> l_CStacksExtra;
		std::vector<const CStack *> r_CStacksExtra;
		std::vector<const CStack *> l_CStacksSummons;
		std::vector<const CStack *> r_CStacksSummons;
		std::vector<const CStack *> l_CStacksMachines;
		std::vector<const CStack *> r_CStacksMachines;
		std::array<const CStack *, 165> hexstacks{};

		std::map<const CStack *, ReachabilityInfo> rinfos;
	};

	std::array<const CStack *, 7> getAllStacksForSide(const Context & ctx, bool side)
	{
		return side ? ctx.r_CStacks : ctx.l_CStacks;
	}

	// Return (attr == N/A), but after performing some checks
	bool isNA(int v, const CStack * stack, const std::string_view attrname)
	{
		if(v == S13::NULL_VALUE_UNENCODED)
		{
			expect(!stack, "%s: N/A but stack != nullptr", attrname);
			return true;
		}
		expect(stack, "%s: != N/A but stack = nullptr", attrname);
		return false;
	};

	bool checkReachable(const Context & ctx, BattleHex bh, bool v, const CStack * stack)
	{
		auto distance = ctx.rinfos.at(stack).distances.at(bh.toInt());
		auto canreach = (stack->getMovementRange() >= distance);

		// XXX: if v=false, returns true when UNreachable
		//      if v=true returns true when reachable
		return v ? canreach : !canreach;
	};

	void ensureReachability(const Context & ctx, BattleHex bh, bool v, const CStack * stack, const char * attrname)
	{
		expect(checkReachable(ctx, bh, v, stack), "%s: (bhex=%d) reachability expected: %d", attrname, bh.toInt(), v);
	};

	void ensureValueMatch(int have, int want, const std::string_view attrname, const std::string & desc = "")
	{
		desc.empty() ? expect(have == want, "%s: have: %d, want: %d", attrname, have, want)
					 : expect(have == want, "%s: have: %d, want: %d (%s)", attrname, have, want, desc.c_str());
	};

	void ensureStackNullOrMatch(HexAttribute a, const CStack * cstack, int have, auto wantfunc, const std::string_view attrname)
	{
		auto vmax = std::get<3>(S13::HEX_ENCODING.at(EI(a)));
		if(isNA(have, cstack, attrname))
			return;
		int want = wantfunc();
		want = std::min(want, vmax);
		have = std::min(have, vmax); // this is usually done by the encoder
		ensureValueMatch(have, want, attrname);
	};

	void ensureMeleeability(const Context & ctx, BattleHex bh, HexActMask mask, HexAction ha, const CStack * cstack, const char * attrname)
	{
		auto mv = mask.test(EI(ha));

		// if AMOVE is allowed, we must be able to reach hex
		// (no else -- we may still be able to reach it)
		if(mv == 1)
			ensureReachability(ctx, bh, true, cstack, attrname);

		auto r_nbh = bh.cloneInDirection(BattleHex::EDir::RIGHT, false);
		auto l_nbh = bh.cloneInDirection(BattleHex::EDir::LEFT, false);
		auto nbh = BattleHex{};

		switch(ha)
		{
			case HexAction::AMOVE_TR:
				nbh = bh.cloneInDirection(BattleHex::EDir::TOP_RIGHT, false);
				break;
			case HexAction::AMOVE_R:
				nbh = bh.cloneInDirection(BattleHex::EDir::RIGHT, false);
				break;
			case HexAction::AMOVE_BR:
				nbh = bh.cloneInDirection(BattleHex::EDir::BOTTOM_RIGHT, false);
				break;
			case HexAction::AMOVE_BL:
				nbh = bh.cloneInDirection(BattleHex::EDir::BOTTOM_LEFT, false);
				break;
			case HexAction::AMOVE_L:
				nbh = bh.cloneInDirection(BattleHex::EDir::LEFT, false);
				break;
			case HexAction::AMOVE_TL:
				nbh = bh.cloneInDirection(BattleHex::EDir::TOP_LEFT, false);
				break;
			case HexAction::AMOVE_2TR:
				nbh = r_nbh.cloneInDirection(BattleHex::EDir::TOP_RIGHT, false);
				break;
			case HexAction::AMOVE_2R:
				nbh = r_nbh.cloneInDirection(BattleHex::EDir::RIGHT, false);
				break;
			case HexAction::AMOVE_2BR:
				nbh = r_nbh.cloneInDirection(BattleHex::EDir::BOTTOM_RIGHT, false);
				break;
			case HexAction::AMOVE_2BL:
				nbh = l_nbh.cloneInDirection(BattleHex::EDir::BOTTOM_LEFT, false);
				break;
			case HexAction::AMOVE_2L:
				nbh = l_nbh.cloneInDirection(BattleHex::EDir::LEFT, false);
				break;
			case HexAction::AMOVE_2TL:
				nbh = l_nbh.cloneInDirection(BattleHex::EDir::TOP_LEFT, false);
				break;
			default:
				THROW_FORMAT("Unexpected HexAction: %d", EI(ha));
				break;
		}

		auto estacks = getAllStacksForSide(ctx, !EI(cstack->unitSide()));
		const auto it = std::ranges::find_if( // NOLINT(readability-qualified-auto)
			estacks,
			[&nbh](const auto & stack)
			{
				return stack && stack->coversPos(nbh);
			}
		);

		const auto * estack = it == estacks.end() ? nullptr : *it;

		if(mv)
		{
			expect(estack, "%s: =1 (bhex %d, nbhex %d), but estack is nullptr", attrname, bh.toInt(), nbh.toInt());
			// must not pass "nbh" for defender position, as it could be its rear hex
			expect(
				cstack->isMeleeAttackPossible(cstack, estack, bh),
				"%s: =1 (bhex %d, nbhex %d), but VCMI says isMeleeAttackPossible=0",
				attrname,
				bh.toInt(),
				nbh.toInt()
			);
		}
	};

	// as opposed to ensureHexShootableOrNA, this hexattr works with a mask
	// values are 0 or 1 and this check requires a valid target
	void ensureShootability(const Context & ctx, BattleHex bh, int v, const CStack * cstack, const char * attrname)
	{
		auto canshoot = ctx.battle->battleCanShoot(cstack);
		auto estacks = getAllStacksForSide(ctx, !EI(cstack->unitSide()));

		const auto it = std::ranges::find_if( // NOLINT(readability-qualified-auto)
			estacks,
			[&bh](auto estack)
			{
				return estack && estack->coversPos(bh);
			}
		);

		const auto * estack = it == estacks.end() ? nullptr : *it;

		// XXX: the estack on `bh` might be "hidden" from the state
		//      in which case the mask for shooting will be 0 although
		//      there IS a stack to shoot on this hex
		if(v)
		{
			expect(estack, "%s: =%d, but estack is nullptr", attrname, bh.toInt());
			expect(canshoot, "%s: =%d but canshoot=%d", attrname, v, canshoot);
		}
		else
		{
			// stack must be unable to shoot
			// OR there must be no target at hex
			expect(!canshoot || !estack, "%s: =%d but canshoot=%d and estack is not null", attrname, v, canshoot);
		}
	};

	void ensureCorrectMaskOrNA(const Context & ctx, BattleHex bh, int v, const CStack * cstack, const std::string_view attrname)
	{
		if(isNA(v, cstack, attrname))
			return;

		auto basename = std::string(attrname);
		auto mask = HexActMask(v);

		ensureReachability(ctx, bh, mask.test(EI(HexAction::MOVE)), cstack, (basename + "{MOVE}").c_str());
		ensureShootability(ctx, bh, mask.test(EI(HexAction::SHOOT)), cstack, (basename + "{SHOOT}").c_str());
		ensureMeleeability(ctx, bh, mask, HexAction::AMOVE_TR, cstack, (basename + "{AMOVE_TR}").c_str());
		ensureMeleeability(ctx, bh, mask, HexAction::AMOVE_R, cstack, (basename + "{AMOVE_R}").c_str());
		ensureMeleeability(ctx, bh, mask, HexAction::AMOVE_BR, cstack, (basename + "{AMOVE_BR}").c_str());
		ensureMeleeability(ctx, bh, mask, HexAction::AMOVE_BL, cstack, (basename + "{AMOVE_BL}").c_str());
		ensureMeleeability(ctx, bh, mask, HexAction::AMOVE_L, cstack, (basename + "{AMOVE_L}").c_str());
		ensureMeleeability(ctx, bh, mask, HexAction::AMOVE_TL, cstack, (basename + "{AMOVE_TL}").c_str());
		ensureMeleeability(ctx, bh, mask, HexAction::AMOVE_2TR, cstack, (basename + "{AMOVE_2TR}").c_str());
		ensureMeleeability(ctx, bh, mask, HexAction::AMOVE_2R, cstack, (basename + "{AMOVE_2R}").c_str());
		ensureMeleeability(ctx, bh, mask, HexAction::AMOVE_2BR, cstack, (basename + "{AMOVE_2BR}").c_str());
		ensureMeleeability(ctx, bh, mask, HexAction::AMOVE_2BL, cstack, (basename + "{AMOVE_2BL}").c_str());
		ensureMeleeability(ctx, bh, mask, HexAction::AMOVE_2L, cstack, (basename + "{AMOVE_2L}").c_str());
		ensureMeleeability(ctx, bh, mask, HexAction::AMOVE_2TL, cstack, (basename + "{AMOVE_2TL}").c_str());
	};

}

// This function used during model development and is never called otherwise
void Verify(const State * state) // NOSONAR - function used for debugging only
{
	const auto * battle = state->battle;
	auto hexes = Hexes();
	const CStack * astack = nullptr;

	expect(battle, "no battle to verify");

	Context ctx;
	ctx.battle = battle;

	ctx.allstacks = battle->battleGetStacks();
	std::ranges::sort(
		ctx.allstacks,
		[](const CStack * a, const CStack * b)
		{
			return a->unitId() < b->unitId();
		}
	);

	for(auto & cstack : ctx.allstacks)
	{
		if(cstack->unitId() == battle->battleActiveUnit()->unitId())
			astack = cstack;

		if(cstack->unitSlot() < 0)
		{
			if(cstack->unitSlot() == SlotID::SUMMONED_SLOT_PLACEHOLDER)
				cstack->unitSide() == BattleSide::DEFENDER ? ctx.r_CStacksSummons.push_back(cstack) : ctx.l_CStacksSummons.push_back(cstack);
			else if(cstack->unitSlot() == SlotID::WAR_MACHINES_SLOT)
				cstack->unitSide() == BattleSide::DEFENDER ? ctx.r_CStacksMachines.push_back(cstack) : ctx.l_CStacksMachines.push_back(cstack);
		}
		else
		{
			cstack->unitSide() == BattleSide::DEFENDER ? ctx.r_CStacks.at(cstack->unitSlot()) = cstack : ctx.l_CStacks.at(cstack->unitSlot()) = cstack;
		}

		ctx.rinfos.try_emplace(cstack, battle->getReachability(cstack));

		for(const auto & bh : cstack->getHexes())
		{
			if(!bh.isAvailable())
				continue; // war machines rear hex, arrow towers
			expect(!ctx.hexstacks.at(Hex::CalcId(bh)), "hex occupied by multiple stacks?");
			ctx.hexstacks.at(Hex::CalcId(bh)) = cstack;
		}
	}

	ctx.l_CStacksAll.insert(ctx.l_CStacksAll.end(), ctx.l_CStacks.begin(), ctx.l_CStacks.end());
	ctx.l_CStacksAll.insert(ctx.l_CStacksAll.end(), ctx.l_CStacksSummons.begin(), ctx.l_CStacksSummons.end());
	ctx.l_CStacksAll.insert(ctx.l_CStacksAll.end(), ctx.l_CStacksMachines.begin(), ctx.l_CStacksMachines.end());

	ctx.r_CStacksAll.insert(ctx.r_CStacksAll.end(), ctx.r_CStacks.begin(), ctx.r_CStacks.end());
	ctx.r_CStacksAll.insert(ctx.r_CStacksAll.end(), ctx.r_CStacksSummons.begin(), ctx.r_CStacksSummons.end());
	ctx.r_CStacksAll.insert(ctx.r_CStacksAll.end(), ctx.r_CStacksMachines.begin(), ctx.r_CStacksMachines.end());

	ctx.l_CStacksExtra.insert(ctx.l_CStacksExtra.end(), ctx.l_CStacksSummons.begin(), ctx.l_CStacksSummons.end());
	ctx.l_CStacksExtra.insert(ctx.l_CStacksExtra.end(), ctx.l_CStacksMachines.begin(), ctx.l_CStacksMachines.end());

	ctx.r_CStacksExtra.insert(ctx.r_CStacksExtra.end(), ctx.r_CStacksSummons.begin(), ctx.r_CStacksSummons.end());
	ctx.r_CStacksExtra.insert(ctx.r_CStacksExtra.end(), ctx.r_CStacksMachines.begin(), ctx.r_CStacksMachines.end());

	auto SideStacks = std::map<bool, std::vector<const CStack *> *>{
		{false, &ctx.l_CStacksAll},
        {true,  &ctx.r_CStacksAll}
	};

	auto ended = state->supdata->ended;

	if(!astack)
		expect(ended, "astack is NULL, but ended is not true");
	else if(ended)
	{
		// at battle-end, activeStack is usually the ENEMY stack
		// XXX: this expect will incorrectly throw if we retreated as a regular action
		//      (in which case our stack will be active, but we would have lost the battle)
		// expect(state->supdata->victory == (astack->getOwner() == battle->battleGetMySide()), "state->supdata->victory is %d, but astack->side=%d and myside=%d", state->supdata->victory, astack->getOwner(), battle->battleGetMySide());

		// at battle-end, even regardless of the actual active stack,
		// battlefield->astack must be nullptr
		expect(!state->battlefield->astack, "ended, but battlefield->astack is not NULL");
		expect(state->supdata->getIsBattleEnded(), "ended, but state->supdata->getIsBattleEnded() is false");
	}

	// XXX: good morale is NOT handled here for simplicity
	//      See comments in Battlefield::GetQueue how to handle it.
	auto tmp = std::vector<battle::Units>{};
	battle->battleGetTurnOrder(tmp, S13::STACK_QUEUE_SIZE, 0);
	auto queue = std::vector<const battle::Unit *>{};
	for(auto & units : tmp)
	{
		for(auto & unit : units)
		{
			if(queue.size() < S13::STACK_QUEUE_SIZE)
				queue.push_back(unit);
			else
				break;
		}
	}

	const auto * gstats = state->supdata->getGlobalStats();
	auto gmask = GlobalActionMask(gstats->getAttr(GA::ACTION_MASK));
	ensureValueMatch(gmask.test(EI(GlobalAction::RETREAT)), false, "GA.ACTION_MASK[RETREAT]");

	if(ended)
	{
		static_assert(EI(Side::LEFT) == EI(BattleSide::ATTACKER));
		static_assert(EI(Side::RIGHT) == EI(BattleSide::DEFENDER));

		auto fin = battle->battleIsFinished();

		// XXX: The logic in battleIsFinished is flawed and returns no value
		//      (i.e. "not finished") if both sides have units, which can
		//      happen if the WE some has retreated as a regular action (not via reset).
		// ASSERT(fin.has_value(), "ended, but battleIsFinished returns no value?");

		if(fin.has_value())
		{
			// NONE means draw (no units on battlefield) -- our value will be null in this case
			(fin == BattleSide::NONE) ? ensureValueMatch(gstats->getAttr(GA::BATTLE_WINNER), S13::NULL_VALUE_UNENCODED, "GA.BATTLE_WINNER (draw)")
									  : ensureValueMatch(gstats->getAttr(GA::BATTLE_WINNER), EI(fin.value()), "GA.BATTLE_WINNER");
		}
		else
		{
			// we have retreated *as an action*
			// There seems to be no way to ask vcmi "which side retreated"
		}

		ensureValueMatch(gstats->getAttr(GA::BATTLE_SIDE_ACTIVE_PLAYER), S13::NULL_VALUE_UNENCODED, "GA.BATTLE_SIDE_ACTIVE_PLAYER");
		ensureValueMatch(gmask.test(EI(GlobalAction::WAIT)), false, "GA.ACTION_MASK[WAIT]");
	}
	else
	{
		static_assert(EI(Side::LEFT) == EI(BattleSide::LEFT_SIDE));
		static_assert(EI(Side::RIGHT) == EI(BattleSide::RIGHT_SIDE));
		ASSERT(astack != nullptr, "not ended, but no astack either");
		ensureValueMatch(gstats->getAttr(GA::BATTLE_WINNER), S13::NULL_VALUE_UNENCODED, "GA.BATTLE_WINNER (battle ongoing)");
		ensureValueMatch(gstats->getAttr(GA::BATTLE_SIDE_ACTIVE_PLAYER), EI(astack->unitSide()), "GA.BATTLE_SIDE_ACTIVE_PLAYER");
		ensureValueMatch(gmask.test(EI(GlobalAction::WAIT)), !astack->waitedThisTurn, "GA.ACTION_MASK[WAIT]");
	}
	auto alogs = state->supdata->getAttackLogs();

	for(int ihex = 0; ihex < 165; ihex++)
	{
		int x = ihex % 15;
		int y = ihex / 15;
		auto & hex = state->battlefield->hexes->at(y).at(x);
		auto bh = hex->bhex;
		expect(bh == BattleHex(x + 1, y), "hex->bhex mismatch");

		auto ainfo = battle->getAccessibility();
		auto aa = ainfo.at(bh.toInt());

		for(int i = 0; i < EI(HexAttribute::_count); i++)
		{
			auto attr = static_cast<HexAttribute>(i);
			auto v = hex->attrs.at(i);
			const auto * cstack = ctx.hexstacks.at(ihex);

			if(cstack)
			{
				expect(!!hex->stack, "cstack is present, but hex->stack is nullptr");
				expect(hex->stack->cstack == cstack, "hex->cstack != cstack");
			}
			else
			{
				expect(!hex->stack, "cstack is nullptr, but hex->stack is present");
			}

			switch(attr)
			{
				case HA::Y_COORD:
					expect(v == y, "HEX.Y_COORD: %d != %d", v, y);
					break;
				case HA::X_COORD:
					expect(v == x, "HEX.X_COORD: %d != %d", v, x);
					break;
				case HA::STATE_MASK:
				{
					auto obstacles = battle->battleGetAllObstaclesOnPos(bh, false);
					auto anyobstacle = [&obstacles](auto fn)
					{
						return std::any_of(
							obstacles.begin(),
							obstacles.end(),
							[&fn](const std::shared_ptr<const CObstacleInstance> & obstacle)
							{
								return fn(obstacle.get());
							}
						);
					};

					auto mask = HexStateMask(v);
					BattleSide side = astack ? astack->unitSide() : BattleSide::ATTACKER; // XXX: Hex defaults to 0 if there is no astack

					if(mask.test(EI(HexState::PASSABLE)))
					{
						expect(
							aa == EAccessibility::ACCESSIBLE || (EI(side) && aa == EAccessibility::GATE),
							"HEX.STATE_MASK: PASSABLE bit is set, but accessibility is %d (side: %d)",
							EI(aa),
							EI(side)
						);
					}
					else
					{
						if(aa == EAccessibility::OBSTACLE || aa == EAccessibility::ALIVE_STACK)
							break;

						switch(aa)
						{
							case EAccessibility::ACCESSIBLE:
								throw std::runtime_error("HEX.STATE_MASK: PASSABLE bit not set, but accessibility is ACCESSIBLE");
								break;
							case EAccessibility::ALIVE_STACK:
							case EAccessibility::OBSTACLE:
							case EAccessibility::DESTRUCTIBLE_WALL:
							case EAccessibility::GATE:
								break;
							case EAccessibility::UNAVAILABLE:
								// only Fort and Boat battles can have unavailable hexes
								expect(
									battle->battleGetFortifications().wallsHealth > 0 || battle->battleTerrainType() == TerrainId::WATER,
									"Found UNAVAILABLE accessibility on non-fort, non-boat battlefield: tertype=%d",
									EI(battle->battleTerrainType())
								);
								break;
							case EAccessibility::SIDE_COLUMN:
								// side hexes should are not included in the observation
								throw std::runtime_error("HEX.STATE_MASK: SIDE_COLUMN accessibility found");
								break;
							default:
								throw std::runtime_error("Unexpected accessibility: " + std::to_string(EI(aa)));
						}
					}

					if(mask.test(EI(HexState::STOPPING)))
					{
						auto stopping = anyobstacle(std::mem_fn(&CObstacleInstance::stopsMovement));
						expect(stopping, "HEX.STATE_MASK: STOPPING bit is set, but no obstacle stops movement");
					}

					if(mask.test(EI(HexState::DAMAGING_L)))
					{
						auto damaging = anyobstacle(
							[side](const CObstacleInstance * o)
							{
								if(o->obstacleType == CObstacleInstance::MOAT)
									return true;
								if(!o->triggersEffects())
									return false;
								auto s = SpellID(o->ID);
								if(s == SpellID::FIRE_WALL)
									return true;
								if(s != SpellID::LAND_MINE)
									return false;
								const auto * so = dynamic_cast<const SpellCreatedObstacle *>(o);
								auto bside = static_cast<bool>(side);
								return (side == so->casterSide) ? bside : !bside;
							}
						);
						expect(damaging, "HEX.STATE_MASK: DAMAGING bit is set, but no obstacle triggers a damaging effect");
					}

					if(mask.test(EI(HexState::DAMAGING_R)))
					{
						auto damaging = anyobstacle(
							[side](const CObstacleInstance * o)
							{
								if(o->obstacleType == CObstacleInstance::MOAT)
									return true;
								if(!o->triggersEffects())
									return false;
								auto s = SpellID(o->ID);
								if(s == SpellID::FIRE_WALL)
									return true;
								if(s != SpellID::LAND_MINE)
									return false;
								const auto * so = dynamic_cast<const SpellCreatedObstacle *>(o);
								auto bside = static_cast<bool>(side);
								return (side == so->casterSide) ? !bside : bside;
							}
						);
						expect(damaging, "HEX.STATE_MASK: DAMAGING bit is set, but no obstacle triggers a damaging effect");
					}
				}
				break;
				case HA::ACTION_MASK:
				{
					if(ended)
					{
						expect(v == 0, "HEX.ACTION_MASK: battle ended, but action mask is %d", v);
					}
					else
					{
						ensureCorrectMaskOrNA(ctx, bh, v, astack, "HEX.ACTION_MASK");
					}
				}
				break;
				case HA::IS_REAR:
				{
					ensureValueMatch(v, cstack ? cstack->occupiedHex() == hex->bhex : 0, "HEX.IS_REAR");
				}
				break;
				case HA::STACK_SIDE:
					ensureStackNullOrMatch(
						attr,
						cstack,
						v,
						[&cstack]
						{
							return EI(cstack->unitSide());
						},
						"HA.STACK_SIDE"
					);
					break;
				case HA::STACK_SLOT:
				{
					if(!cstack)
						break;

					auto want = static_cast<int>(cstack->unitSlot());
					if(want == SlotID::WAR_MACHINES_SLOT)
						want = S13::STACK_SLOT_WARMACHINES;
					else if(want < 0 || want > 7)
						want = S13::STACK_SLOT_SPECIAL;

					ensureValueMatch(v, want, "HA.STACK_SLOT");
				}
				break;
				case HA::STACK_QUANTITY:
					ensureStackNullOrMatch(
						attr,
						cstack,
						v,
						[&cstack]
						{
							return std::round(S13::STACK_QTY_MAX * static_cast<float>(cstack->getCount()) / S13::STACK_QTY_MAX);
						},
						"HEX.STACK_QUANTITY"
					);
					break;
				case HA::STACK_ATTACK:
					ensureStackNullOrMatch(
						attr,
						cstack,
						v,
						[&cstack]
						{
							return cstack->getAttack(false);
						},
						"HEX.STACK_ATTACK"
					);
					break;
				case HA::STACK_DEFENSE:
					ensureStackNullOrMatch(
						attr,
						cstack,
						v,
						[&cstack]
						{
							return cstack->getDefense(false);
						},
						"HEX.STACK_DEFENSE"
					);
					break;
				case HA::STACK_SHOTS:
					ensureStackNullOrMatch(
						attr,
						cstack,
						v,
						[&cstack]
						{
							return cstack->shots.available();
						},
						"HEX.STACK_SHOTS"
					);
					break;
				case HA::STACK_DMG_MIN:
					ensureStackNullOrMatch(
						attr,
						cstack,
						v,
						[&cstack]
						{
							return cstack->getMinDamage(false);
						},
						"HEX.STACK_DMG_MIN"
					);
					break;
				case HA::STACK_DMG_MAX:
					ensureStackNullOrMatch(
						attr,
						cstack,
						v,
						[&cstack]
						{
							return cstack->getMaxDamage(false);
						},
						"HEX.STACK_DMG_MAX"
					);
					break;
				case HA::STACK_HP:
					ensureStackNullOrMatch(
						attr,
						cstack,
						v,
						[&cstack]
						{
							return cstack->getMaxHealth();
						},
						"HEX.STACK_HP"
					);
					break;
				case HA::STACK_HP_LEFT:
					ensureStackNullOrMatch(
						attr,
						cstack,
						v,
						[&cstack]
						{
							return cstack->getFirstHPleft();
						},
						"HEX.STACK_VALUE_REL"
					);
					break;
				case HA::STACK_SPEED:
					ensureStackNullOrMatch(
						attr,
						cstack,
						v,
						[&cstack]
						{
							return cstack->getMovementRange();
						},
						"HEX.STACK_SPEED"
					);
					break;
				case HA::STACK_QUEUE:
				{
					// at battle end, queue is messed up
					// (the stack that dealt the killing blow is still "active", but not on 0 pos)
					if(ended || !cstack)
						break;

					auto qbits = std::bitset<S13::STACK_QUEUE_SIZE>(v);

					for(int n = 0; n < S13::STACK_QUEUE_SIZE; ++n)
					{
						int have = qbits.test(n);
						int want = (queue.at(n) == cstack);
						ensureValueMatch(have, want, ("HEX.STACK_QUEUE[" + std::to_string(n) + "]"));
					}
				}
				break;
				case HA::STACK_VALUE_ONE:
				{
					ensureStackNullOrMatch(
						attr,
						cstack,
						v,
						[&cstack]
						{
							return Stack::GetValue(cstack->unitType());
						},
						"HEX.STACK_VALUE_ONE"
					);
				}
				break;
				case HA::STACK_VALUE_REL:
				{
					int tot = 0;
					for(auto & s : ctx.allstacks)
						tot += s->getCount() * Stack::GetValue(s->unitType());

					ensureStackNullOrMatch(
						attr,
						cstack,
						v,
						[&cstack, &tot]
						{
							return 1000LL * cstack->getCount() * Stack::GetValue(cstack->unitType()) / tot;
						},
						"HEX.STACK_VALUE_REL"
					);
				}
				break;
				// These require historical information
				// (CPlayerCallback does not provide such)
				case HA::STACK_VALUE_REL0:
				case HA::STACK_VALUE_KILLED_REL:
				case HA::STACK_VALUE_KILLED_ACC_REL0:
				case HA::STACK_VALUE_LOST_REL:
				case HA::STACK_VALUE_LOST_ACC_REL0:
				case HA::STACK_DMG_DEALT_REL:
				case HA::STACK_DMG_DEALT_ACC_REL0:
				case HA::STACK_DMG_RECEIVED_REL:
				case HA::STACK_DMG_RECEIVED_ACC_REL0:
					break;
				case HA::STACK_FLAGS1:
				{
					if(isNA(v, cstack, "HEX.STACK_FLAGS"))
						break;

					for(int j = 0; j < EI(StackFlag1::_count); j++)
					{
						auto f = static_cast<StackFlag1>(j);
						auto vf = hex->stack->flag(f);

						switch(f)
						{
							case SF1::IS_ACTIVE:
								// at battle end, queue is messed up
								// (the stack that dealt the killing blow is still "active", but not on 0 pos)
								if(ended)
									break;

								if(vf == 0)
									expect(cstack != astack, "HEX.STACK_FLAGS1.IS_ACTIVE: =0 but cstack == astack");
								else
									expect(cstack == astack, "HEX.STACK_FLAGS1.IS_ACTIVE: =%d but cstack != astack", vf);
								break;
							case SF1::WILL_ACT:
								ensureValueMatch(vf, cstack->willMove(), "HEX.STACK_FLAGS1.WILL_ACT");
								break;
							case SF1::CAN_WAIT:
								ensureValueMatch(vf, cstack->willMove() && !cstack->waitedThisTurn, "HEX.STACK_FLAGS1.CAN_WAIT");
								break;
							case SF1::CAN_RETALIATE:
								// XXX: ableToRetaliate() calls CAmmo's (i.e. CRetaliations's) canUse() method
								//      which takes into account relevant bonuses (e.g. NO_RETALIATION from expert Blind)
								//      It does *NOT* take into account the attacker's BLOCKS_RETALIATION bonus
								//      (if it did, what would be the correct CAN_RETALIATE value for friendly units?)
								ensureValueMatch(vf, cstack->ableToRetaliate(), "HEX.STACK_FLAGS1.CAN_RETALIATE");
								break;
							case SF1::SLEEPING:
								cstack->unitType()->getId() == CreatureID::AMMO_CART
									? ensureValueMatch(vf, false, "HEX.STACK_FLAGS1.SLEEPING")
									: ensureValueMatch(vf, cstack->hasBonusOfType(BonusType::NOT_ACTIVE), "HEX.STACK_FLAGS1.SLEEPING");
								break;
							case SF1::BLOCKED:
								ensureValueMatch(vf, cstack->canShoot() && battle->battleIsUnitBlocked(cstack), "HEX.STACK_FLAGS1.TWO_HEX_ATTACK_BREATH");
								break;
							case SF1::BLOCKING:
							{
								auto adjUnits = battle->battleAdjacentUnits(cstack);
								bool want = std::ranges::any_of(
									adjUnits,
									[&battle, &cstack](const auto & adjstack)
									{
										return adjstack->unitSide() != cstack->unitSide() && adjstack->canShoot() && battle->battleIsUnitBlocked(adjstack)
											&& !adjstack->hasBonusOfType(BonusType::FREE_SHOOTING) && !adjstack->hasBonusOfType(BonusType::SIEGE_WEAPON);
									}
								);

								ensureValueMatch(vf, want, "HEX.STACK_FLAGS1.BLOCKING");
							}
							break;
							case SF1::IS_WIDE:
								ensureValueMatch(vf, cstack->occupiedHex().isAvailable(), "HEX.STACK_FLAGS1.IS_WIDE");
								break;
							case SF1::FLYING:
								ensureValueMatch(vf, cstack->hasBonusOfType(BonusType::FLYING), "HEX.STACK_FLAGS1.FLYING");
								break;
							case SF1::ADDITIONAL_ATTACK:
								ensureValueMatch(vf, cstack->hasBonusOfType(BonusType::ADDITIONAL_ATTACK), "HEX.STACK_FLAGS1.ADDITIONAL_ATTACK");
								break;
							case SF1::NO_MELEE_PENALTY:
								ensureValueMatch(vf, cstack->hasBonusOfType(BonusType::NO_MELEE_PENALTY), "HEX.STACK_FLAGS1.NO_MELEE_PENALTY");
								break;
							case SF1::TWO_HEX_ATTACK_BREATH:
								ensureValueMatch(vf, cstack->hasBonusOfType(BonusType::TWO_HEX_ATTACK_BREATH), "HEX.STACK_FLAGS1.TWO_HEX_ATTACK_BREATH");
								break;
							case SF1::BLOCKS_RETALIATION:
								ensureValueMatch(vf, cstack->hasBonusOfType(BonusType::BLOCKS_RETALIATION), "HEX.STACK_FLAGS1.BLOCKS_RETALIATION");
								break;
							case SF1::SHOOTER:
								ensureValueMatch(vf, cstack->hasBonusOfType(BonusType::SHOOTER), "HEX.STACK_FLAGS1.SHOOTER");
								// canShoot returns false if ammo = 0
								// ensureValueMatch(vf, cstack->canShoot(), "HEX.STACK_FLAGS1.SHOOTER (canShoot)");
								break;
							case SF1::NON_LIVING:
							{
								auto undead = cstack->hasBonusOfType(BonusType::UNDEAD);
								auto nonliving = cstack->hasBonusOfType(BonusType::NON_LIVING);
								ensureValueMatch(vf, undead || nonliving, "HEX.STACK_FLAGS1.NON_LIVING", cstack->getDescription());
							}
							break;
							case SF1::WAR_MACHINE:
								ensureValueMatch(vf, cstack->hasBonusOfType(BonusType::SIEGE_WEAPON), "HEX.STACK_FLAGS1.WAR_MACHINE");
								break;
							case SF1::FIREBALL:
								ensureValueMatch(
									vf, cstack->hasBonusOfType(BonusType::SPELL_LIKE_ATTACK, SpellID(SpellID::FIREBALL)), "HEX.STACK_FLAGS1.FIREBALL"
								);
								break;
							case SF1::DEATH_CLOUD:
								ensureValueMatch(
									vf, cstack->hasBonusOfType(BonusType::SPELL_LIKE_ATTACK, SpellID(SpellID::DEATH_CLOUD)), "HEX.STACK_FLAGS1.DEATH_CLOUD"
								);
								break;
							case SF1::THREE_HEADED_ATTACK:
								ensureValueMatch(vf, cstack->hasBonusOfType(BonusType::THREE_HEADED_ATTACK), "HEX.STACK_FLAGS1.THREE_HEADED_ATTACK");
								break;
							case SF1::ALL_AROUND_ATTACK:
								ensureValueMatch(vf, cstack->hasBonusOfType(BonusType::ATTACKS_ALL_ADJACENT), "HEX.STACK_FLAGS1.ALL_AROUND_ATTACK");
								break;
							case SF1::RETURN_AFTER_STRIKE:
								ensureValueMatch(vf, cstack->hasBonusOfType(BonusType::RETURN_AFTER_STRIKE), "HEX.STACK_FLAGS1.RETURN_AFTER_STRIKE");
								break;
							case SF1::ENEMY_DEFENCE_REDUCTION:
								ensureValueMatch(vf, cstack->hasBonusOfType(BonusType::ENEMY_DEFENCE_REDUCTION), "HEX.STACK_FLAGS1.ENEMY_DEFENCE_REDUCTION");
								break;
							case SF1::LIFE_DRAIN:
								ensureValueMatch(vf, cstack->hasBonusOfType(BonusType::LIFE_DRAIN), "HEX.STACK_FLAGS1.LIFE_DRAIN");
								break;
							case SF1::DOUBLE_DAMAGE_CHANCE:
								ensureValueMatch(vf, cstack->hasBonusOfType(BonusType::DOUBLE_DAMAGE_CHANCE), "HEX.STACK_FLAGS1.DOUBLE_DAMAGE_CHANCE");
								break;
							case SF1::DEATH_STARE:
								ensureValueMatch(vf, cstack->hasBonusOfType(BonusType::DEATH_STARE), "HEX.STACK_FLAGS1.DEATH_STARE");
								break;
							default:
								THROW_FORMAT("Unexpected StackFlag: %d", EI(f));
						}
					}
				}
				break;
				case HA::STACK_FLAGS2:
				{
					if(!isNA(v, cstack, "HEX.STACK_FLAGS"))
					{
						for(int j = 0; j < EI(StackFlag2::_count); j++)
						{
							auto f = static_cast<StackFlag2>(j);
							auto vf = hex->stack->flag(f);

							switch(f)
							{
								case SF2::AGE:
									ensureValueMatch(vf, cstack->hasBonusFrom(BonusSource::SPELL_EFFECT, SpellID(SpellID::AGE)), "HEX.STACK_FLAGS2.AGE");
									break;
								case SF2::AGE_ATTACK:
									ensureValueMatch(
										vf, cstack->hasBonusOfType(BonusType::SPELL_AFTER_ATTACK, SpellID(SpellID::AGE)), "HEX.STACK_FLAGS2.AGE_ATTACK"
									);
									break;
								case SF2::BIND:
									ensureValueMatch(vf, cstack->hasBonusFrom(BonusSource::SPELL_EFFECT, SpellID(SpellID::BIND)), "HEX.STACK_FLAGS2.BIND");
									break;
								case SF2::BIND_ATTACK:
									ensureValueMatch(
										vf, cstack->hasBonusOfType(BonusType::SPELL_AFTER_ATTACK, SpellID(SpellID::BIND)), "HEX.STACK_FLAGS2.BIND_ATTACK"
									);
									break;
								case SF2::BLIND:
								{
									auto blind = cstack->hasBonusFrom(BonusSource::SPELL_EFFECT, SpellID(SpellID::BLIND));
									auto paralyzed = cstack->hasBonusFrom(BonusSource::SPELL_EFFECT, SpellID(SpellID::PARALYZE));
									ensureValueMatch(vf, blind || paralyzed, "HEX.STACK_FLAGS2.BLIND");
								}
								break;
								case SF2::BLIND_ATTACK:
								{
									auto blind = cstack->hasBonusOfType(BonusType::SPELL_AFTER_ATTACK, SpellID(SpellID::BLIND));
									auto paralyze = cstack->hasBonusOfType(BonusType::SPELL_AFTER_ATTACK, SpellID(SpellID::PARALYZE));
									ensureValueMatch(vf, blind || paralyze, "HEX.STACK_FLAGS2.BLIND_ATTACK");
								}
								break;
								case SF2::CURSE:
									ensureValueMatch(vf, cstack->hasBonusFrom(BonusSource::SPELL_EFFECT, SpellID(SpellID::CURSE)), "HEX.STACK_FLAGS2.CURSE");
									break;
								case SF2::CURSE_ATTACK:
									ensureValueMatch(
										vf, cstack->hasBonusOfType(BonusType::SPELL_AFTER_ATTACK, SpellID(SpellID::CURSE)), "HEX.STACK_FLAGS2.CURSE_ATTACK"
									);
									break;
								case SF2::DISPEL_ATTACK:
									ensureValueMatch(
										vf,
										cstack->hasBonusOfType(BonusType::SPELL_AFTER_ATTACK, SpellID(SpellID::DISPEL_HELPFUL_SPELLS)),
										"HEX.STACK_FLAGS2.DISPEL_ATTACK"
									);
									break;
								case SF2::PETRIFY:
									ensureValueMatch(
										vf, cstack->hasBonusFrom(BonusSource::SPELL_EFFECT, SpellID(SpellID::STONE_GAZE)), "HEX.STACK_FLAGS2.PETRIFY"
									);
									break;
								case SF2::PETRIFY_ATTACK:
									ensureValueMatch(
										vf,
										cstack->hasBonusOfType(BonusType::SPELL_AFTER_ATTACK, SpellID(SpellID::STONE_GAZE)),
										"HEX.STACK_FLAGS2.PETRIFY_ATTACK"
									);
									break;
								case SF2::POISON:
									ensureValueMatch(vf, cstack->hasBonusFrom(BonusSource::SPELL_EFFECT, SpellID(SpellID::POISON)), "HEX.STACK_FLAGS2.POISON");
									break;
								case SF2::POISON_ATTACK:
									ensureValueMatch(
										vf, cstack->hasBonusOfType(BonusType::SPELL_AFTER_ATTACK, SpellID(SpellID::POISON)), "HEX.STACK_FLAGS2.POISON_ATTACK"
									);
									break;
								case SF2::WEAKNESS:
									ensureValueMatch(
										vf, cstack->hasBonusFrom(BonusSource::SPELL_EFFECT, SpellID(SpellID::WEAKNESS)), "HEX.STACK_FLAGS2.WEAKNESS"
									);
									break;
								case SF2::WEAKNESS_ATTACK:
									ensureValueMatch(
										vf,
										cstack->hasBonusOfType(BonusType::SPELL_AFTER_ATTACK, SpellID(SpellID::WEAKNESS)),
										"HEX.STACK_FLAGS2.WEAKNESS_ATTACK"
									);
									break;
								default:
									THROW_FORMAT("Unexpected StackFlag2: %d", EI(f));
							}
						}
					}
				}
				break;
				default:
					THROW_FORMAT("Unexpected HexAttribute: %d", EI(attr));
			}
		}
	}

	// Mask is undefined at battle end
	if(ended)
		return;
}

// This intentionally uses the IState interface to ensure that
// the schema is properly exposing all needed informaton
std::string Render(const Schema::IState * istate, const Action * action) // NOSONAR - function used for debugging only
{
	auto supdata_ = istate->getSupplementaryData();
	expect(supdata_.has_value(), "supdata_ holds no value");
	expect(supdata_.type() == typeid(const ISupplementaryData *), "supdata_ of unexpected type");
	const auto * sup = std::any_cast<const ISupplementaryData *>(supdata_);
	expect(sup, "sup holds a nullptr");
	const auto * gstats = sup->getGlobalStats();
	const auto * lpstats = sup->getLeftPlayerStats();
	const auto * rpstats = sup->getRightPlayerStats();
	const auto * mystats = gstats->getAttr(GA::BATTLE_SIDE) ? rpstats : lpstats;
	auto hexes = sup->getHexes();
	auto alogs = sup->getAttackLogs();

	const IStack * astack = nullptr;

	// find an active hex (i.e. with active stack on it)
	for(auto & row : hexes)
	{
		for(auto & hex : row)
		{
			const auto * const stack = hex->getStack();
			if(stack && stack->getFlag(SF1::IS_ACTIVE))
			{
				expect(!astack || astack == stack, "two active stacks found");
				astack = stack;
			}
		}
	}

	auto ended = gstats->getAttr(GA::BATTLE_WINNER) != S13::NULL_VALUE_UNENCODED;

	if(!astack && !ended)
		logAi->error("could not find an active stack (battle has not ended).");

	std::string nocol = "\033[0m";
	std::string redcol = "\033[31m"; // red
	std::string bluecol = "\033[34m"; // blue
	std::string darkcol = "\033[90m";
	std::string activemod = "\033[107m\033[7m"; // bold+reversed
	std::string ukncol = "\033[7m"; // white

	std::vector<std::stringstream> lines;

	//
	// 1. Add logs table:
	//
	// #1 attacks #5 for 16 dmg (1 killed)
	// #5 attacks #1 for 4 dmg (0 killed)
	// ...
	//
	for(auto & alog : alogs)
	{
		auto row = std::stringstream();
		auto attcol = ukncol;
		auto attalias = '?';
		auto defcol = ukncol;
		auto defalias = '?';

		if(alog->getAttacker())
		{
			attcol = (alog->getAttacker()->getAttr(SA::SIDE) == 0) ? redcol : bluecol;
			attalias = alog->getAttacker()->getAlias();
		}

		if(alog->getDefender())
		{
			defcol = (alog->getDefender()->getAttr(SA::SIDE) == 0) ? redcol : bluecol;
			defalias = alog->getDefender()->getAlias();
		}

		row << attcol << "#" << attalias << nocol;
		row << " attacks ";
		row << defcol << "#" << defalias << nocol;
		row << " for " << alog->getDamageDealt() << " dmg";
		row << " (kills: " << alog->getUnitsKilled() << ", value: " << alog->getValueKilled() << " / " << alog->getValueKilledPermille() << "‰)";

		lines.push_back(std::move(row));
	}

	//
	// 2. Build ASCII table
	//    (+populate aliveStacks var)
	//    NOTE: the contents below look mis-aligned in some editors.
	//          In (my) terminal, it all looks correct though.
	//
	//   ▕₁▕₂▕₃▕₄▕₅▕₆▕₇▕₈▕₉▕₀▕₁▕₂▕₃▕₄▕₅▕
	//  ┃▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔┃
	// ¹┨  1 ◌ ○ ○ ○ ○ ◌ ◌ ◌ ◌ ◌ ◌ ◌ ◌ 1 ┠¹
	// ²┨ ◌ ○ ○ ○ ○ ○ ○ ◌ ◌ ◌ ◌ ◌ ◌ ◌ ◌  ┠²
	// ³┨  ◌ ○ ○ ○ ○ ○ ○ ◌ ▦ ▦ ◌ ◌ ◌ ◌ ◌ ┠³
	// ⁴┨ ◌ ○ ○ ○ ○ ○ ○ ○ ▦ ▦ ▦ ◌ ◌ ◌ ◌  ┠⁴
	// ⁵┨  2 ◌ ○ ○ ▦ ▦ ◌ ○ ◌ ◌ ◌ ◌ ◌ ◌ 2 ┠⁵
	// ⁶┨ ◌ ○ ○ ○ ▦ ▦ ◌ ○ ○ ◌ ◌ ◌ ◌ ◌ ◌  ┠⁶
	// ⁷┨  3 3 ○ ○ ○ ▦ ◌ ○ ○ ◌ ◌ ▦ ◌ ◌ 3 ┠⁷
	// ⁸┨ ◌ ○ ○ ○ ○ ○ ○ ○ ○ ◌ ◌ ▦ ▦ ◌ ◌  ┠⁸
	// ⁹┨  ◌ ○ ○ ○ ○ ○ ○ ○ ◌ ◌ ◌ ◌ ◌ ◌ ◌ ┠⁹
	// ⁰┨ ◌ ○ ○ ○ ○ ○ ○ ○ ◌ ◌ ◌ ◌ ◌ ◌ ◌  ┠⁰
	// ¹┨  4 ◌ ○ ○ ○ ○ ○ ◌ ◌ ◌ ◌ ◌ ◌ ◌ 4 ┠¹
	//  ┃▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁┃
	//   ▕¹▕²▕³▕⁴▕⁵▕⁶▕⁷▕⁸▕⁹▕⁰▕¹▕²▕³▕⁴▕⁵▕
	//

	// s=special; can be any number, slot is always 7 (SPECIAL), visualized A,B,C...

	auto tablestartrow = lines.size();

	lines.emplace_back() << "    ₀▏₁▏₂▏₃▏₄▏₅▏₆▏₇▏₈▏₉▏₀▏₁▏₂▏₃▏₄";
	lines.emplace_back() << " ┃▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔┃ ";

	static const std::array<std::string, 10> nummap{"₀", "₁", "₂", "₃", "₄", "₅", "₆", "₇", "₈", "₉"};

	bool addspace = true;

	auto seenstacks = std::map<const IStack *, IHex *>{};
	bool divlines = true;

	// y even "▏"
	// y odd "▕"

	for(int y = 0; y < 11; y++)
	{
		for(int x = 0; x < 15; x++)
		{
			auto sym = std::string("?");
			auto & hex = hexes.at(y).at(x);
			const auto * stack = hex->getStack();

			const char * spacer = (y % 2 == 0) ? " " : "";
			auto & row = (x == 0) ? (lines.emplace_back() << nummap.at(y % 10) << "┨" << spacer) : lines.back();

			if(addspace)
			{
				if(divlines && (x != 0))
				{
					row << darkcol << (y % 2 == 0 ? "▏" : "▕") << nocol;
				}
				else
				{
					row << " ";
				}
			}

			addspace = true;

			auto smask = HexStateMask(hex->getAttr(HA::STATE_MASK));
			auto col = nocol;

			// First put symbols based on hex state.
			// If there's a stack on this hex, symbol will be overriden.
			HexStateMask mpass = 1 << EI(HexState::PASSABLE);
			HexStateMask mstop = 1 << EI(HexState::STOPPING);
			HexStateMask mdmgl = 1 << EI(HexState::DAMAGING_L);
			HexStateMask mdmgr = 1 << EI(HexState::DAMAGING_R);
			HexStateMask mdefault = 0; // or mother :)

			std::vector<std::tuple<std::string, std::string, HexStateMask>> symbols{
				{"⨻", bluecol, mpass | mstop | mdmgl},
				{"⨻", redcol,  mpass | mstop | mdmgr},
				{"✶", bluecol, mpass | mdmgl        },
				{"✶", redcol,  mpass | mdmgr        },
				{"△", nocol,   mpass | mstop        },
				{"○", nocol,   mpass                }, // changed to "◌" if unreachable
				{"◼", nocol,   mdefault             }
			};

			for(auto & tuple : symbols)
			{
				const auto & [s, c, m] = tuple;
				if((smask & m) == m)
				{
					sym = s;
					col = c;
					break;
				}
			}

			auto amask = HexActMask(hex->getAttr(HA::ACTION_MASK));
			if(col == nocol && !amask.test(EI(HexAction::MOVE)))
			{ // || supdata->getIsBattleEnded()
				col = darkcol;
				sym = sym == "○" ? "◌" : sym;
			}

			if(stack)
			{
				auto seen = seenstacks.find(stack) != seenstacks.end();
				// MSVC mandates constexpr `n` here
				constexpr int n = std::get<2>(S13::HEX_ENCODING[EI(HA::STACK_FLAGS1)]);
				auto flags = std::bitset<n>(stack->getAttr(SA::FLAGS1));
				sym = std::string(1, stack->getAlias());
				col = stack->getAttr(SA::SIDE) ? bluecol : redcol;

				if(stack->getAttr(SA::QUEUE) & 1)
					col += activemod;

				if(flags.test(EI(SF1::IS_WIDE)) && !seen)
				{
					if(stack->getAttr(SA::SIDE) == 0)
					{
						sym += "↠";
						addspace = false;
					}
					else if(stack->getAttr(SA::SIDE) == 1 && hex->getAttr(HA::X_COORD) < 14)
					{
						sym += "↞";
						addspace = false;
					}
				}

				if(!seen)
					seenstacks.try_emplace(stack, hex);
			}

			row << col << sym << nocol;

			if(x == 15 - 1)
			{
				row << (y % 2 == 0 ? " " : "  ") << "┠" << nummap.at(y % 10);
			}
		}
	}

	lines.emplace_back() << " ┃▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁┃";
	lines.emplace_back() << "   ⁰▕¹▕²▕³▕⁴▕⁵▕⁶▕⁷▕⁸▕⁹▕⁰▕¹▕²▕³▕⁴";

	//
	// 3. Add side table stuff
	//
	//   ▕₁▕₂▕₃▕₄▕₅▕₆▕₇▕₈▕₉▕₀▕₁▕₂▕₃▕₄▕₅▕
	//  ┃▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔┃         Player: RED
	// ₁┨  ○ ○ ○ ○ ○ ◌ ◌ ◌ ◌ ◌ ◌ ◌ ◌ ◌ ◌ ┠₁    Last action:
	// ₂┨ ○ ○ ○ ○ ○ ○ ◌ ◌ ◌ ◌ ◌ ◌ ◌ ◌ ◌  ┠₂      DMG dealt: 0
	// ₃┨  1 ○ ○ ○ ○ ○ ◌ ◌ ▦ ▦ ◌ ◌ ◌ ◌ 1 ┠₃   Units killed: 0
	// ...

	for(int i = 0; i <= lines.size(); i++)
	{
		std::string name;
		std::string value;
		auto side = gstats->getAttr(GA::BATTLE_SIDE);

		switch(i)
		{
			case 1:
				name = "Player";
				if(ended)
					value = "";
				else
					value = side ? bluecol + "BLUE" + nocol : redcol + "RED" + nocol;
				break;
			case 2:
				name = "Last action";
				value = action ? action->name() + " [" + std::to_string(action->action) + "]" : "";
				break;
			case 3:
				name = "DMG dealt";
				value = boost::str(boost::format("%d (%d since start)") % mystats->getAttr(PA::DMG_DEALT_NOW_ABS) % mystats->getAttr(PA::DMG_DEALT_ACC_ABS));
				break;
			case 4:
				name = "DMG received";
				value =
					boost::str(boost::format("%d (%d since start)") % mystats->getAttr(PA::DMG_RECEIVED_NOW_ABS) % mystats->getAttr(PA::DMG_RECEIVED_ACC_ABS));
				break;
			case 5:
				name = "Value killed";
				value =
					boost::str(boost::format("%d (%d since start)") % mystats->getAttr(PA::VALUE_KILLED_NOW_ABS) % mystats->getAttr(PA::VALUE_KILLED_ACC_ABS));
				break;
			case 6:
				name = "Value lost";
				value = boost::str(boost::format("%d (%d since start)") % mystats->getAttr(PA::VALUE_LOST_NOW_ABS) % mystats->getAttr(PA::VALUE_LOST_ACC_ABS));
				break;
			case 7:
			{
				// XXX: if there's a draw, this text will be incorrect
				auto restext = gstats->getAttr(GA::BATTLE_WINNER) ? (bluecol + "BLUE WINS") : (redcol + "RED WINS");

				name = "Battle result";
				value = ended ? (restext + nocol) : "";
			}
			break;
			case 8:
				name = "Army value (L)";
				value = boost::str(
					boost::format("%d (%.0f‰ of current BF value)") % lpstats->getAttr(PA::ARMY_VALUE_NOW_ABS) % lpstats->getAttr(PA::ARMY_VALUE_NOW_REL)
				);
				break;
			case 9:
				name = "Army value (R)";
				value = boost::str(
					boost::format("%d (%.0f‰ of current BF value)") % rpstats->getAttr(PA::ARMY_VALUE_NOW_ABS) % rpstats->getAttr(PA::ARMY_VALUE_NOW_REL)
				);
				break;
			case 10:
				name = "Current BF value";
				value = boost::str(
					boost::format("%d (%.0f‰ of starting BF value)") % gstats->getAttr(GA::BFIELD_VALUE_NOW_ABS) % gstats->getAttr(GA::BFIELD_VALUE_NOW_REL0)
				);
				break;
			default:
				continue;
		}

		lines.at(tablestartrow + i) << PadLeft(name, 17, ' ') << ": " << value;
	}

	lines.emplace_back() << "";

	//
	// 5. Add stacks table:
	//
	//          Stack # |   0   1   2   3   4   5   6   A   B   C   0   1   2   3   4   5   6   A   B   C
	// -----------------+--------------------------------------------------------------------------------
	//              Qty |   0  34   0   0   0   0   0   0   0   0   0  17   0   0   0   0   0   0   0   0
	//           Attack |   0   8   0   0   0   0   0   0   0   0   0   6   0   0   0   0   0   0   0   0
	//    ...10 more... | ...
	// -----------------+--------------------------------------------------------------------------------
	//
	// table with 24 columns (1 header, 3 dividers, 10 stacks per side)
	// Each row represents a separate attribute

	using RowDef = std::tuple<StackAttribute, std::string>;

	// max to show
	constexpr int max_stacks_per_side = 10;

	// All cell text is aligned right
	auto colwidths = std::array<int, 4 + (2 * max_stacks_per_side)>{};
	colwidths.fill(5); // default col width
	colwidths.at(0) = 16; // header col

	// Divider column indexes
	auto divcolids = {1, max_stacks_per_side + 2, (2 * max_stacks_per_side) + 3};

	for(int i : divcolids)
		colwidths.at(i) = 2; // divider col

	// {Attribute, name, colwidth}
	const auto rowdefs = std::vector<RowDef>{
		RowDef{SA::FLAGS1,    "Stack #"         }, // stack alias (1..7, S or M)
		RowDef{SA::SIDE,      ""                }, // divider row
		RowDef{SA::QUANTITY,  "Qty"             },
		RowDef{SA::ATTACK,    "Attack"          },
		RowDef{SA::DEFENSE,   "Defense"         },
		RowDef{SA::SHOTS,     "Shots"           },
		RowDef{SA::DMG_MIN,   "Dmg (min)"       },
		RowDef{SA::DMG_MAX,   "Dmg (max)"       },
		RowDef{SA::HP,        "HP"              },
		RowDef{SA::HP_LEFT,   "HP left"         },
		RowDef{SA::SPEED,     "Speed"           },
		RowDef{SA::QUEUE,     "Queue"           },
		RowDef{SA::VALUE_ONE, "Value (one)"     },
		RowDef{SA::VALUE_REL, "       Value (‰)"}, // manually pad to 16 (unicode length issue)
		// RowDef{SA::ESTIMATED_DMG, "Est. DMG%"},
		RowDef{SA::FLAGS1,    "State"           }, // "WAR" = CAN_WAIT, WILL_ACT, CAN_RETAL
		RowDef{SA::FLAGS1,    "Attack mods"     }, // "DB" = Double, Blinding
		// 2 values per column to avoid too long table
		RowDef{SA::FLAGS1,    "Blocked/ing"     },
		RowDef{SA::FLAGS1,    "Fly/Sleep"       },
		RowDef{SA::FLAGS1,    "NoRetal/NoMelee" },
		RowDef{SA::FLAGS1,    "Wide/Breath"     },
		RowDef{SA::SIDE,      ""                }, // divider row
	};

	// Table with nrows and ncells, each cell a 3-element tuple
	// cell: color, width, txt
	using TableCell = std::tuple<std::string, int, std::string>;
	using TableRow = std::array<TableCell, colwidths.size()>;

	auto table = std::vector<TableRow>{};

	auto divrow = TableRow{};
	for(int i = 0; i < colwidths.size(); i++)
		divrow[i] = {nocol, colwidths.at(i), std::string(colwidths.at(i), '-')};

	for(int i : divcolids)
		divrow.at(i) = {nocol, colwidths.at(i), std::string(colwidths.at(i) - 1, '-') + "+"};

	int specialcounter = 0;

	// Attribute rows
	for(const auto & [a, aname] : rowdefs)
	{
		if(a == SA::SIDE)
		{ // divider row
			table.push_back(divrow);
			continue;
		}

		auto row = TableRow{};

		// Header col
		row.at(0) = {nocol, colwidths.at(0), aname};

		// Div cols
		for(int i : {1, 2 + max_stacks_per_side, static_cast<int>(colwidths.size() - 1)})
			row.at(i) = {nocol, colwidths.at(i), "|"};

		// Stack cols
		for(auto side : {0, 1})
		{
			auto sidestacks = std::array<std::pair<const IStack *, IHex *>, max_stacks_per_side>{};
			auto extracounter = 0;
			for(const auto & [stack, hex] : seenstacks)
			{
				if(stack->getAttr(SA::SIDE) == side)
				{
					int slot = stack->getAlias() >= '0' && stack->getAlias() <= '6' ? stack->getAlias() - '0' : 7 + extracounter;

					if(slot < max_stacks_per_side)
						sidestacks.at(slot) = {stack, hex};

					if(slot >= 7)
						extracounter += 1;
				}
			}

			for(int i = 0; i < sidestacks.size(); ++i)
			{
				auto & [stack, hex] = sidestacks.at(i);
				auto colid = 2 + i + side + (max_stacks_per_side * side);

				if(!stack)
				{
					row.at(colid) = {nocol, colwidths.at(colid), ""};
					continue;
				}

				std::string value;

				// MSVC mandates constexpr `n` here
				constexpr int n1 = std::get<2>(S13::HEX_ENCODING[EI(HA::STACK_FLAGS1)]);
				constexpr int n2 = std::get<2>(S13::HEX_ENCODING[EI(HA::STACK_FLAGS2)]);
				auto flags1 = std::bitset<n1>(stack->getAttr(SA::FLAGS1));
				auto flags2 = std::bitset<n2>(stack->getAttr(SA::FLAGS2));

				auto color = stack->getAttr(SA::SIDE) ? bluecol : redcol;

				if(a == SA::QUEUE)
				{
					auto qbits = std::bitset<S13::STACK_QUEUE_SIZE>(stack->getAttr(SA::QUEUE));
					for(int n = 0; n < S13::STACK_QUEUE_SIZE; ++n)
					{
						if(qbits.test(n))
						{
							value = std::to_string(n);
							break;
						}
					}
				}
				else if(a == SA::VALUE_ONE && stack->getAttr(a) >= 1000)
				{
					std::ostringstream oss;
					oss << std::fixed << std::setprecision(1) << (stack->getAttr(a) / 1000.0);
					value = oss.str();
					value[value.size() - 2] = 'k';
					if(value.rfind("K0") == (value.size() - 2))
						value.resize(value.size() - 1);
				}
				else if(a == SA::FLAGS1)
				{
					auto fmt = boost::format("%d/%d");

					switch(specialcounter)
					{
						case 0:
							value = std::string(1, stack->getAlias());
							break;
						case 1:
						{
							value = std::string("");
							value += flags1.test(EI(SF1::CAN_WAIT)) ? "W" : " ";
							value += flags1.test(EI(SF1::WILL_ACT)) ? "A" : " ";
							value += flags1.test(EI(SF1::CAN_RETALIATE)) ? "R" : " ";
						}
						break;
						case 2:
						{
							value = std::string("");
							value += flags1.test(EI(SF1::ADDITIONAL_ATTACK)) ? "D" : " ";
							value += flags2.test(EI(SF2::BLIND_ATTACK)) ? "B" : " ";
						}
						break;
						case 3:
							value = boost::str(fmt % flags1.test(EI(SF1::BLOCKED)) % flags1.test(EI(SF1::BLOCKING)));
							break;
						case 4:
							value = boost::str(fmt % flags1.test(EI(SF1::FLYING)) % flags1.test(EI(SF1::SLEEPING)));
							break;
						case 5:
							value = boost::str(fmt % flags1.test(EI(SF1::BLOCKS_RETALIATION)) % flags1.test(EI(SF1::NO_MELEE_PENALTY)));
							break;
						case 6:
							value = boost::str(fmt % flags1.test(EI(SF1::IS_WIDE)) % flags1.test(EI(SF1::TWO_HEX_ATTACK_BREATH)));
							break;
						default:
							THROW_FORMAT("Unexpected specialcounter: %d", specialcounter);
					}
				}
				else
				{
					value = std::to_string(stack->getAttr(a));
				}

				if((stack->getAttr(SA::QUEUE) & 1) && !ended)
					color += activemod;

				row.at(colid) = {color, colwidths.at(colid), value};
			}
		}

		if(a == SA::FLAGS1)
			++specialcounter;

		table.push_back(row);
	}

	for(auto & r : table)
	{
		auto line = std::stringstream();
		for(auto & [color, width, txt] : r)
			line << color << PadLeft(txt, width, ' ') << nocol;

		lines.push_back(std::move(line));
	}

	//
	// 7. Join rows into a single string
	//
	std::string res = lines[0].str();
	for(int i = 1; i < lines.size(); i++)
		res += "\n" + lines[i].str();

	return res;
}
}
