import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import isLocalhost from 'is-localhost-ip';
import { txHostConfig } from '@core/globalData';
import consoleFactory from '@lib/console';
const console = consoleFactory();


/**
 * Detect the dominant newline character of a string.
 * Extracted from https://www.npmjs.com/package/detect-newline
 */
const detectNewline = (str: string) => {
    if (typeof str !== 'string') {
        throw new TypeError('Expected a string');
    }

    const newlines = str.match(/(?:\r?\n)/g) || [];

    if (newlines.length === 0) {
        return;
    }

    const crlf = newlines.filter((newline) => newline === '\r\n').length;
    const lf = newlines.length - crlf;

    return crlf > lf ? '\r\n' : '\n';
};


/**
 * Helper function to store commands
 */
class Command {
    readonly command: string;
    readonly args: string[];
    readonly file: string;
    readonly line: number;

    constructor(tokens: string[], filePath: string, fileLine: number) {
        if (!Array.isArray(tokens) || tokens.length < 1) {
            throw new Error('Invalid command format');
        }
        if (typeof tokens[0] === 'string' && tokens[0].length) {
            this.command = tokens[0].toLocaleLowerCase();
        } else {
            this.command = 'invalid_empty_command';
        }
        this.args = tokens.slice(1);
        this.file = filePath;
        this.line = fileLine;
    }

    //Kinda confusing name, but it returns the value of a set if it's for that ne var
    isConvarSetterFor(varname: string) {
        if (
            ['set', 'sets', 'setr'].includes(this.command)
            && this.args.length === 2
            && this.args[0].toLowerCase() === varname.toLowerCase()
        ) {
            return this.args[1];
        }

        if (
            this.command === varname.toLowerCase()
            && this.args.length === 1
        ) {
            return this.args[0];
        }

        return false;
    }
}


/**
 * Helper function to store exec errors
 */
class ExecRecursionError {
    constructor(readonly file: string, readonly message: string, readonly line: number) { }
}

/**
 * Helper class to store file TODOs, errors and warnings
 */
class FilesInfoList {
    readonly store: Record<string, [number | false, string][]> = {};

    add(file: string, line: number | false, msg: string) {
        if (Array.isArray(this.store[file])) {
            this.store[file].push([line, msg]);
        } else {
            this.store[file] = [[line, msg]];
        }
    }
    count() {
        return Object.keys(this.store).length;
    }
    toJSON() {
        return this.store;
    }
    toMarkdown(hasHostConfig = false) {
        const files = Object.keys(this.store);
        if (!files) return null;

        const msgLines = [];
        for (const file of files) {
            const fileInfos = this.store[file];
            msgLines.push(`\`${file}\`:`);
            for (const [line, msg] of fileInfos) {
                const linePrefix = line ? `Line ${line}: ` : '';
                const indentedMsg = msg.replaceAll(/\n\t/gm, '\n\t- ');
                msgLines.push(`- ${linePrefix}${indentedMsg}`);
            }
        }
        if (hasHostConfig) {
            msgLines.push(''); //blank line so the warning doesn't join the list
            msgLines.push(`**Some of the configuration above is controlled by ${txHostConfig.sourceName}.**`);
        }
        return msgLines.join('\n');
    }
}


/**
 * Returns the first likely server.cfg given a server data path, or false
 */
export const findLikelyCFGPath = (serverDataPath: string) => {
    const commonCfgFileNames = [
        'server.cfg',
        'server.cfg.txt',
        'server.cfg.cfg',
        'server.txt',
        'server',
    ];

    for (const cfgFileName of commonCfgFileNames) {
        const absoluteCfgPath = path.join(serverDataPath, cfgFileName);
        try {
            if (fs.lstatSync(absoluteCfgPath).isFile()) {
                return cfgFileName;
            }
        } catch (error) { }
    }
    return false;
}


/**
 * Returns the absolute path of the given CFG Path
 */
export const resolveCFGFilePath = (cfgPath: string, dataPath: string) => {
    return (path.isAbsolute(cfgPath)) ? path.normalize(cfgPath) : path.resolve(dataPath, cfgPath);
};


