/*
Copyright (C) 1996-2001 Id Software, Inc.
Copyright (C) 2002-2009 John Fitzgibbons and others
Copyright (C) 2010-2014 QuakeSpasm developers

This program 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; either version 2
of the License, or (at your option) any later version.

This program 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 this program; if not, write to the Free Software
Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

*/

// common.c -- misc functions used in client and server

#include <cstring>

#include "quakedef.hpp"
#include "q_ctype.hpp"
#include <errno.h>
#include <sstream>

#include "miniz.hpp"

static char argvdummy[] = " ";

convar registered = {"registered", "1", {.rom = true}}; /* set to correct value in COM_CheckRegistered() */
convar cmdline = {"cmdline", "", {.rom = true}/*|CVAR_SERVERINFO*/}; /* sending cmdline upon CCREQ_RULE_INFO is evil */

static bool com_modified; // set true if using non-id files

bool fitzmode;

static void COM_Path_f(void);

// if a packfile directory differs from this, it is assumed to be hacked
#define PAK0_COUNT		339	/* id1/pak0.pak - v1.0x */
#define PAK0_CRC_V100		13900	/* id1/pak0.pak - v1.00 */
#define PAK0_CRC_V101		62751	/* id1/pak0.pak - v1.01 */
#define PAK0_CRC_V106		32981	/* id1/pak0.pak - v1.06 */
#define PAK0_CRC	(PAK0_CRC_V106)
#define PAK0_COUNT_V091		308	/* id1/pak0.pak - v0.91/0.92, not supported */
#define PAK0_CRC_V091		28804	/* id1/pak0.pak - v0.91/0.92, not supported */


bool standard_quake = true, rogue, hipnotic;

// this graphic needs to be in the pak file to use registered features
static unsigned short pop[] =
{
    0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000,
    0x0000, 0x0000, 0x6600, 0x0000, 0x0000, 0x0000, 0x6600, 0x0000,
    0x0000, 0x0066, 0x0000, 0x0000, 0x0000, 0x0000, 0x0067, 0x0000,
    0x0000, 0x6665, 0x0000, 0x0000, 0x0000, 0x0000, 0x0065, 0x6600,
    0x0063, 0x6561, 0x0000, 0x0000, 0x0000, 0x0000, 0x0061, 0x6563,
    0x0064, 0x6561, 0x0000, 0x0000, 0x0000, 0x0000, 0x0061, 0x6564,
    0x0064, 0x6564, 0x0000, 0x6469, 0x6969, 0x6400, 0x0064, 0x6564,
    0x0063, 0x6568, 0x6200, 0x0064, 0x6864, 0x0000, 0x6268, 0x6563,
    0x0000, 0x6567, 0x6963, 0x0064, 0x6764, 0x0063, 0x6967, 0x6500,
    0x0000, 0x6266, 0x6769, 0x6a68, 0x6768, 0x6a69, 0x6766, 0x6200,
    0x0000, 0x0062, 0x6566, 0x6666, 0x6666, 0x6666, 0x6562, 0x0000,
    0x0000, 0x0000, 0x0062, 0x6364, 0x6664, 0x6362, 0x0000, 0x0000,
    0x0000, 0x0000, 0x0000, 0x0062, 0x6662, 0x0000, 0x0000, 0x0000,
    0x0000, 0x0000, 0x0000, 0x0061, 0x6661, 0x0000, 0x0000, 0x0000,
    0x0000, 0x0000, 0x0000, 0x0000, 0x6500, 0x0000, 0x0000, 0x0000,
    0x0000, 0x0000, 0x0000, 0x0000, 0x6400, 0x0000, 0x0000, 0x0000
};

/*

All of Quake's data access is through a hierchal file system, but the contents
of the file system can be transparently merged from several sources.

The "base directory" is the path to the directory holding the quake.exe and all
game directories.  The sys_* files pass this to host_init in quakeparms_t->basedir.
This can be overridden with the "-basedir" command line parm to allow code
debugging in a different directory.  The base directory is only used during
filesystem initialization.

The "game directory" is the first tree on the search path and directory that all
generated files (savegames, screenshots, demos, config files) will be saved to.
This can be overridden with the "-game" command line parameter.  The game
directory can never be changed while quake is executing.  This is a precacution
against having a malicious server instruct clients to write files over areas they
shouldn't.

The "cache directory" is only used during development to save network bandwidth,
especially over ISDN / T1 lines.  If there is a cache directory specified, when
a file is found by the normal search path, it will be mirrored into the cache
directory, then opened there.

FIXME:
The file "parms.txt" will be read out of the game directory and appended to the
current command line arguments to allow different games to initialize startup
parms differently.  This could be used to add a "-sspeed 22050" for the high
quality sound edition.  Because they are added at the end, they will not
override an explicit setting on the original command line.

*/

//============================================================================


// ClearLink is used for new headnodes
void ClearLink(link_t *l) {
    l->prev = l->next = l;
}

void RemoveLink(link_t *l) {
    l->next->prev = l->prev;
    l->prev->next = l->next;
}

void InsertLinkBefore(link_t *l, link_t *before) {
    l->next = before;
    l->prev = before->prev;
    l->prev->next = l;
    l->next->prev = l;
}

void InsertLinkAfter(link_t *l, link_t *after) {
    l->next = after->next;
    l->prev = after;
    l->prev->next = l;
    l->next->prev = l;
}


/*
============================================================================

					LIBRARY REPLACEMENT FUNCTIONS

============================================================================
*/

int q_strcasecmp(const char *s1, const char *s2) {
    const char *p1 = s1;
    const char *p2 = s2;
    char c1, c2;

    if (p1 == p2)
        return 0;

    do {
        c1 = q_tolower(*p1++);
        c2 = q_tolower(*p2++);
        if (c1 == '\0')
            break;
    } while (c1 == c2);

    return (int) (c1 - c2);
}

int q_strncasecmp(const char *s1, const char *s2, size_t n) {
    const char *p1 = s1;
    const char *p2 = s2;
    char c1, c2;

    if (p1 == p2 || n == 0)
        return 0;

    do {
        c1 = q_tolower(*p1++);
        c2 = q_tolower(*p2++);
        if (c1 == '\0' || c1 != c2)
            break;
    } while (--n > 0);

    return (int) (c1 - c2);
}

char *q_strcasestr(const char *haystack, const char *needle) {
    const size_t len = strlen(needle);

    while (*haystack) {
        if (std::is_eq(common::strncasecmp(haystack, needle, len)))
            return (char *) haystack;

        ++haystack;
    }

    return NULL;
}

char *q_strlwr(char *str) {
    char *c;
    c = str;
    while (*c) {
        *c = q_tolower(*c);
        c++;
    }
    return str;
}

char *q_strupr(char *str) {
    char *c;
    c = str;
    while (*c) {
        *c = q_toupper(*c);
        c++;
    }
    return str;
}

/* platform dependant (v)snprintf function names: */
#if defined(_WIN32)
#define	snprintf_func		_snprintf
#define	vsnprintf_func		_vsnprintf
#else
#define	snprintf_func		snprintf
#define	vsnprintf_func		vsnprintf
#endif

int q_vsnprintf(char *str, const size_t size, const char *format, va_list args) {
    int ret;

    ret = vsnprintf_func(str, size, format, args);

    if (ret < 0)
        ret = (int) size;
    if (size == 0) /* no buffer */
        return ret;
    if ((size_t) ret >= size)
        str[size - 1] = '\0';

    return ret;
}

