/**
 * Author: Anton Tarasenko
 * Home repo: https://insultplayers.ru/git/AcediaFramework/AcediaCore/
 * License: GPL
 * Copyright 2023 Anton Tarasenko
 *------------------------------------------------------------------------------
 * This file is part of Acedia.
 *
 * Acedia is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, version 3 of the License, or
 * (at your option) any later version.
 *
 * Acedia is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with Acedia.  If not, see <https://www.gnu.org/licenses/>.
 */
class MapTool extends AcediaObject;

//! Tool for adapting AcediaLauncher's map lists for [`xVotingHandler`].
//!
//! This class is responsible for making [`xVotingHandler`] use specific maps (defined in
//! AcediaLauncer's configs) for specific game modes.
//! To achieve that it abuses [`xVotingHandler`]'s ability to filter maps by
//! game mode-specific prefix.
//! Normally prefix filtering for [`xVotingHandler`] is of limited use, because most Killing Floor
//! maps start with the same prefix `KF-` (and some objective ones starting with `KFO-`).
//! However we swap that prefix for something unique for each game mode: `MapSet0-`, `MapSet1-`,
//! etc, allowing us to pick the precise map set we want.
//!
//! There's two main challenges:
//!
//! 1. *Altered map names break voting* - since [`xVotingHandler`] expects to be provided real map
//!     names and our mangled ones. We deal with it by catching a map change message broadcasted
//!     right before actual map change occurs and swap our names with real ones.
//! 2. *Increased amount of maps to replicate* - if we implement this name mangling by using
//!     a naive approach, in which we separately add maps for every game mode, then it will lead to
//!     drastic increase in replication time of the complete map list to players.
//!     Consider, for example, that you have 10 different game modes with exactly the same maps:
//!     we will be needlessly replicating the exact same thing 10 times!
//!     To solve this issue we specifically track map lists other game modes use, along with
//!     prefixes assigned to them, and reuse already added maps in case two game modes are defined
//!     to use the exactly same ones.

/// For storing which map sequences have which prefixes. Storage order is important.
struct MapSequenceRecord {
    var public array<string> sequence;
    var public string prefix;
};

// To avoid doing excesive work when injecting maps for a second time
var private bool injectedMaps;

// Finding voting handler is not cheap, so only do it once and then store it.
var private NativeActorRef votingHandlerReference;
// Resulting full map list with pseudonim (with replaced prefixes) and real names of maps.
var private array<VotingHandler.MapVoteMapList> pseudonimMapList;
var private array<VotingHandler.MapVoteMapList> realMapList;
var private array<KFVotingHandler.FMapRepType> likesData;

// Map sequences used by game modes we've seen so far.
var private array<MapSequenceRecord> usedMapSequences;

// To more easily detect broadcasted message about map change we replace it with our own that is
// both unlikely to occur and is easy to get voted map name from.
var private string backupMessageMapWon;
var private string backupMessageAdminMapChange;
var private const string ACEDIA_ADMIN_MAP_CHANGE_COMMAND;
var private const string ACEDIA_MAP_WON_COMMAND;

var private LoggerAPI.Definition fatVotingHandlerMissing, warnMissingMapList;

protected function Finalizer() {
    _server.unreal.broadcasts.OnHandleText(self).Disconnect();
    _.memory.Free(votingHandlerReference);
    votingHandlerReference = none;
    pseudonimMapList.length = 0;
    realMapList.length = 0;
    usedMapSequences.length = 0;
    injectedMaps = false;
}

/// Initializes [`MapTool`] by associating it with an [`XVotingHandler`].
///
/// Initialization fails if [`initVotingHandlerReference`] doesn't provide reference to
/// [`XVotingHandler`] or caller [`MapTool`] already was initialized.
/// Returns `true` iff initialization was successful.
public final function bool Initialize(NativeActorRef initVotingHandlerReference) {
    if (initVotingHandlerReference == none)                         return false;
    if (XVotingHandler(initVotingHandlerReference.Get()) == none)   return false;

    initVotingHandlerReference.NewRef();
    votingHandlerReference = initVotingHandlerReference;
    return true;
}

/// Adds map information from the new game mode.
///
/// Returns prefix that given game mode must use to display maps configured for it.
public final function string AddGameMode(GameMode gameMode) {
    local XVotingHandler votingHandler;
    local string gameModePrefix;

    votingHandler = GetVotingHandler();
    if (votingHandler == none) {
        _.logger.Auto(fatVotingHandlerMissing);
        return "KF";
    }
    if (CheckNeedToLoadMaps(gameMode, gameModePrefix)) {
        LoadGameModeMaps(gameMode, gameModePrefix, votingHandler);
    }
    return gameModePrefix;
}