/**
 * Reads CFG Path and return the file contents, or throw error if:
 *  - the path is not valid (must be absolute)
 *  - cannot read the file data
 */
export const readRawCFGFile = async (cfgPath: string) => {
    //Validating if the path is absolute
    if (!path.isAbsolute(cfgPath)) {
        throw new Error('File path must be absolute.');
    }

    //Validating file existence
    if (!fs.existsSync(cfgPath)) {
        throw new Error("File doesn't exist or its unreadable.");
    }

    //Validating if its actually a file
    if (!fs.lstatSync(cfgPath).isFile()) {
        throw new Error("File doesn't exist or its unreadable. Make sure to include the CFG file in the path, and not just the directory that contains it.");
    }

    //Reading file
    try {
        return await fsp.readFile(cfgPath, 'utf8');
    } catch (error) {
        throw new Error('Cannot read CFG file.');
    }
};


/**
 * Parse a cfg/console line and return an array of commands with tokens.
 * Notable difference: we don't handle inline block comment
 * Original Line Parser:
 *  fivem/code/client/citicore/console/Console.cpp > ProgramArguments Tokenize
 */
export const readLineCommands = (input: string) => {
    let inQuote = false;
    let inEscape = false;
    const prevCommands = [];
    let currCommand = [];
    let currToken = '';
    for (let i = 0; i < input.length; i++) {
        if (inEscape) {
            if (input[i] === '"' || input[i] === '\\') {
                currToken += input[i];
            }
            inEscape = false;
            continue;
        }

        if (!currToken.length) {
            if (
                input.slice(i, i + 2) === '//'
                || input[i] === '#'
            ) {
                break;
            }
        }

        if (!inQuote && input.charCodeAt(i) <= 32) {
            if (currToken.length) {
                currCommand.push(currToken);
                currToken = '';
            }
            continue;
        }

        if (input[i] === '"') {
            if (inQuote) {
                currCommand.push(currToken);
                currToken = '';
                inQuote = false;
            } else {
                inQuote = true;
            }
            continue;
        }

        if (input[i] === '\\') {
            inEscape = true;
            continue;
        }

        if (!inQuote && input[i] === ';') {
            if (currToken.length) {
                currCommand.push(currToken);
                currToken = '';
            }
            prevCommands.push(currCommand);
            currCommand = [];
            continue;
        };

        currToken += input[i];
    }
    if (currToken.length) {
        currCommand.push(currToken);
    }
    prevCommands.push(currCommand);

    return prevCommands;
};
//NOTE: tests for the parser above
// import chalk from 'chalk';
// const testCommands = [
//     ' \x1B ONE_ARG_WITH_SPACE "part1 part2"',
//     'TWO_ARGS arg1 arg2',
//     'ONE_ARG_WITH_SPACE_SEMICOLON "arg mid;cut"',
//     'ESCAPED_QUOTE "aa\\"bb"',
//     'ESCAPED_ESCAPE "aa\\\\bb"',
//     'ESCAPED_X "aa\\xbb"',
//     // 'NO_CLOSING_QUOTE "aa',
//     // 'SHOW_AB_C aaa#bbb ccc',
//     // 'COMMENT //anything noshow',
//     'COMMENT #anything noshow',
//     'noshow2',
// ];
// const parsed = readLineCommands(testCommands.join(';'));
// for (const commandTokens of parsed) {
//     console.log(`${commandTokens[0]}:`);
//     commandTokens.slice(1).forEach((token) => {
//         console.log(chalk.inverse(token));
//     });
//     console.log('\n');
// }


/**
 * Recursively parse server.cfg files losely based on the FXServer original parser.
 * Notable differences: we have recursivity depth limit, and no json parsing
 * Original CFG (console) parser:
 *  fivem/code/client/citicore/console/Console.cpp > Context::ExecuteBuffer
 *
 * FIXME: support `@resource/whatever.cfg` syntax
 */