int q_snprintf(char *str, const size_t size, const char *format, ...) {
    int ret;
    va_list argptr;

    va_start(argptr, format);
    ret = q_vsnprintf(str, size, format, argptr);
    va_end(argptr);

    return ret;
}

/*
============================================================================

					BYTE ORDER FUNCTIONS

============================================================================
*/

bool host_bigendian;

short (*BigShort)(short l);

short (*LittleShort)(short l);

int (*BigLong)(int l);

int (*LittleLong)(int l);

float (*BigFloat)(float l);

float (*LittleFloat)(float l);

short ShortSwap(const short l) {
    byte b1, b2;

    b1 = l & 255;
    b2 = (l >> 8) & 255;

    return (b1 << 8) + b2;
}

short ShortNoSwap(const short l) {
    return l;
}

int LongSwap(const int l) {
    byte b1, b2, b3, b4;

    b1 = l & 255;
    b2 = (l >> 8) & 255;
    b3 = (l >> 16) & 255;
    b4 = (l >> 24) & 255;

    return ((int) b1 << 24) + ((int) b2 << 16) + ((int) b3 << 8) + b4;
}

int LongNoSwap(const int l) {
    return l;
}

float FloatSwap(const float f) {
    union {
        float f;
        byte b[4];
    } dat1, dat2;


    dat1.f = f;
    dat2.b[0] = dat1.b[3];
    dat2.b[1] = dat1.b[2];
    dat2.b[2] = dat1.b[1];
    dat2.b[3] = dat1.b[0];
    return dat2.f;
}

float FloatNoSwap(const float f) {
    return f;
}

//============================================================================

/*
============
COM_SkipPath
============
*/
const char *COM_SkipPath(const char *pathname) {
    const char *last;

    last = pathname;
    while (*pathname) {
        if (*pathname == '/')
            last = pathname + 1;
        pathname++;
    }
    return last;
}

/*
============
COM_StripExtension
============
*/
void COM_StripExtension(const char *in, char *out, const size_t outsize) {
    int length;

    if (!*in) {
        *out = '\0';
        return;
    }
    if (in != out) /* copy when not in-place editing */
        q_strlcpy(out, in, outsize);
    length = (int) strlen(out) - 1;
    while (length > 0 && out[length] != '.') {
        --length;
        if (out[length] == '/' || out[length] == '\\')
            return; /* no extension */
    }
    if (length > 0)
        out[length] = '\0';
}

/*
============
COM_FileGetExtension - doesn't return NULL
============
*/
const char *COM_FileGetExtension(const char *in) {
    const char *src;
    size_t len;

    len = strlen(in);
    if (len < 2) /* nothing meaningful */
        return "";

    src = in + len - 1;
    while (src != in && src[-1] != '.')
        src--;
    if (src == in || strchr(src, '/') != NULL || strchr(src, '\\') != NULL)
        return ""; /* no extension, or parent directory has a dot */

    return src;
}

/*
============
COM_ExtractExtension
============
*/
void COM_ExtractExtension(const char *in, char *out, const size_t outsize) {
    const char *ext = COM_FileGetExtension(in);
    if (!*ext)
        *out = '\0';
    else
        q_strlcpy(out, ext, outsize);
}

/*
============
COM_FileBase
take 'somedir/otherdir/filename.ext',
write only 'filename' to the output
============
*/
void COM_FileBase(const char *in, char *out, const size_t outsize) {
    const char *dot, *slash, *s;

    s = in;
    slash = in;
    dot = NULL;
    while (*s) {
        if (*s == '/')
            slash = s + 1;
        if (*s == '.')
            dot = s;
        s++;
    }
    if (dot == NULL)
        dot = s;

    if (dot - slash < 2)
        q_strlcpy(out, "?model?", outsize);
    else {
        size_t len = dot - slash;
        if (len >= outsize)
            len = outsize - 1;
        memcpy(out, slash, len);
        out[len] = '\0';
    }
}

/*
==================
COM_DefaultExtension
if path doesn't have a .EXT, append extension
(extension should include the leading ".")
==================
*/
#if 0 /* can be dangerous */
void COM_DefaultExtension(char *path, const char *extension, size_t len) {
    char *src;

    if (!*path) return;
    src = path + strlen(path) - 1;

    while (*src != '/' && *src != '\\' && src != path) {
        if (*src == '.')
            return; // it has an extension
        src--;
    }

    q_strlcat(path, extension, len);
}
#endif

/*
==================
COM_AddExtension
if path extension doesn't match .EXT, append it
(extension should include the leading ".")
==================
*/
void COM_AddExtension(char *path, const char *extension, const size_t len) {
    if (strcmp(COM_FileGetExtension(path), extension + 1) != 0)
        q_strlcat(path, extension, len);
}


namespace common {
    std::vector<std::string> _argv{};
    std::string cmdline{};
    bool safe_mode{false};

    void init() {
        int i = 0x12345678;
        /*    U N I X */

        /*
        BE_ORDER:  12 34 56 78
               U  N  I  X

        LE_ORDER:  78 56 34 12
               X  I  N  U

        PDP_ORDER: 34 12 78 56
               N  U  X  I
        */
        if (*reinterpret_cast<char *>(&i) == 0x12) {
            host_bigendian = true;
        } else if (*reinterpret_cast<char *>(&i) == 0x78) {
            host_bigendian = false;
        } else {
            Sys_Error("Unsupported endianism.");
        }

        if (host_bigendian) {
            BigShort = ShortNoSwap;
            LittleShort = ShortSwap;
            BigLong = LongNoSwap;
            LittleLong = LongSwap;
            BigFloat = FloatNoSwap;
            LittleFloat = FloatSwap;
        } else /* assumed LITTLE_ENDIAN. */
        {
            BigShort = ShortSwap;
            LittleShort = ShortNoSwap;
            BigLong = LongSwap;
            LittleLong = LongNoSwap;
            BigFloat = FloatSwap;
            LittleFloat = FloatNoSwap;
        }

        if (check_param("-fitz").has_value()) {
            fitzmode = true;
        }
    }

    void init_argv(const int nargc, char **nargv) {
        for (auto j = 0; (j < MAX_NUM_ARGVS) && (j < nargc); j++) {
            cmdline += nargv[j];
            cmdline += " ";
        }

        if (!cmdline.empty() && cmdline.back() == ' ')
            cmdline.pop_back();

        console::info("Command line: %s\n", cmdline.c_str());

        for (auto i = 0; (i < MAX_NUM_ARGVS) && (i < nargc); i++) {
            _argv.emplace_back(nargv[i]);
            if (_argv.back() == "-safe")
                safe_mode = true;
        }

        _argv.push_back(" ");

        if (check_param("-rogue").has_value()) {
            rogue = true;
            standard_quake = false;
        }

        if (check_param("-hipnotic").has_value() || check_param("-quoth").has_value()) //johnfitz -- "-quoth" support
        {
            hipnotic = true;
            standard_quake = false;
        }
    }

    std::optional<size_t> check_param(const std::string_view parm) {
        for (auto i = 1; i < _argv.size(); i++) {
            if (_argv[i].empty()) {
                continue; // NEXTSTEP sometimes clears appkit vars.
            }
            if (parm == _argv[i]) {
                return i;
            }
        }

        return std::nullopt;
    }