/// Injects final map list into [`XVotingHandler`].
///
/// Call this after all game modes have been added.
/// Shouldn't be called more than once.
public final function Inject() {
    local KFVotingHandler kfHandler;
    local XVotingHandler votingHandler;

    votingHandler = GetVotingHandler();
    if (votingHandler == none) {
        _.logger.Auto(fatVotingHandlerMissing);
        return;
    }
    votingHandler.mapList = pseudonimMapList;
    votingHandler.mapCount = pseudonimMapList.length;
    kfHandler = KFVotingHandler(votingHandler);
    if (kfHandler != none) {
        kfHandler.repArray = likesData;
    }
    // Replace map change messages with our commands and make sure it is done only once,
    // in case we mess up somewhere else and call this method second time
    if (!injectedMaps) {
        backupMessageMapWon = votingHandler.lmsgMapWon;
        backupMessageAdminMapChange = votingHandler.lmsgAdminMapChange;
        votingHandler.lmsgMapWon = ACEDIA_MAP_WON_COMMAND $ "::%mapname%";
        votingHandler.lmsgAdminMapChange = ACEDIA_ADMIN_MAP_CHANGE_COMMAND $ "::%mapname%";
        _server.unreal.broadcasts.OnHandleText(self).connect = HandleMapChange;
    }
    injectedMaps = true;
}

/// Builds arrays of [`VotingHandler::MapVoteMapList`] (each such item describes a map +
/// its meta data in a way [`XVotingHandler`] understands).
private function string LoadGameModeMaps(
    GameMode gameMode,
    string gameModePrefix,
    XVotingHandler votingHandler
) {
    local int i;
    local ArrayList gameModeMaps;
    local Text mapNameReal, mapNamePseudonim;
    local KFVotingHandler kfHandler;
    local KFVotingHandler.FMapRepType nextReputation;
    local VotingHandler.MapHistoryInfo nextMapInfo;
    local VotingHandler.MapVoteMapList nextRecord;
    local array<VotingHandler.MapVoteMapList> newMapsPseudonim, newMapReal;

    kfHandler = KFVotingHandler(votingHandler);
    nextRecord.bEnabled = true;
    gameModeMaps = GetAllGameModeMaps(gameMode);
    for (i = 0; i < gameModeMaps.GetLength(); i += 1) {
        mapNameReal = gameModeMaps.GetText(i);
        mapNamePseudonim = MakeMapPseudonim(mapNameReal, gameModePrefix);
        if (votingHandler.history != none) {
            nextMapInfo = votingHandler.history.GetMapHistory(mapNameReal.ToString());
            nextRecord.playCount = nextMapInfo.p;
            nextRecord.sequence = nextMapInfo.s;
        }
        nextRecord.mapName = _.text.IntoString(mapNamePseudonim);
        newMapsPseudonim[newMapsPseudonim.length] = nextRecord;
        nextRecord.mapName = _.text.IntoString(mapNameReal);
        newMapReal[newMapReal.length] = nextRecord;
        if (kfHandler != none) {
            class'MVMapRepHistory'.static.GetMapHistoryRep(
                nextRecord.mapName,
                nextReputation.positive,
                nextReputation.negative);
            likesData[likesData.length] = nextReputation;
        }
    }
    AppendMapsIntoVotingHandler(newMapsPseudonim, newMapReal);
    _.memory.Free(gameModeMaps);
    return gameModePrefix;
}

private function bool CheckNeedToLoadMaps(GameMode gameMode, out string prefix) {
    local int mapSequenceIndex, mapListIndex;
    local bool sameMapList, foundMatch;
    local array<string> existingMapSequence, newMapSequence;
    local MapSequenceRecord newRecord;

    // We don't need to load maps for the `gameMode` only when we've already added the exactly same
    // map sequence, order being important
    newMapSequence = gameMode.GetIncludedMapLists_S();
    for (mapSequenceIndex = 0; mapSequenceIndex < usedMapSequences.length; mapSequenceIndex += 1) {
        existingMapSequence = usedMapSequences[mapSequenceIndex].sequence;
        if (existingMapSequence.length != newMapSequence.length) {
            continue;
        }
        foundMatch = true;
        for (mapListIndex = 0; mapListIndex < newMapSequence.length; mapListIndex += 1) {
            // Map lists are ASCII config names, so we can compare them with case-ignoring
            // built-in `~=` operator works (it can only handle properly ASCII input)
            sameMapList = (existingMapSequence[mapListIndex] ~= newMapSequence[mapListIndex]);
            if (!sameMapList) {
                foundMatch = false;
                break;
            }
        }
        if (foundMatch) {
            prefix = usedMapSequences[mapSequenceIndex].prefix;
            return false;
        }
    }
    newRecord.sequence = newMapSequence;
    newRecord.prefix = "MapSet" $ usedMapSequences.length;
    usedMapSequences[usedMapSequences.length] = newRecord;
    prefix = newRecord.prefix;
    return true;
}