export const parseRecursiveConfig = async (
    cfgInputString: string | null, //cfg string, or null to load from file
    cfgAbsolutePath: string,
    serverDataPath: string,
    stack?: string[]
) => {
    if (typeof cfgInputString !== 'string' && cfgInputString !== null) {
        throw new Error('cfgInputString expected to be string or null');
    }

    // Ensure safe stack
    const MAX_DEPTH = 5;
    if (!Array.isArray(stack)) {
        stack = [];
    } else if (stack.length >= MAX_DEPTH) {
        throw new Error(`cfg 'exec' command depth above ${MAX_DEPTH}`);
    } else if (stack.includes(cfgAbsolutePath)) {
        throw new Error(`cfg cyclical 'exec' command detected to file ${cfgAbsolutePath}`); //should block
    }
    stack.push(cfgAbsolutePath);

    // Read raw config and split lines
    const cfgData = cfgInputString ?? await readRawCFGFile(cfgAbsolutePath);
    const cfgLines = cfgData.split('\n');

    // Parse CFG lines
    const parsedCommands: (Command | ExecRecursionError)[] = [];
    for (let i = 0; i < cfgLines.length; i++) {
        const lineString = cfgLines[i].trim();
        const lineNumber = i + 1;
        const lineCommands = readLineCommands(lineString);

        // For each command in that line
        for (const cmdTokens of lineCommands) {
            if (!cmdTokens.length) continue;
            const cmdObject = new Command(cmdTokens, cfgAbsolutePath, lineNumber);
            parsedCommands.push(cmdObject);

            // If exec command, process recursively then flatten the output
            if (cmdObject.command === 'exec' && typeof cmdObject.args[0] === 'string') {
                //FIXME: temporarily disable resoure references
                if (!cmdObject.args[0].startsWith('@')) {
                    const recursiveCfgAbsolutePath = resolveCFGFilePath(cmdObject.args[0], serverDataPath);
                    try {
                        const extractedCommands = await parseRecursiveConfig(null, recursiveCfgAbsolutePath, serverDataPath, stack);
                        parsedCommands.push(...extractedCommands);
                    } catch (error) {
                        parsedCommands.push(new ExecRecursionError(cfgAbsolutePath, (error as Error).message, lineNumber));
                    }
                }
            }
        }
    }

    stack.pop();
    return parsedCommands;
};

type EndpointsObjectType = Record<string, { tcp?: true; udp?: true; }>

/**
 * Validates a list of parsed commands to return endpoints, errors, warnings and lines to comment out
 */