    /**
     * Parses a single token from a string-stream.
     *
     * @param ss String-stream containing the data buffer to parse.
     * @return A token, if parseable, otherwise \c std::nullopt .
     */
    std::optional<std::string> parse_token(std::istringstream &ss) {
        std::string res{};
    skipwhite:
        while (ss.peek() <= ' ') {
            ss.get();
            if (ss.peek() == '\0') {
                return std::nullopt;
            }
            if (ss.eof()) {
                return std::nullopt;
            }
        }

        if (ss.peek() == '/') {
            ss.get();
            switch (ss.peek()) {
                case '/': {
                    std::string _ignore;
                    std::getline(ss, _ignore);
                    goto skipwhite;
                }
                case '*': {
                    ss.get();
                commentwait:
                    while (ss.peek() != '*') {
                        ss.get();
                    }
                    ss.get();
                    if (ss.peek() != '/') {
                        goto commentwait;
                    }
                    ss.get();
                    goto skipwhite;
                }
                default:
                    ss.unget();
            }
        }

        if (ss.peek() == '"') {
            ss.get();
            std::getline(ss, res, '"');
            return res;
        }

        if (ss.peek() == '{' || ss.peek() == '}' || ss.peek() == '(' || ss.peek() == ')' || ss.peek() == '\'' || ss.
            peek() == ':') {
            res += static_cast<char>(ss.get());
            return res;
        }

        do {
            res += static_cast<char>(ss.get());
            if (ss.peek() == '{' || ss.peek() == '}' || ss.peek() == '(' || ss.peek() == ')' || ss.peek() == '\'') {
                break;
            }
        } while (ss.peek() > ' ');

        return res;
    }

    /**
     * Parses a string of non-whitespace, and then attempts to consume a newline.
     *
     * N.B. This is implementing the function as \a described, not following the original
     * implementation. The original implementation is bugged.
     * @param ss
     * @return
     */
    std::string parse_string_newline(std::istringstream &ss) {
        std::string res;

        while (ss.peek() > ' ') {
            res += static_cast<char>(ss.get());
        }
        if (ss.peek() == '\n') {
            ss.get();
        }

        return res;
    }

    /**
     * Attempts to parse an int, followed by a newline.
     *
     * @param ss
     * @return
     */
    int parse_int_newline(std::istringstream &ss) {
        int res;

        ss >> res;
        if (ss.peek() == '\n') {
            ss.get();
        }

        return res;
    }

    /**
     * Attempts to parse a float followed by a newline.
     *
     * @param ss
     * @return
     */
    float parse_float_newline(std::istringstream &ss) {
        float res;

        ss >> res;
        if (ss.peek() == '\n') {
            ss.get();
        }

        return res;
    }

    std::partial_ordering strcasecmp(const std::string_view lhs, const std::string_view rhs) {
        return std::lexicographical_compare_three_way(lhs.begin(), lhs.end(), rhs.begin(), rhs.end(),
                                                      [](const char c, const char d) {
                                                          return q_tolower(c) <=> q_tolower(d);
                                                      });
    }

    std::partial_ordering strncasecmp(const std::string_view lhs, const std::string_view rhs, const size_t n) {
        return std::lexicographical_compare_three_way(lhs.begin(), lhs.begin() + std::min(lhs.size(), n), rhs.begin(),
                                                      rhs.begin() + std::min(rhs.size(), n),
                                                      [](const char c, const char d) {
                                                          return q_tolower(c) <=> q_tolower(d);
                                                      });
    }
}


/*
================
COM_CheckRegistered

Looks for the pop.txt file and verifies it.
Sets the "registered" cvar.
Immediately exits out if an alternate game was attempted to be started without
being registered.
================
*/
static void COM_CheckRegistered(void) {
    int h;
    unsigned short check[128];
    int i;

    COM_OpenFile("gfx/pop.lmp", &h, NULL);

    if (h == -1) {
        convar::set_rom("registered", "0");
        console::info("Playing shareware version.\n");
        if (com_modified)
            Sys_Error("You must have the registered version to use modified games.\n\n"
                      "Basedir is: %s\n\n"
                      "Check that this has an " GAMENAME " subdirectory containing pak0.pak and pak1.pak, "
                      "or use the -basedir command-line option to specify another directory.",
                      com_basedir);
        return;
    }

    i = Sys_FileRead(h, check, sizeof(check));
    COM_CloseFile(h);
    if (i != (int) sizeof(check))
        goto corrupt;

    for (i = 0; i < 128; i++) {
        if (pop[i] != (unsigned short) BigShort(check[i])) {
        corrupt:
            Sys_Error("Corrupted data file.");
        }
    }

    for (i = 0; common::cmdline[i]; i++) {
        if (common::cmdline[i] != ' ')
            break;
    }

    convar::set_rom("cmdline", &common::cmdline[i]);
    convar::set_rom("registered", "1");
    console::info("Playing registered version.\n");
}


/*
============
va

does a varargs printf into a temp buffer. cycles between
4 different static buffers. the number of buffers cycled
is defined in VA_NUM_BUFFS.
FIXME: make this buffer size safe someday
============
*/
#define	VA_NUM_BUFFS	4
#define	VA_BUFFERLEN	1024

static char *get_va_buffer(void) {
    static char va_buffers[VA_NUM_BUFFS][VA_BUFFERLEN];
    static int buffer_idx = 0;
    buffer_idx = (buffer_idx + 1) & (VA_NUM_BUFFS - 1);
    return va_buffers[buffer_idx];
}

char *va(const char *format, ...) {
    va_list argptr;
    char *va_buf;

    va_buf = get_va_buffer();
    va_start(argptr, format);
    q_vsnprintf(va_buf, VA_BUFFERLEN, format, argptr);
    va_end(argptr);

    return va_buf;
}

/*
=============================================================================

QUAKE FILESYSTEM

=============================================================================
*/

int com_filesize;


//
// on-disk pakfile
//
typedef struct {
    char name[56];
    int filepos, filelen;
} dpackfile_t;

typedef struct {
    char id[4];
    int dirofs;
    int dirlen;
} dpackheader_t;

#define MAX_FILES_IN_PACK	2048

char com_gamedir[MAX_OSPATH];
char com_basedir[MAX_OSPATH];
int file_from_pak; // ZOID: global indicating that file came from a pak

searchpath_t *com_searchpaths;
searchpath_t *com_base_searchpaths;

/*
============
COM_Path_f
============
*/
static void COM_Path_f(void) {
    searchpath_t *s;

    console::info("Current search path:\n");
    for (s = com_searchpaths; s; s = s->next) {
        if (s->pack) {
            console::info("%s (%i files)\n", s->pack->filename, s->pack->numfiles);
        } else
            console::info("%s\n", s->filename);
    }
}

/*
============
COM_WriteFile

The filename will be prefixed by the current game directory
============
*/
void COM_WriteFile(const char *filename, const void *data, const int len) {
    int handle;
    char name[MAX_OSPATH];

    Sys_mkdir(com_gamedir); //johnfitz -- if we've switched to a nonexistant gamedir, create it now so we don't crash

    q_snprintf(name, sizeof(name), "%s/%s", com_gamedir, filename);

    handle = Sys_FileOpenWrite(name);
    if (handle == -1) {
        Sys_Printf("COM_WriteFile: failed on %s\n", name);
        return;
    }

    Sys_Printf("COM_WriteFile: %s\n", name);
    Sys_FileWrite(handle, data, len);
    Sys_FileClose(handle);
}

/*
============
COM_CreatePath
============
*/
void COM_CreatePath(char *path) {
    char *ofs;

    for (ofs = path + 1; *ofs; ofs++) {
        if (*ofs == '/') {
            // create the directory
            *ofs = 0;
            Sys_mkdir(path);
            *ofs = '/';
        }
    }
}