// Replaces prefixes like "KF-", "KFO-" or "KFS-" with "{gameModePrefix}-".
private function Text MakeMapPseudonim(Text realName, string gameModePrefix) {
    local Parser parser;
    local MutableText prefix, nameBody;
    local MutableText result;

    result = _.text.FromStringM(gameModePrefix);
    result.Append(P("-"));
    parser = realName.Parse();
    parser.MUntil(prefix, _.text.GetCharacter("-"));
    parser.Match(P("-"));
    if (parser.Ok()) {
        nameBody = parser.GetRemainderM();
        result.Append(nameBody);
    }
    else {
        result.Append(realName);
    }
    _.memory.Free(nameBody);
    _.memory.Free(prefix);
    _.memory.Free(parser);
    return result.IntoText();
}

private function ArrayList GetAllGameModeMaps(GameMode gameMode) {
    local int i, j;
    local HashTable uniqueMapSet;
    local ArrayList result;
    local array<Text> usedMapLists;
    local array<string> nextMapArray;
    local Text nextMapName, lowerMapName;

    uniqueMapSet = _.collections.EmptyHashTable(); // for testing map name uniqueness
    result = _.collections.EmptyArrayList();
    usedMapLists = gameMode.GetIncludedMapLists();
    for (i = 0; i < usedMapLists.length; i += 1) {
        nextMapArray = GetMapNameFromConfig(usedMapLists[i]);
        for (j = 0; j < nextMapArray.length; j += 1) {
            nextMapName = _.text.FromString(nextMapArray[j]);
            // Use lower case version of map name for uniqueness testing to ignore characters' case
            lowerMapName = nextMapName.LowerCopy();
            if (!uniqueMapSet.HasKey(lowerMapName)) {
                uniqueMapSet.SetItem(lowerMapName, none);
                result.AddItem(nextMapName);
            }
            _.memory.Free(lowerMapName);
            _.memory.Free(nextMapName);
        }
    }
    _.memory.Free(uniqueMapSet);
    _.memory.FreeMany(usedMapLists);
    return result;
}

private function array<string> GetMapNameFromConfig(Text configName) {
    local MapList mapConfig;
    local array<string> result;

    mapConfig = MapList(class'MapList'.static.GetConfigInstance(configName));
    if (mapConfig == none) {
        _.logger.Auto(warnMissingMapList).Arg(configName.Copy());
    } else {
        result = mapConfig.map;
        _.memory.Free(mapConfig);
    }
    return result;
}

private function AppendMapsIntoVotingHandler(
    array<VotingHandler.MapVoteMapList> newMapsPseudonim,
    array<VotingHandler.MapVoteMapList> newMapsReal) {
    local int i;
    local XVotingHandler votingHandler;

    votingHandler = GetVotingHandler();
    if (votingHandler == none) {
        _.logger.Auto(fatVotingHandlerMissing);
        return;
    }
    for (i = 0; i < newMapsPseudonim.length; i += 1) {
        pseudonimMapList[pseudonimMapList.length] = newMapsPseudonim[i];
    }
    for (i = 0; i < newMapsReal.length; i += 1) {
        realMapList[realMapList.length] = newMapsReal[i];
    }
}

private function bool HandleMapChange(
    Actor sender,
    out string message,
    name type,
    bool teamMessage
) {
    local Parser parser;
    local XVotingHandler votingHandler;

    votingHandler = GetVotingHandler();
    if (sender == none)             return true;
    if (votingHandler != sender)    return true;

    parser = _.text.ParseString(message);
    parser.Match(P(ACEDIA_MAP_WON_COMMAND));
    parser.Match(P("::"));
    if (parser.Ok()) {
        message = Repl(backupMessageMapWon, "%mapname%", parser.GetRemainderS());
    } else {
        parser.Match(P(ACEDIA_ADMIN_MAP_CHANGE_COMMAND));
        parser.Match(P("::"));
        if (parser.Ok()) {
            message = Repl(backupMessageAdminMapChange, "%mapname%", parser.GetRemainderS());
        }
    }
    if (parser.Ok()) {
        votingHandler.mapList = realMapList;
        votingHandler.mapCount = realMapList.length;
    }
    _.memory.Free(parser);
    return true;
}

private function XVotingHandler GetVotingHandler() {
    if (votingHandlerReference != none) {
        return XVotingHandler(votingHandlerReference.Get());
    }
    return none;
}

defaultproperties {
    ACEDIA_ADMIN_MAP_CHANGE_COMMAND = "ACEDIA_LAUNCHER:ADMIN_MAP_CHANGE:DEADBEEF"
    ACEDIA_MAP_WON_COMMAND = "ACEDIA_LAUNCHER:MAP_WON:DEADBEEF"
    fatVotingHandlerMissing = (l=LOG_Fatal,m="No voting `XVotingHandler` available. This is unexpected at this stage. Report this issue.")
    warnMissingMapList      = (l=LOG_Warning,m="Cannot find map list `%1`.")
}