const validateCommands = async (parsedCommands: (ExecRecursionError | Command)[]) => {
    const checkedInterfaces = new Map();
    let detectedGameName: string | undefined;
    const requiredGameName = txHostConfig.forceGameName
        ? txHostConfig.forceGameName === 'fivem' ? 'gta5' : 'rdr3'
        : undefined;

    //To return
    let hasHostConfigMessage = false;
    let hasEndpointCommand = false;
    const endpoints: EndpointsObjectType = {};
    const errors = new FilesInfoList();
    const warnings = new FilesInfoList();
    const toCommentOut = new FilesInfoList();

    for (const cmd of parsedCommands) {
        //In case of error
        if (cmd instanceof ExecRecursionError) {
            warnings.add(cmd.file, cmd.line, cmd.message);
            continue;
        }

        //Check for +set
        if (['+set', '+setr', '+setr'].includes(cmd.command)) {
            const msg = `Line ${cmd.line}: remove the '+' from '${cmd.command}', as this is not an launch parameter.`;
            warnings.add(cmd.file, cmd.line, msg);
            continue;
        }

        //Check for start/stop/ensure txAdmin/txAdminClient/monitor
        if (
            ['start', 'stop', 'ensure'].includes(cmd.command)
            && cmd.args.length >= 1
            && ['txadmin', 'txadminclient', 'monitor'].includes(cmd.args[0].toLowerCase())
        ) {
            toCommentOut.add(
                cmd.file,
                cmd.line,
                'you MUST NOT start/stop/ensure txadmin resources.',
            );
            continue;
        }

        //Check sv_maxClients against TXHOST config
        const isMaxClientsString = cmd.isConvarSetterFor('sv_maxclients');
        if (
            txHostConfig.forceMaxClients
            && isMaxClientsString
        ) {
            const maxClients = parseInt(isMaxClientsString);
            if (maxClients > txHostConfig.forceMaxClients) {
                hasHostConfigMessage = true;
                errors.add(
                    cmd.file,
                    cmd.line,
                    `your 'sv_maxclients' MUST be <= ${txHostConfig.forceMaxClients}.`
                );
                continue;
            }
        }

        //Check gamename against TXHOST config
        const isGameNameString = cmd.isConvarSetterFor('gamename');
        if (isGameNameString && detectedGameName) {
            errors.add(
                cmd.file,
                cmd.line,
                `you already set the 'gamename' to '${detectedGameName}', please remove this line.`
            );
            continue;
        }
        if (
            txHostConfig.forceGameName
            && isGameNameString
        ) {
            detectedGameName = isGameNameString;
            if (isGameNameString !== requiredGameName) {
                hasHostConfigMessage = true;
                errors.add(
                    cmd.file,
                    cmd.line,
                    `your 'gamename' MUST be '${requiredGameName}'.`
                );
                continue;
            }
        }

        //Comment out any onesync sets
        if (cmd.isConvarSetterFor('onesync')) {
            toCommentOut.add(
                cmd.file,
                cmd.line,
                'onesync MUST only be set in the txAdmin settings page.',
            );
            continue;
        }

        //FIXME: add isConvarSetterFor for all "Settings page only" convars

        //Extract & process endpoint validity
        if (cmd.command === 'endpoint_add_tcp' || cmd.command === 'endpoint_add_udp') {
            hasEndpointCommand = true;

            //Validating args length
            if (cmd.args.length !== 1) {
                warnings.add(
                    cmd.file,
                    cmd.line,
                    `the \`endpoint_add_*\` commands MUST have exactly 1 argument (received ${cmd.args.length})`
                );
                continue;
            }

            //Extracting parts & validating format
            const endpointsRegex = /^\[?(([0-9.]{7,15})|([a-z0-9:]{2,29}))\]?:(\d{1,5})$/gi;
            const matches = [...cmd.args[0].matchAll(endpointsRegex)];
            if (!Array.isArray(matches) || !matches.length) {
                errors.add(
                    cmd.file,
                    cmd.line,
                    `the \`${cmd.args[0]}\` is not in a valid \`ip:port\` format.`
                );
                continue;
            }
            const [_matchedString, iface, ipv4, ipv6, portString] = matches[0];

            //Checking if that interface is available to binding
            let canBind = checkedInterfaces.get(iface);
            if (typeof canBind === 'undefined') {
                canBind = await isLocalhost(iface, true);
                checkedInterfaces.set(iface, canBind);
            }
            if (canBind === false) {
                errors.add(
                    cmd.file,
                    cmd.line,
                    `the \`${cmd.command}\` interface \`${iface}\` is not available for this host.`
                );
                continue;
            }
            if (txHostConfig.netInterface && iface !== txHostConfig.netInterface) {
                hasHostConfigMessage = true;
                errors.add(
                    cmd.file,
                    cmd.line,
                    `the \`${cmd.command}\` interface MUST be \`${txHostConfig.netInterface}\`.`
                );
                continue;
            }

            //Validating port
            const port = parseInt(portString);
            if (port >= 40120 && port <= 40150) {
                errors.add(
                    cmd.file,
                    cmd.line,
                    `the \`${cmd.command}\` port \`${port}\` is dedicated for txAdmin and CAN NOT be used for FXServer.`
                );
                continue;
            }
            if (port === txHostConfig.txaPort) {
                errors.add(
                    cmd.file,
                    cmd.line,
                    `the \`${cmd.command}\` port \`${port}\` is being used by txAdmin and CAN NOT be used for FXServer at the same time.`
                );
                continue;
            }
            if (txHostConfig.fxsPort && port !== txHostConfig.fxsPort) {
                hasHostConfigMessage = true;
                errors.add(
                    cmd.file,
                    cmd.line,
                    `the \`${cmd.command}\` port MUST be \`${txHostConfig.fxsPort}\`.`
                );
                continue;
            }

            //Add to the endpoint list and check duplicity
            const endpoint = (ipv4) ? `${ipv4}:${port}` : `[${ipv6}]:${port}`;
            const protocol = (cmd.command === 'endpoint_add_tcp') ? 'tcp' : 'udp';
            if (typeof endpoints[endpoint] === 'undefined') {
                endpoints[endpoint] = {};
            }
            if (endpoints[endpoint][protocol]) {
                errors.add(
                    cmd.file,
                    cmd.line,
                    `you CANNOT execute \`${cmd.command}\` twice for the interface \`${endpoint}\`.`
                );
                continue;
            } else {
                endpoints[endpoint][protocol] = true;
            }
        }
    }

    //Since gta5 is the default, we need to check TXHOST for redm
    if (txHostConfig.forceGameName === 'redm' && detectedGameName !== 'rdr3') {
        const initFile = parsedCommands[0]?.file ?? 'unknown';
        hasHostConfigMessage = true;
        errors.add(
            initFile,
            false,
            `your config MUST have a 'gamename' set to '${requiredGameName}'.`
        );
    }

    return {
        endpoints,
        hasEndpointCommand,
        hasHostConfigMessage,
        errors,
        warnings,
        toCommentOut,
    };
};