/*
================
COM_filelength
================
*/
long COM_filelength(FILE *f) {
    long pos, end;

    pos = ftell(f);
    fseek(f, 0, SEEK_END);
    end = ftell(f);
    fseek(f, pos, SEEK_SET);

    return end;
}

/*
===========
COM_FindFile

Finds the file in the search path.
Sets com_filesize and one of handle or file
If neither of file or handle is set, this
can be used for detecting a file's presence.
===========
*/
static int COM_FindFile(const char *filename, int *handle, FILE **file,
                        unsigned int *path_id) {
    searchpath_t *search;
    char netpath[MAX_OSPATH];
    pack_t *pak;
    int i;

    if (file && handle)
        Sys_Error("COM_FindFile: both handle and file set");

    file_from_pak = 0;

    //
    // search through the path, one element at a time
    //
    for (search = com_searchpaths; search; search = search->next) {
        if (search->pack) /* look through all the pak file elements */
        {
            pak = search->pack;
            for (i = 0; i < pak->numfiles; i++) {
                if (strcmp(pak->files[i].name, filename) != 0)
                    continue;
                // found it!
                com_filesize = pak->files[i].filelen;
                file_from_pak = 1;
                if (path_id)
                    *path_id = search->path_id;
                if (handle) {
                    *handle = pak->handle;
                    Sys_FileSeek(pak->handle, pak->files[i].filepos);
                    return com_filesize;
                } else if (file) {
                    /* open a new file on the pakfile */
                    *file = fopen(pak->filename, "rb");
                    if (*file)
                        fseek(*file, pak->files[i].filepos, SEEK_SET);
                    return com_filesize;
                } else /* for COM_FileExists() */
                {
                    return com_filesize;
                }
            }
        } else /* check a file in the directory tree */
        {
            if (!registered.value) {
                /* if not a registered version, don't ever go beyond base */
                if (strchr(filename, '/') || strchr(filename, '\\'))
                    continue;
            }

            q_snprintf(netpath, sizeof(netpath), "%s/%s", search->filename, filename);
            if (!(Sys_FileType(netpath) & FS_ENT_FILE))
                continue;

            if (path_id)
                *path_id = search->path_id;
            if (handle) {
                com_filesize = Sys_FileOpenRead(netpath, &i);
                *handle = i;
                return com_filesize;
            } else if (file) {
                *file = fopen(netpath, "rb");
                com_filesize = (*file == NULL) ? -1 : COM_filelength(*file);
                return com_filesize;
            } else {
                return 0; /* dummy valid value for COM_FileExists() */
            }
        }
    }

    if (strcmp(COM_FileGetExtension(filename), "pcx") != 0
        && strcmp(COM_FileGetExtension(filename), "tga") != 0
        && strcmp(COM_FileGetExtension(filename), "lit") != 0
        && strcmp(COM_FileGetExtension(filename), "vis") != 0
        && strcmp(COM_FileGetExtension(filename), "ent") != 0)
        console::debug("FindFile: can't find %s\n", filename);
    else console::trace("FindFile: can't find %s\n", filename);

    if (handle)
        *handle = -1;
    if (file)
        *file = NULL;
    com_filesize = -1;
    return com_filesize;
}


/*
===========
COM_FileExists

Returns whether the file is found in the quake filesystem.
===========
*/
bool COM_FileExists(const char *filename, unsigned int *path_id) {
    int ret = COM_FindFile(filename, NULL, NULL, path_id);
    return (ret == -1) ? false : true;
}

/*
===========
COM_OpenFile

filename never has a leading slash, but may contain directory walks
returns a handle and a length
it may actually be inside a pak file
===========
*/
int COM_OpenFile(const char *filename, int *handle, unsigned int *path_id) {
    return COM_FindFile(filename, handle, NULL, path_id);
}

/*
===========
COM_FOpenFile

If the requested file is inside a packfile, a new FILE * will be opened
into the file.
===========
*/
int COM_FOpenFile(const char *filename, FILE **file, unsigned int *path_id) {
    return COM_FindFile(filename, NULL, file, path_id);
}

/*
============
COM_CloseFile

If it is a pak file handle, don't really close it
============
*/
void COM_CloseFile(const int h) {
    searchpath_t *s;

    for (s = com_searchpaths; s; s = s->next)
        if (s->pack && s->pack->handle == h)
            return;

    Sys_FileClose(h);
}


/*
============
COM_LoadFile

Filename are reletive to the quake directory.
Allways appends a 0 byte.
============
*/
#define	LOADFILE_ZONE		0
#define	LOADFILE_HUNK		1
#define	LOADFILE_TEMPHUNK	2
#define	LOADFILE_CACHE		3
#define	LOADFILE_STACK		4
#define	LOADFILE_MALLOC		5

static byte *loadbuf;
static cache_user_t *loadcache;
static int loadsize;

byte *COM_LoadFile(const char *path, const int usehunk, unsigned int *path_id) {
    int h;
    byte *buf;
    char base[32];
    int len, nread;

    buf = NULL; // quiet compiler warning

    // look for it in the filesystem or pack files
    len = COM_OpenFile(path, &h, path_id);
    if (h == -1)
        return NULL;

    // extract the filename base name for hunk tag
    COM_FileBase(path, base, sizeof(base));

    switch (usehunk) {
        case LOADFILE_HUNK:
            buf = (byte *) Hunk_AllocName(len + 1, base);
            break;
        case LOADFILE_TEMPHUNK:
            buf = (byte *) Hunk_TempAlloc(len + 1);
            break;
        case LOADFILE_ZONE:
            buf = (byte *) Z_Malloc(len + 1);
            break;
        case LOADFILE_CACHE:
            buf = (byte *) Cache_Alloc(loadcache, len + 1, base);
            break;
        case LOADFILE_STACK:
            if (len < loadsize)
                buf = loadbuf;
            else
                buf = (byte *) Hunk_TempAlloc(len + 1);
            break;
        case LOADFILE_MALLOC:
            buf = (byte *) malloc(len + 1);
            break;
        default:
            Sys_Error("COM_LoadFile: bad usehunk");
    }

    if (!buf)
        Sys_Error("COM_LoadFile: not enough space for %s", path);

    ((byte *) buf)[len] = 0;

    nread = Sys_FileRead(h, buf, len);
    COM_CloseFile(h);
    if (nread != len)
        Sys_Error("COM_LoadFile: Error reading %s", path);

    return buf;
}

byte *COM_LoadHunkFile(const char *path, unsigned int *path_id) {
    return COM_LoadFile(path, LOADFILE_HUNK, path_id);
}

byte *COM_LoadZoneFile(const char *path, unsigned int *path_id) {
    return COM_LoadFile(path, LOADFILE_ZONE, path_id);
}

byte *COM_LoadTempFile(const char *path, unsigned int *path_id) {
    return COM_LoadFile(path, LOADFILE_TEMPHUNK, path_id);
}

void COM_LoadCacheFile(const char *path, struct cache_user_s *cu, unsigned int *path_id) {
    loadcache = cu;
    COM_LoadFile(path, LOADFILE_CACHE, path_id);
}

// uses temp hunk if larger than bufsize
byte *COM_LoadStackFile(const char *path, void *buffer, const int bufsize, unsigned int *path_id) {
    byte *buf;

    loadbuf = (byte *) buffer;
    loadsize = bufsize;
    buf = COM_LoadFile(path, LOADFILE_STACK, path_id);

    return buf;
}

// returns malloc'd memory
byte *COM_LoadMallocFile(const char *path, unsigned int *path_id) {
    return COM_LoadFile(path, LOADFILE_MALLOC, path_id);
}

byte *COM_LoadMallocFile_TextMode_OSPath(const char *path, long *len_out) {
    FILE *f;
    byte *data;
    long len, actuallen;

    // ericw -- this is used by Host_Loadgame_f. Translate CRLF to LF on load games,
    // othewise multiline messages have a garbage character at the end of each line.
    // TODO: could handle in a way that allows loading CRLF savegames on mac/linux
    // without the junk characters appearing.
    f = fopen(path, "rt");
    if (f == NULL)
        return NULL;

    len = COM_filelength(f);
    if (len < 0) {
        fclose(f);
        return NULL;
    }

    data = (byte *) malloc(len + 1);
    if (data == NULL) {
        fclose(f);
        return NULL;
    }

    // (actuallen < len) if CRLF to LF translation was performed
    actuallen = fread(data, 1, len, f);
    if (ferror(f)) {
        fclose(f);
        free(data);
        return NULL;
    }
    data[actuallen] = '\0';

    if (len_out != NULL)
        *len_out = actuallen;
    fclose(f);
    return data;
}

const char *COM_ParseIntNewline(const char *buffer, int *value) {
    int consumed = 0;
    sscanf(buffer, "%i\n%n", value, &consumed);
    return buffer + consumed;
}

const char *COM_ParseFloatNewline(const char *buffer, float *value) {
    int consumed = 0;
    sscanf(buffer, "%f\n%n", value, &consumed);
    return buffer + consumed;
}

/*
=================
COM_LoadPackFile -- johnfitz -- modified based on topaz's tutorial

Takes an explicit (not game tree related) path to a pak file.

Loads the header and directory, adding the files at the beginning
of the list so they override previous pack files.
=================
*/
static pack_t *COM_LoadPackFile(const char *packfile) {
    dpackheader_t header;
    int i;
    packfile_t *newfiles;
    int numpackfiles;
    pack_t *pack;
    int packhandle;
    dpackfile_t info[MAX_FILES_IN_PACK];
    unsigned short crc;

    if (Sys_FileOpenRead(packfile, &packhandle) == -1)
        return NULL;

    if (Sys_FileRead(packhandle, &header, sizeof(header)) != (int) sizeof(header) ||
        header.id[0] != 'P' || header.id[1] != 'A' || header.id[2] != 'C' || header.id[3] != 'K')
        Sys_Error("%s is not a packfile", packfile);

    header.dirofs = LittleLong(header.dirofs);
    header.dirlen = LittleLong(header.dirlen);

    numpackfiles = header.dirlen / sizeof(dpackfile_t);

    if (header.dirlen < 0 || header.dirofs < 0) {
        Sys_Error("Invalid packfile %s (dirlen: %i, dirofs: %i)",
                  packfile, header.dirlen, header.dirofs);
    }
    if (!numpackfiles) {
        Sys_Printf("WARNING: %s has no files, ignored\n", packfile);
        Sys_FileClose(packhandle);
        return NULL;
    }
    if (numpackfiles > MAX_FILES_IN_PACK)
        Sys_Error("%s has %i files", packfile, numpackfiles);

    if (numpackfiles != PAK0_COUNT)
        com_modified = true; // not the original file

    newfiles = (packfile_t *) Z_Malloc(numpackfiles * sizeof(packfile_t));

    Sys_FileSeek(packhandle, header.dirofs);
    if (Sys_FileRead(packhandle, info, header.dirlen) != header.dirlen)
        Sys_Error("Error reading %s", packfile);

    // crc the directory to check for modifications
    crc::init(&crc);
    for (i = 0; i < header.dirlen; i++)
        crc::process_byte(&crc, ((byte *) info)[i]);
    if (crc != PAK0_CRC_V106 && crc != PAK0_CRC_V101 && crc != PAK0_CRC_V100)
        com_modified = true;

    // parse the directory
    for (i = 0; i < numpackfiles; i++) {
        q_strlcpy(newfiles[i].name, info[i].name, sizeof(newfiles[i].name));
        newfiles[i].filepos = LittleLong(info[i].filepos);
        newfiles[i].filelen = LittleLong(info[i].filelen);
    }

    pack = (pack_t *) Z_Malloc(sizeof(pack_t));
    q_strlcpy(pack->filename, packfile, sizeof(pack->filename));
    pack->handle = packhandle;
    pack->numfiles = numpackfiles;
    pack->files = newfiles;

    //Sys_Printf ("Added packfile %s (%i files)\n", packfile, numpackfiles);
    return pack;
}

/*
=================
COM_AddGameDirectory -- johnfitz -- modified based on topaz's tutorial
=================
*/
static void COM_AddGameDirectory(const char *base, const char *dir) {
    int i;
    unsigned int path_id;
    searchpath_t *search;
    pack_t *pak, *qspak;
    char pakfile[MAX_OSPATH];
    bool been_here = false;

    q_strlcpy(com_gamedir, va("%s/%s", base, dir), sizeof(com_gamedir));

    // assign a path_id to this game directory
    if (com_searchpaths)
        path_id = com_searchpaths->path_id << 1;
    else path_id = 1U;

_add_path:
    // add the directory to the search path
    search = (searchpath_t *) Z_Malloc(sizeof(searchpath_t));
    search->path_id = path_id;
    q_strlcpy(search->filename, com_gamedir, sizeof(search->filename));
    search->next = com_searchpaths;
    com_searchpaths = search;

    // add any pak files in the format pak0.pak pak1.pak, ...
    for (i = 0; ; i++) {
        q_snprintf(pakfile, sizeof(pakfile), "%s/pak%i.pak", com_gamedir, i);
        pak = COM_LoadPackFile(pakfile);
        if (i != 0 || path_id != 1 || fitzmode)
            qspak = NULL;
        else {
            bool old = com_modified;
            if (been_here) base = host_parms->userdir;
            q_snprintf(pakfile, sizeof(pakfile), "%s/quakespasm.pak", base);
            qspak = COM_LoadPackFile(pakfile);
            com_modified = old;
        }
        if (pak) {
            search = (searchpath_t *) Z_Malloc(sizeof(searchpath_t));
            search->path_id = path_id;
            search->pack = pak;
            search->next = com_searchpaths;
            com_searchpaths = search;
        }
        if (qspak) {
            search = (searchpath_t *) Z_Malloc(sizeof(searchpath_t));
            search->path_id = path_id;
            search->pack = qspak;
            search->next = com_searchpaths;
            com_searchpaths = search;
        }
        if (!pak) break;
    }

    if (!been_here && host_parms->userdir != host_parms->basedir) {
        been_here = true;
        q_strlcpy(com_gamedir, va("%s/%s", host_parms->userdir, dir), sizeof(com_gamedir));
        Sys_mkdir(com_gamedir);
        goto _add_path;
    }
}