/**
 * Process endpoints object, checks validity, and then returns a connection string
 */
const getConnectEndpoint = (endpoints: EndpointsObjectType, hasEndpointCommand: boolean) => {
    if (!Object.keys(endpoints).length) {
        const instruction = hasEndpointCommand
            ? 'Please delete all \`endpoint_add_*\` lines and'
            : 'Please'
        const suggestedPort = txHostConfig.fxsPort ?? 30120;
        const suggestedInterface = txHostConfig.netInterface ?? '0.0.0.0';
        const desidredEndpoint = `${suggestedInterface}:${suggestedPort}`;
        const msg = [
            `Your config file does not specify a valid endpoints for FXServer to use. ${instruction} add the following to the start of the file:`,
            `\t\`endpoint_add_tcp "${desidredEndpoint}"\``,
            `\t\`endpoint_add_udp "${desidredEndpoint}"\``,
        ].join('\n');
        throw new Error(msg);
    }
    const tcpudpEndpoint = Object.keys(endpoints).find((ep) => {
        return endpoints[ep].tcp && endpoints[ep].udp;
    });
    if (!tcpudpEndpoint) {
        throw new Error('Your config file does not not contain a ip:port used in both `endpoint_add_tcp` and `endpoint_add_udp` commands. Players would not be able to connect.');
    }

    return tcpudpEndpoint.replace(/(0\.0\.0\.0|\[::\])/, '127.0.0.1');
};


/**
 * Validates & ensures correctness in FXServer config file recursively.
 * Used when trying to start server, or validate the server.cfg.
 * Returns errors, warnings and connectEndpoint
 */
export const validateFixServerConfig = async (cfgPath: string, serverDataPath: string) => {
    //Parsing FXServer config & going through each command
    const cfgAbsolutePath = resolveCFGFilePath(cfgPath, serverDataPath);
    const parsedCommands = await parseRecursiveConfig(null, cfgAbsolutePath, serverDataPath);
    const {
        endpoints,
        hasEndpointCommand,
        hasHostConfigMessage,
        errors,
        warnings,
        toCommentOut
    } = await validateCommands(parsedCommands);

    //Validating if a valid endpoint was detected
    let connectEndpoint: string | null = null;
    try {
        connectEndpoint = getConnectEndpoint(endpoints, hasEndpointCommand);
    } catch (error) {
        errors.add(cfgAbsolutePath, false, (error as Error).message);
    }

    //Commenting out lines or registering them as warnings
    for (const targetCfgPath in toCommentOut.store) {
        const actions = toCommentOut.store[targetCfgPath];
        try {
            const cfgRaw = await fsp.readFile(targetCfgPath, 'utf8');

            //modify the cfg lines
            const fileEOL = detectNewline(cfgRaw);
            const cfgLines = cfgRaw.split(/\r?\n/);
            for (const [ln, reason] of actions) {
                if (ln === false) continue;
                if (typeof cfgLines[ln - 1] !== 'string') {
                    throw new Error(`Line ${ln} not found.`);
                }
                cfgLines[ln - 1] = `## [txAdmin CFG validator]: ${reason}${fileEOL}# ${cfgLines[ln - 1]}`;
                warnings.add(targetCfgPath, ln, `Commented out: ${reason}`);
            }

            //Saving modified lines
            const newCfg = cfgLines.join(fileEOL);
            console.warn(`Saving modified file '${targetCfgPath}'`);
            await fsp.writeFile(targetCfgPath, newCfg, 'utf8');
        } catch (error) {
            console.verbose.error(error);
            for (const [ln, reason] of actions) {
                errors.add(targetCfgPath, ln, `Please comment out this line: ${reason}`);
            }
        }
    }

    //Prepare response
    return {
        connectEndpoint,
        errors: errors.toMarkdown(hasHostConfigMessage),
        warnings: warnings.toMarkdown(hasHostConfigMessage),
        // errors: errors.store,
        // warnings: warnings.store,
        // endpoints, //Not being used
    };
};