//==============================================================================
//johnfitz -- dynamic gamedir stuff -- modified by QuakeSpasm team.
//==============================================================================
static void COM_Game_f(void) {
    if (command::argc() > 1) {
        const char *p = command::argv(1)->c_str();
        const char *p2 = command::argv(2)->c_str();
        searchpath_t *search;

        if (!registered.value) //disable shareware quake
        {
            console::info("You must have the registered version to use modified games\n");
            return;
        }

        if (!*p || !strcmp(p, ".") || strstr(p, "..") || strstr(p, "/") || strstr(p, "\\") || strstr(p, ":")) {
            console::info("gamedir should be a single directory name, not a path\n");
            return;
        }

        if (*p2) {
            if (strcmp(p2, "-hipnotic") && strcmp(p2, "-rogue") && strcmp(p2, "-quoth")) {
                console::info("invalid mission pack argument to \"game\"\n");
                return;
            }
            if (std::is_eq(common::strcasecmp(p, GAMENAME))) {
                console::info("no mission pack arguments to %s game\n", GAMENAME);
                return;
            }
        }

        if (Sys_FileType(va("%s/%s", com_basedir, p)) != FS_ENT_DIRECTORY) {
            if (host_parms->userdir == host_parms->basedir || (
                    Sys_FileType(va("%s/%s", host_parms->userdir, p)) != FS_ENT_DIRECTORY)) {
                console::info("No such game directory \"%s\"\n", p);
                return;
            }
        }

        if (std::is_eq(common::strcasecmp(p, COM_SkipPath(com_gamedir)))) //no change
        {
            if (com_searchpaths->path_id > 1) {
                //current game not id1
                if (*p2 && com_searchpaths->path_id == 2) {
                    // rely on QuakeSpasm extension treating '-game missionpack'
                    // as '-missionpack', otherwise would be a mess
                    if (std::is_eq(common::strcasecmp(p, &p2[1])))
                        goto _same;
                    console::info("reloading game \"%s\" with \"%s\" support\n", p, &p2[1]);
                } else if (!*p2 && com_searchpaths->path_id > 2)
                    console::info("reloading game \"%s\" without mission pack support\n", p);
                else goto _same;
            } else {
            _same:
                console::info("\"game\" is already \"%s\"\n", COM_SkipPath(com_gamedir));
                return;
            }
        }

        com_modified = true;

        //Kill the server
        CL_Disconnect();
        Host_ShutdownServer(true);

        //Write config file
        Host_WriteConfiguration();

        //Kill the extra game if it is loaded
        while (com_searchpaths != com_base_searchpaths) {
            if (com_searchpaths->pack) {
                Sys_FileClose(com_searchpaths->pack->handle);
                Z_Free(com_searchpaths->pack->files);
                Z_Free(com_searchpaths->pack);
            }
            search = com_searchpaths->next;
            Z_Free(com_searchpaths);
            com_searchpaths = search;
        }
        hipnotic = false;
        rogue = false;
        standard_quake = true;

        if (is_neq(common::strcasecmp(p, GAMENAME))) //game is not id1
        {
            if (*p2) {
                COM_AddGameDirectory(com_basedir, &p2[1]);
                standard_quake = false;
                if (!strcmp(p2, "-hipnotic") || !strcmp(p2, "-quoth"))
                    hipnotic = true;
                else if (!strcmp(p2, "-rogue"))
                    rogue = true;
                if (is_neq(common::strcasecmp(p, &p2[1]))) //don't load twice
                    COM_AddGameDirectory(com_basedir, p);
            } else {
                COM_AddGameDirectory(com_basedir, p);
                // QuakeSpasm extension: treat '-game missionpack' as '-missionpack'
                if (std::is_eq(common::strcasecmp(p, "hipnotic")) || std::is_eq(common::strcasecmp(p, "quoth"))) {
                    hipnotic = true;
                    standard_quake = false;
                } else if (std::is_eq(common::strcasecmp(p, "rogue"))) {
                    rogue = true;
                    standard_quake = false;
                }
            }
        } else // just update com_gamedir
        {
            q_snprintf(com_gamedir, sizeof(com_gamedir), "%s/%s",
                       (host_parms->userdir != host_parms->basedir) ? host_parms->userdir : com_basedir,
                       GAMENAME);
        }

        //clear out and reload appropriate data
        Cache_Flush();
        Mod_ResetAll();
        Sky_ClearAll();
        if (!isDedicated) {
            TexMgr_NewGame();
            Draw_NewGame();
            R_NewGame();
        }
        ExtraMaps_NewGame();
        Host_Resetdemos();
        DemoList_Rebuild();

        console::info("\"game\" changed to \"%s\"\n", COM_SkipPath(com_gamedir));

        VID_Lock();
        command::buffer::add_text("exec quake.rc\n");
        command::buffer::add_text("vid_unlock\n");
    } else //Diplay the current gamedir
        console::info("\"game\" is \"%s\"\n", COM_SkipPath(com_gamedir));
}

/*
=================
COM_InitFilesystem
=================
*/
void COM_InitFilesystem(void) //johnfitz -- modified based on topaz's tutorial
{
    registered.inscribe();
    cmdline.inscribe();
    command::add("path", COM_Path_f);
    command::add("game", COM_Game_f); //johnfitz

    auto i = common::check_param("-basedir");
    if (i.has_value() && i.value() < common::_argv.size() - 1) {
        q_strlcpy(com_basedir, common::_argv[i.value() + 1].c_str(), sizeof(com_basedir));
    } else {
        q_strlcpy(com_basedir, host_parms->basedir, sizeof(com_basedir));
    }

    const auto j = strlen(com_basedir);
    if (j < 1) Sys_Error("Bad argument to -basedir");
    if ((com_basedir[j - 1] == '\\') || (com_basedir[j - 1] == '/')) {
        com_basedir[j - 1] = 0;
    }

    // start up with GAMENAME by default (id1)
    COM_AddGameDirectory(com_basedir, GAMENAME);

    /* this is the end of our base searchpath:
     * any set gamedirs, such as those from -game command line
     * arguments or by the 'game' console command will be freed
     * up to here upon a new game command. */
    com_base_searchpaths = com_searchpaths;

    // add mission pack requests (only one should be specified)
    if (common::check_param("-rogue").has_value()) {
        COM_AddGameDirectory(com_basedir, "rogue");
    }
    if (common::check_param("-hipnotic").has_value()) {
        COM_AddGameDirectory(com_basedir, "hipnotic");
    }
    if (common::check_param("-quoth").has_value()) {
        COM_AddGameDirectory(com_basedir, "quoth");
    }

    i = common::check_param("-game");
    if (i.has_value() && i.value() < common::_argv.size() - 1) {
        std::optional<std::string> p = common::_argv[i.value() + 1];
        if (p.value().empty() || p == "." || p->contains("..") || p->contains("/") || p->contains("\\") || p->
            contains(":")) {
            Sys_Error("gamedir should be a single directory name, not a path\n");
        }
        com_modified = true;
        // don't load mission packs twice
        if (common::check_param("-rogue").has_value() && std::is_eq(common::strcasecmp(p->c_str(), "rogue"))) {
            p = std::nullopt;
        }
        if (p.has_value() && common::check_param("-hipnotic").has_value() &&
            std::is_eq(common::strcasecmp(*p, "hipnotic"))) {
            p = std::nullopt;
        }
        if (p.has_value() && common::check_param("-quoth").has_value() && std::is_eq(common::strcasecmp(*p, "quoth"))) {
            p = std::nullopt;
        }
        if (p.has_value()) {
            COM_AddGameDirectory(com_basedir, p->c_str());
            // QuakeSpasm extension: treat '-game missionpack' as '-missionpack'
            if (std::is_eq(common::strcasecmp(*p, "rogue"))) {
                rogue = true;
                standard_quake = false;
            }
            if (std::is_eq(common::strcasecmp(*p, "hipnotic")) || std::is_eq(common::strcasecmp(*p, "quoth"))) {
                hipnotic = true;
                standard_quake = false;
            }
        }
    }

    COM_CheckRegistered();
}


/* The following FS_*() stdio replacements are necessary if one is
 * to perform non-sequential reads on files reopened on pak files
 * because we need the bookkeeping about file start/end positions.
 * Allocating and filling in the fshandle_t structure is the users'
 * responsibility when the file is initially opened. */

size_t FS_fread(void *ptr, const size_t size, const size_t nmemb, fshandle_t *fh) {
    long byte_size;
    long bytes_read;
    size_t nmemb_read;

    if (!fh) {
        errno = EBADF;
        return 0;
    }
    if (!ptr) {
        errno = EFAULT;
        return 0;
    }
    if (!size || !nmemb) {
        /* no error, just zero bytes wanted */
        errno = 0;
        return 0;
    }

    byte_size = nmemb * size;
    if (byte_size > fh->length - fh->pos) /* just read to end */
        byte_size = fh->length - fh->pos;
    bytes_read = fread(ptr, 1, byte_size, fh->file);
    fh->pos += bytes_read;

    /* fread() must return the number of elements read,
     * not the total number of bytes. */
    nmemb_read = bytes_read / size;
    /* even if the last member is only read partially
     * it is counted as a whole in the return value. */
    if (bytes_read % size)
        nmemb_read++;

    return nmemb_read;
}

int FS_fseek(fshandle_t *fh, long offset, const int whence) {
    /* I don't care about 64 bit off_t or fseeko() here.
     * the quake/hexen2 file system is 32 bits, anyway. */
    int ret;

    if (!fh) {
        errno = EBADF;
        return -1;
    }

    /* the relative file position shouldn't be smaller
     * than zero or bigger than the filesize. */
    switch (whence) {
        case SEEK_SET:
            break;
        case SEEK_CUR:
            offset += fh->pos;
            break;
        case SEEK_END:
            offset = fh->length + offset;
            break;
        default:
            errno = EINVAL;
            return -1;
    }

    if (offset < 0) {
        errno = EINVAL;
        return -1;
    }

    if (offset > fh->length) /* just seek to end */
        offset = fh->length;

    ret = fseek(fh->file, fh->start + offset, SEEK_SET);
    if (ret < 0)
        return ret;

    fh->pos = offset;
    return 0;
}

int FS_fclose(fshandle_t *fh) {
    if (!fh) {
        errno = EBADF;
        return -1;
    }
    return fclose(fh->file);
}

long FS_ftell(fshandle_t *fh) {
    if (!fh) {
        errno = EBADF;
        return -1;
    }
    return fh->pos;
}

void FS_rewind(fshandle_t *fh) {
    if (!fh) return;
    clearerr(fh->file);
    fseek(fh->file, fh->start, SEEK_SET);
    fh->pos = 0;
}

int FS_feof(fshandle_t *fh) {
    if (!fh) {
        errno = EBADF;
        return -1;
    }
    if (fh->pos >= fh->length)
        return -1;
    return 0;
}

int FS_ferror(fshandle_t *fh) {
    if (!fh) {
        errno = EBADF;
        return -1;
    }
    return ferror(fh->file);
}

int FS_fgetc(fshandle_t *fh) {
    if (!fh) {
        errno = EBADF;
        return EOF;
    }
    if (fh->pos >= fh->length)
        return EOF;
    fh->pos += 1;
    return fgetc(fh->file);
}

char *FS_fgets(char *s, int size, fshandle_t *fh) {
    char *ret;

    if (FS_feof(fh))
        return NULL;

    if (size > (fh->length - fh->pos) + 1)
        size = (fh->length - fh->pos) + 1;

    ret = fgets(s, size, fh->file);
    fh->pos = ftell(fh->file) - fh->start;

    return ret;
}

long FS_filelength(fshandle_t *fh) {
    if (!fh) {
        errno = EBADF;
        return -1;
    }
    return fh->length;
}

/*
============================================================================
								LOCALIZATION
============================================================================
*/
typedef struct {
    char *key;
    char *value;
} locentry_t;

typedef struct {
    int numentries;
    int maxnumentries;
    int numindices;
    unsigned *indices;
    locentry_t *entries;
    char *text;
} localization_t;

static localization_t localization;

/*
================
COM_HashString
Computes the FNV-1a hash of string str
================
*/
unsigned COM_HashString(const char *str) {
    unsigned hash = 0x811c9dc5u;
    while (*str) {
        hash ^= *str++;
        hash *= 0x01000193u;
    }
    return hash;
}

static size_t mz_zip_file_read_func(void *opaque, const mz_uint64 ofs, void *buf, const size_t n) {
    if (SDL_RWseek((SDL_RWops*)opaque, (Sint64)ofs, RW_SEEK_SET) < 0)
        return 0;
#ifdef USE_SDL2
    return SDL_RWread((SDL_RWops*)opaque, buf, 1, n);
#else
    else {
        int r = SDL_RWread((SDL_RWops*)opaque, buf, 1, n);
        return (r < 0) ? 0 : r;
    }
#endif
}

#ifndef USE_SDL2 /* no SDL_RWsize in SDL-1.2 */
static Sint32 SDLCALL SDL_RWsize(SDL_RWops *rw) {
    Sint32 pos, size;
    if ((pos = SDL_RWtell(rw)) < 0) return -1;
    size = SDL_RWseek(rw, 0, RW_SEEK_END);
    SDL_RWseek(rw, pos, RW_SEEK_SET);
    return size;
}
#endif