/**
 * Validating config contents + saving file and backup.
 * In case of any errors, it does not save the contents.
 * Does not comment out (fix) bad lines.
 * Used whenever a user wants to modify server.cfg.
 * Returns if saved, and warnings
 */
export const validateModifyServerConfig = async (
    cfgInputString: string,
    cfgPath: string,
    serverDataPath: string
) => {
    if (typeof cfgInputString !== 'string') {
        throw new Error('cfgInputString expected to be string.');
    }

    //Parsing FXServer config & going through each command
    const cfgAbsolutePath = resolveCFGFilePath(cfgPath, serverDataPath);
    const parsedCommands = await parseRecursiveConfig(cfgInputString, cfgAbsolutePath, serverDataPath);
    const {
        endpoints,
        hasEndpointCommand,
        hasHostConfigMessage,
        errors,
        warnings,
        toCommentOut
    }  = await validateCommands(parsedCommands);

    //Validating if a valid endpoint was detected
    try {
        const _connectEndpoint = getConnectEndpoint(endpoints, hasEndpointCommand);
    } catch (error) {
        errors.add(cfgAbsolutePath, false, (error as Error).message);
    }

    //If there are any errors
    if (errors.count()) {
        return {
            success: false,
            errors: errors.toMarkdown(hasHostConfigMessage),
            warnings: warnings.toMarkdown(hasHostConfigMessage),
        };
    }

    //Save file + backup
    try {
        console.warn(`Saving modified file '${cfgAbsolutePath}'`);
        await fsp.copyFile(cfgAbsolutePath, `${cfgAbsolutePath}.bkp`);
        await fsp.writeFile(cfgAbsolutePath, cfgInputString, 'utf8');
    } catch (error) {
        throw new Error(`Failed to edit 'server.cfg' with error: ${(error as Error).message}`);
    }

    return {
        success: true,
        warnings: warnings.toMarkdown(),
    };
};
/*
    fxrunner spawnServer:       recursive validate file, get endpoint
    settings handleFXServer:    recursive validate file
    setup handleValidateCFGFile:    recursive validate file
    setup handleSaveLocal:      recursive validate file

    cfgEditor CFGEditorSave:    validate string, save
    deployer handleSaveConfig:  validate string, save *
*/

/*

# Endpoints test cases:
/"\[?(([0-9.]{7,15})|([a-z0-9:]{2,29}))\]?:(\d{1,5})"/gmi

# default
"0.0.0.0:30120"
"[0.0.0.0]:30120"
"0.0.0.0"
"[0.0.0.0]"

# ipv6/ipv4
"[::]:30120"
":::30120"
"[::]"

# ipv6 only
"fe80::4cec:1264:187e:ce2b:30120"
"[fe80::4cec:1264:187e:ce2b]:30120"
"::1:30120"
"[::1]:30120"
"[fe80::4cec:1264:187e:ce2b]"
"::1"
"[::1]"

# FXServer doesn't accept
"::1.30120"
"::1 port 30120"
"::1p30120"
"::1#30120"
"::"

# FXServer misreads last part as a port
"fe80::4cec:1264:187e:ce2b"

*/