/*
================
LOC_LoadFile
================
*/
void LOC_LoadFile(const char *file) {
    char path[1024];
    int i, lineno;
    char *cursor;

    SDL_RWops *rw = NULL;
    Sint64 sz;
    mz_zip_archive archive;
    size_t size = 0;

    // clear existing data
    if (localization.text) {
        free(localization.text);
        localization.text = NULL;
    }
    localization.numentries = 0;
    localization.numindices = 0;

    if (!file || !*file)
        return;

    console::info("\nLanguage initialization\n");

    memset(&archive, 0, sizeof(archive));
    q_snprintf(path, sizeof(path), "%s/%s", com_basedir, file);
    rw = SDL_RWFromFile(path, "rb");
#if defined(DO_USERDIRS)
    if (!rw) {
        q_snprintf(path, sizeof(path), "%s/%s", host_parms->userdir, file);
        rw = SDL_RWFromFile(path, "rb");
    }
#endif
    if (!rw) {
        q_snprintf(path, sizeof(path), "%s/QuakeEX.kpf", com_basedir);
        rw = SDL_RWFromFile(path, "rb");
#if defined(DO_USERDIRS)
        if (!rw) {
            q_snprintf(path, sizeof(path), "%s/QuakeEX.kpf", host_parms->userdir);
            rw = SDL_RWFromFile(path, "rb");
        }
#endif
        if (!rw) goto fail;
        sz = SDL_RWsize(rw);
        if (sz <= 0) goto fail;
        archive.m_pRead = mz_zip_file_read_func;
        archive.m_pIO_opaque = rw;
        if (!mz_zip_reader_init(&archive, sz, 0)) goto fail;
        localization.text = (char *) mz_zip_reader_extract_file_to_heap(&archive, file, &size, 0);
        if (!localization.text) goto fail;
        mz_zip_reader_end(&archive);
        SDL_RWclose(rw);
        localization.text = (char *) realloc(localization.text, size + 1);
        localization.text[size] = 0;
    } else {
        sz = SDL_RWsize(rw);
        if (sz <= 0) goto fail;
        localization.text = (char *) calloc(1, sz + 1);
        if (!localization.text) {
        fail:
            mz_zip_reader_end(&archive);
            if (rw)
                SDL_RWclose(rw);
            console::info("Couldn't load '%s'\nfrom '%s'\n", file, com_basedir);
            return;
        }
        SDL_RWread(rw, localization.text, 1, sz);
        SDL_RWclose(rw);
    }

    cursor = localization.text;

    // skip BOM
    if ((unsigned char) (cursor[0]) == 0xEF && (unsigned char) (cursor[1]) == 0xBB && (unsigned char) (cursor[2]) ==
        0xBF)
        cursor += 3;

    lineno = 0;
    while (*cursor) {
        char *line, *equals;

        lineno++;

        // skip leading whitespace
        while (q_isblank(*cursor))
            ++cursor;

        line = cursor;
        equals = NULL;
        // find line end and first equals sign, if any
        while (*cursor && *cursor != '\n') {
            if (*cursor == '=' && !equals)
                equals = cursor;
            cursor++;
        }

        if (line[0] == '/') {
            if (line[1] != '/')
                console::debug("LOC_LoadFile: malformed comment on line %d\n", lineno);
        } else if (equals) {
            char *key_end = equals;
            bool leading_quote;
            bool trailing_quote;
            locentry_t *entry;
            char *value_src;
            char *value_dst;
            char *value;

            // trim whitespace before equals sign
            while (key_end != line && q_isspace(key_end[-1]))
                key_end--;
            *key_end = 0;

            value = equals + 1;
            // skip whitespace after equals sign
            while (value != cursor && q_isspace(*value))
                value++;

            leading_quote = (*value == '\"');
            trailing_quote = false;
            value += leading_quote;

            // transform escape sequences in-place
            value_src = value;
            value_dst = value;
            while (value_src != cursor) {
                if (*value_src == '\\' && value_src + 1 != cursor) {
                    char c = value_src[1];
                    value_src += 2;
                    switch (c) {
                        case 'n': *value_dst++ = '\n';
                            break;
                        case 't': *value_dst++ = '\t';
                            break;
                        case 'v': *value_dst++ = '\v';
                            break;
                        case 'b': *value_dst++ = '\b';
                            break;
                        case 'f': *value_dst++ = '\f';
                            break;

                        case '"':
                        case '\'':
                            *value_dst++ = c;
                            break;

                        default:
                            console::info("LOC_LoadFile: unrecognized escape sequence \\%c on line %d\n", c, lineno);
                            *value_dst++ = c;
                            break;
                    }
                    continue;
                }

                if (*value_src == '\"') {
                    trailing_quote = true;
                    *value_dst = 0;
                    break;
                }

                *value_dst++ = *value_src++;
            }

            // if not a quoted string, trim trailing whitespace
            if (!trailing_quote) {
                while (value_dst != value && q_isblank(value_dst[-1])) {
                    *value_dst = 0;
                    value_dst--;
                }
            }

            if (localization.numentries == localization.maxnumentries) {
                // grow by 50%
                localization.maxnumentries += localization.maxnumentries >> 1;
                localization.maxnumentries = std::max(localization.maxnumentries, 32);
                localization.entries = (locentry_t *) realloc(localization.entries,
                                                              sizeof(*localization.entries) * localization.
                                                              maxnumentries);
            }

            entry = &localization.entries[localization.numentries++];
            entry->key = line;
            entry->value = value;
        }

        if (*cursor)
            *cursor++ = 0; // terminate line and advance to next
    }

    // hash all entries

    localization.numindices = localization.numentries * 2; // 50% load factor
    if (localization.numindices == 0) {
        console::info("No localized strings in file '%s'\n", file);
        return;
    }

    localization.indices = (unsigned *) realloc(localization.indices,
                                                localization.numindices * sizeof(*localization.indices));
    memset(localization.indices, 0, localization.numindices * sizeof(*localization.indices));

    for (i = 0; i < localization.numentries; i++) {
        locentry_t *entry = &localization.entries[i];
        unsigned pos = COM_HashString(entry->key) % localization.numindices, end = pos;

        for (;;) {
            if (!localization.indices[pos]) {
                localization.indices[pos] = i + 1;
                break;
            }

            ++pos;
            if (pos == localization.numindices)
                pos = 0;

            if (pos == end)
                Sys_Error("LOC_LoadFile failed");
        }
    }

    console::info("Loaded %d strings from '%s'\n", localization.numentries, file);
}

/*
================
LOC_Init
================
*/
void LOC_Init(void) {
    LOC_LoadFile("localization/loc_english.txt");
}

/*
================
LOC_Shutdown
================
*/
void LOC_Shutdown(void) {
    free(localization.indices);
    free(localization.entries);
    free(localization.text);
}

/*
================
LOC_GetRawString

Returns localized string if available, or NULL otherwise
================
*/
const char *LOC_GetRawString(const char *key) {
    unsigned pos, end;

    if (!localization.numindices || !key || !*key || *key != '$')
        return NULL;
    key++;

    pos = COM_HashString(key) % localization.numindices;
    end = pos;

    do {
        unsigned idx = localization.indices[pos];
        locentry_t *entry;
        if (!idx)
            return NULL;

        entry = &localization.entries[idx - 1];
        if (!std::strcmp(entry->key, key))
            return entry->value;

        ++pos;
        if (pos == localization.numindices)
            pos = 0;
    } while (pos != end);

    return NULL;
}

/*
================
LOC_GetString

Returns localized string if available, or input string otherwise
================
*/
const char *LOC_GetString(const char *key) {
    const char *value = LOC_GetRawString(key);
    return value ? value : key;
}

/*
================
LOC_ParseArg

Returns argument index (>= 0) and advances the string if it starts with a placeholder ({} or {N}),
otherwise returns a negative value and leaves the pointer unchanged
================
*/
static int LOC_ParseArg(const char **pstr) {
    int arg;
    const char *str = *pstr;

    // opening brace
    if (*str != '{')
        return -1;
    ++str;

    // optional index, defaulting to 0
    arg = 0;
    while (q_isdigit(*str))
        arg = arg * 10 + *str++ - '0';

    // closing brace
    if (*str != '}')
        return -1;
    *pstr = ++str;

    return arg;
}

/*
================
LOC_HasPlaceholders
================
*/
bool LOC_HasPlaceholders(const char *str) {
    if (!localization.numindices)
        return false;
    while (*str) {
        if (LOC_ParseArg(&str) >= 0)
            return true;
        str++;
    }
    return false;
}

/*
================
LOC_Format

Replaces placeholders (of the form {} or {N}) with the corresponding arguments

Returns number of written chars, excluding the NUL terminator
If len > 0, output is always NUL-terminated
================
*/
size_t LOC_Format(const char *format, const char * (*getarg_fn)(int idx, void *userdata), void *userdata, char *out,
                  size_t len) {
    size_t written = 0;
    int numargs = 0;

    if (!len) {
        console::debug("LOC_Format: no output space\n");
        return 0;
    }
    --len; // reserve space for the terminator

    while (*format && written < len) {
        const char *insert;
        size_t space_left;
        size_t insert_len;
        int argindex = LOC_ParseArg(&format);

        if (argindex < 0) {
            out[written++] = *format++;
            continue;
        }

        insert = getarg_fn(argindex, userdata);
        space_left = len - written;
        insert_len = std::strlen(insert);

        if (insert_len > space_left) {
            console::debug("LOC_Format: overflow at argument #%d\n", numargs);
            insert_len = space_left;
        }

        std::memcpy(out + written, insert, insert_len);
        written += insert_len;
    }

    if (*format)
        console::debug("LOC_Format: overflow\n");

    out[written] = 0;

    return written;
}
