const modulename = 'AdminStore';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import { cloneDeep } from 'lodash-es';
import { nanoid } from 'nanoid';
import { txHostConfig } from '@core/globalData';
import CfxProvider from './providers/CitizenFX.js';
import { createHash } from 'node:crypto';
import consoleFactory from '@lib/console.js';
import fatalError from '@lib/fatalError.js';
import { chalkInversePad } from '@lib/misc.js';
const console = consoleFactory(modulename);

//NOTE: The way I'm doing versioning right now is horrible but for now it's the best I can do
//NOTE: I do not need to version every admin, just the file itself
const ADMIN_SCHEMA_VERSION = 1;


//Helpers
const migrateProviderIdentifiers = (providerName, providerData) => {
    if (providerName === 'citizenfx') {
        // data may be empty, or nameid may be invalid
        try {
            const res = /\/user\/(\d{1,8})/.exec(providerData.data.nameid);
            providerData.identifier = `fivem:${res[1]}`;
        } catch (error) {
            providerData.identifier = 'fivem:00000000';
        }
    } else if (providerName === 'discord') {
        providerData.identifier = `discord:${providerData.id}`;
    }
};


/**
 * Module responsible for storing, retrieving and validating admins data.
 */
export default class AdminStore {
    constructor() {
        this.adminsFile = txHostConfig.dataSubPath('admins.json');
        this.adminsFileHash = null;
        this.admins = null;
        this.refreshRoutine = null;

        //Not alphabetical order, but that's fine
        //FIXME: move to a separate file
        //TODO: maybe put in @shared so the frontend's UnauthorizedPage can use it
        //TODO: when migrating the admins page to react, definitely put this in @shared so the front rendering doesn't depend on the backend response - lessons learned from the settings page.
        //FIXME: if not using enums, definitely use so other type of type safety
        //FIXME: maybe rename all_permissions to `administrator` (just like discord) or `super_admin` and rename the `Admins` page to `Users`. This fits better with how people use txAdmin as "mods" are not really admins
        this.registeredPermissions = {
            'all_permissions': 'All Permissions',
            'manage.admins': 'Manage Admins', //will enable the "set admin" button in the player modal
            'settings.view': 'Settings: View (no tokens)',
            'settings.write': 'Settings: Change',
            'console.view': 'Console: View',
            'console.write': 'Console: Write',
            'control.server': 'Start/Stop Server + Scheduler', //FIXME: horrible name
            'announcement': 'Send Announcements',
            'commands.resources': 'Start/Stop Resources',
            'server.cfg.editor': 'Read/Write server.cfg', //FIXME: rename to server.cfg_editor
            'txadmin.log.view': 'View System Logs', //FIXME: rename to system.log.view
            'server.log.view': 'View Server Logs',

            'menu.vehicle': 'Spawn / Fix Vehicles',
            'menu.clear_area': 'Reset world area',
            'menu.viewids': 'View Player IDs in-game', //be able to see the ID of the players
            'players.direct_message': 'Direct Message',
            'players.whitelist': 'Whitelist',
            'players.warn': 'Warn',
            'players.kick': 'Kick',
            'players.ban': 'Ban',
            'players.freeze': 'Freeze Players',
            'players.heal': 'Heal', //self, everyone, and the "heal" button in player modal
            'players.playermode': 'NoClip / God Mode', //self playermode, and also the player spectate option
            'players.spectate': 'Spectate', //self playermode, and also the player spectate option
            'players.teleport': 'Teleport', //self teleport, and the bring/go to on player modal
            'players.troll': 'Troll Actions', //all the troll options in the player modal
        };
        //FIXME: pode remover, hardcode na cron function
        this.hardConfigs = {
            refreshInterval: 15e3,
        };


        //Load providers
        //FIXME: pode virar um top-level singleton , não precisa estar na classe
        try {
            this.providers = {
                discord: false,
                citizenfx: new CfxProvider(),
            };
        } catch (error) {
            throw new Error(`Failed to load providers with error: ${error.message}`);
        }

        //Check if admins file exists
        let adminFileExists;
        try {
            fs.statSync(this.adminsFile, fs.constants.F_OK);
            adminFileExists = true;
        } catch (error) {
            if (error.code === 'ENOENT') {
                adminFileExists = false;
            } else {
                throw new Error(`Failed to check presence of admin file with error: ${error.message}`);
            }
        }

        //Printing PIN or starting loop
        if (!adminFileExists) {
            if (!txHostConfig.defaults.account) {
                this.addMasterPin = (Math.random() * 10000).toFixed().padStart(4, '0');
                this.admins = false;
            } else {
                const { username, fivemId, password } = txHostConfig.defaults.account;
                this.createAdminsFile(
                    username,
                    fivemId ? `fivem:${fivemId}` : undefined,
                    undefined,
                    password,
                    password ? false : undefined,
                );
                console.ok(`Created master account ${chalkInversePad(username)} with credentials provided by ${txHostConfig.sourceName}.`);
            }
        } else {
            this.loadAdminsFile();
            this.setupRefreshRoutine();
        }
    }


    /**
     * sets the admins file refresh routine
     */
    setupRefreshRoutine() {
        this.refreshRoutine = setInterval(() => {
            this.checkAdminsFile();
        }, this.hardConfigs.refreshInterval);
    }


    /**
     * Creates a admins.json file based on the first account
     * @param {string} username
     * @param {string|undefined} fivemId with the fivem: prefix
     * @param {string|undefined} discordId with the discord: prefix
     * @param {string|undefined} password backup password
     * @param {boolean|undefined} isPlainTextPassword
     * @returns {(boolean)} true or throws an error
     */
    createAdminsFile(username, fivemId, discordId, password, isPlainTextPassword) {
        //Sanity check
        if (this.admins !== false && this.admins !== null) throw new Error('Admins file already exists.');
        if (typeof username !== 'string' || username.length < 3) throw new Error('Invalid username parameter.');

        //Handling password
        let password_hash, password_temporary;
        if(password){
            password_hash = isPlainTextPassword ? GetPasswordHash(password) : password;
            // password_temporary = false; //undefined will do the same
        } else {
            const veryRandomString = `${username}-password-not-meant-to-be-used-${nanoid()}`;
            password_hash = GetPasswordHash(veryRandomString);
            password_temporary = true;
        }

        //Handling third party providers
        const providers = {};
        if (fivemId) {
            providers.citizenfx = {
                id: username,
                identifier: fivemId,
                data: {},
            };
        }
        if (discordId) {
            providers.discord = {
                id: discordId,
                identifier: `discord:${discordId}`,
                data: {},
            };
        }

        //Creating new admin
        const newAdmin = {
            $schema: ADMIN_SCHEMA_VERSION,
            name: username,
            master: true,
            password_hash,
            password_temporary,
            providers,
            permissions: [],
        };
        this.admins = [newAdmin];
        this.addMasterPin = undefined;

        //Saving admin file
        try {
            const jsonData = JSON.stringify(this.admins);
            this.adminsFileHash = createHash('sha1').update(jsonData).digest('hex');
            fs.writeFileSync(this.adminsFile, jsonData, { encoding: 'utf8', flag: 'wx' });
            this.setupRefreshRoutine();
            return newAdmin;
        } catch (error) {
            let message = `Failed to create '${this.adminsFile}' with error: ${error.message}`;
            console.verbose.error(message);
            throw new Error(message);
        }
    }


    /**
     * Returns a list of admins and permissions
     */
    getAdminsList() {
        if (this.admins == false) return [];
        return this.admins.map((user) => {
            return {
                name: user.name,
                master: user.master,
                providers: Object.keys(user.providers),
                permissions: user.permissions,
            };
        });
    }


    /**
     * Returns the raw array of admins, except for the hash
     */
    getRawAdminsList() {
        if (this.admins === false) return [];
        return cloneDeep(this.admins);
    }


    /**
     * Returns all data from an admin by provider user id (ex discord id), or false
     * @param {string} uid
     */
    getAdminByProviderUID(uid) {
        if (this.admins == false) return false;
        let id = uid.trim().toLowerCase();
        if (!id.length) return false;
        let admin = this.admins.find((user) => {
            return Object.keys(user.providers).find((provider) => {
                return (id === user.providers[provider].id.toLowerCase());
            });
        });
        return (admin) ? cloneDeep(admin) : false;
    }


    /**
     * Returns an array with all identifiers of the admins (fivem/discord)
     */
    getAdminsIdentifiers() {
        if (this.admins === false) return [];
        const ids = [];
        for (const admin of this.admins) {
            admin.providers.citizenfx && ids.push(admin.providers.citizenfx.identifier);
            admin.providers.discord && ids.push(admin.providers.discord.identifier);
        }
        return ids;
    }


    /**
     * Returns all data from an admin by their name, or false
     * @param {string} uname
     */
    getAdminByName(uname) {
        if (!this.admins) return false;
        const username = uname.trim().toLowerCase();
        if (!username.length) return false;
        const admin = this.admins.find((user) => {
            return (username === user.name.toLowerCase());
        });
        return (admin) ? cloneDeep(admin) : false;
    }


    /**
     * Returns all data from an admin by game identifier, or false
     * @param {string[]} identifiers
     */
    getAdminByIdentifiers(identifiers) {
        if (!this.admins) return false;
        identifiers = identifiers
            .map((i) => i.trim().toLowerCase())
            .filter((i) => i.length);
        if (!identifiers.length) return false;
        const admin = this.admins.find((user) =>
            identifiers.find((identifier) =>
                Object.keys(user.providers).find((provider) =>
                    (identifier === user.providers[provider].identifier.toLowerCase()))));
        return (admin) ? cloneDeep(admin) : false;
    }


    /**
     * Returns a list with all registered permissions
     */
    getPermissionsList() {
        return cloneDeep(this.registeredPermissions);
    }


    /**
     * Writes to storage the admins file
     */
    async writeAdminsFile() {
        const jsonData = JSON.stringify(this.admins, null, 2);
        this.adminsFileHash = createHash('sha1').update(jsonData).digest('hex');
        await fsp.writeFile(this.adminsFile, jsonData, 'utf8');
        return true;
    }


    /**
     * Writes to storage the admins file
     */
    async checkAdminsFile() {
        const restore = async () => {
            try {
                await this.writeAdminsFile();
                console.ok('Restored admins.json file.');
            } catch (error) {
                console.error(`Failed to restore admins.json file: ${error.message}`);
                console.verbose.dir(error);
            }
        };
        try {
            const jsonData = await fsp.readFile(this.adminsFile, 'utf8');
            const inboundHash = createHash('sha1').update(jsonData).digest('hex');
            if (this.adminsFileHash !== inboundHash) {
                console.warn('The admins.json file was modified or deleted by an external source, txAdmin will try to restore it.');
                restore();
            }
        } catch (error) {
            console.error(`Cannot check admins file integrity: ${error.message}`);
        }
    }


    /**
     * Add a new admin to the admins file
     * NOTE: I'm fully aware this coud be optimized. Leaving this way to improve readability and error verbosity
     * @param {string} name
     * @param {object|undefined} citizenfxData or false
     * @param {object|undefined} discordData or false
     * @param {string} password
     * @param {array} permissions
     */
    async addAdmin(name, citizenfxData, discordData, password, permissions) {
        if (this.admins == false) throw new Error('Admins not set');

        //Check if username is already taken
        if (this.getAdminByName(name)) throw new Error('Username already taken');

        //Preparing admin
        const admin = {
            $schema: ADMIN_SCHEMA_VERSION,
            name,
            master: false,
            password_hash: GetPasswordHash(password),
            password_temporary: true,
            providers: {},
            permissions,
        };

        //Check if provider uid already taken and inserting into admin object
        if (citizenfxData) {
            const existingCitizenFX = this.getAdminByProviderUID(citizenfxData.id);
            if (existingCitizenFX) throw new Error('CitizenFX ID already taken');
            admin.providers.citizenfx = {
                id: citizenfxData.id,
                identifier: citizenfxData.identifier,
                data: {},
            };
        }
        if (discordData) {
            const existingDiscord = this.getAdminByProviderUID(discordData.id);
            if (existingDiscord) throw new Error('Discord ID already taken');
            admin.providers.discord = {
                id: discordData.id,
                identifier: discordData.identifier,
                data: {},
            };
        }

        //Saving admin file
        this.admins.push(admin);
        this.refreshOnlineAdmins().catch((e) => { });
        try {
            return await this.writeAdminsFile();
        } catch (error) {
            throw new Error(`Failed to save admins.json with error: ${error.message}`);
        }
    }


    /**
     * Edit admin and save to the admins file
     * @param {string} name
     * @param {string|null} password
     * @param {object|false} [citizenfxData] or false
     * @param {object|false} [discordData] or false
     * @param {string[]} [permissions]
     */
    async editAdmin(name, password, citizenfxData, discordData, permissions) {
        if (this.admins == false) throw new Error('Admins not set');

        //Find admin index
        let username = name.toLowerCase();
        let adminIndex = this.admins.findIndex((user) => {
            return (username === user.name.toLowerCase());
        });
        if (adminIndex == -1) throw new Error('Admin not found');

        //Editing admin
        if (password !== null) {
            this.admins[adminIndex].password_hash = GetPasswordHash(password);
            delete this.admins[adminIndex].password_temporary;
        }
        if (typeof citizenfxData !== 'undefined') {
            if (!citizenfxData) {
                delete this.admins[adminIndex].providers.citizenfx;
            } else {
                this.admins[adminIndex].providers.citizenfx = {
                    id: citizenfxData.id,
                    identifier: citizenfxData.identifier,
                    data: {},
                };
            }
        }
        if (typeof discordData !== 'undefined') {
            if (!discordData) {
                delete this.admins[adminIndex].providers.discord;
            } else {
                this.admins[adminIndex].providers.discord = {
                    id: discordData.id,
                    identifier: discordData.identifier,
                    data: {},
                };
            }
        }
        if (typeof permissions !== 'undefined') this.admins[adminIndex].permissions = permissions;

        //Prevent race condition, will allow the session to be updated before refreshing socket.io
        //sessions which will cause reauth and closing of the temp password modal on first access
        setTimeout(() => {
            this.refreshOnlineAdmins().catch((e) => { });
        }, 250);

        //Saving admin file
        try {
            await this.writeAdminsFile();
            return (password !== null) ? this.admins[adminIndex].password_hash : true;
        } catch (error) {
            throw new Error(`Failed to save admins.json with error: ${error.message}`);
        }
    }


    /**
     * Delete admin and save to the admins file
     * @param {string} name
     */
    async deleteAdmin(name) {
        if (this.admins == false) throw new Error('Admins not set');

        //Delete admin
        let username = name.toLowerCase();
        let found = false;
        this.admins = this.admins.filter((user) => {
            if (username !== user.name.toLowerCase()) {
                return true;
            } else {
                found = true;
                return false;
            }
        });
        if (!found) throw new Error('Admin not found');

        //Saving admin file
        this.refreshOnlineAdmins().catch((e) => { });
        try {
            return await this.writeAdminsFile();
        } catch (error) {
            throw new Error(`Failed to save admins.json with error: ${error.message}`);
        }
    }

    /**
     * Loads the admins.json file into the admins list
     * NOTE: The verbosity here is driving me insane.
     *       But still seems not to be enough for people that don't read the README.
     */
    async loadAdminsFile() {
        let raw = null;
        let jsonData = null;
        let hasMigration = false;

        const callError = (reason) => {
            let details;
            if (reason === 'cannot read file') {
                details = ['This means the file  doesn\'t exist or txAdmin doesn\'t have permission to read it.'];
            } else {
                details = [
                    'This likely means the file got somehow corrupted.',
                    'You can try restoring it or you can delete it and let txAdmin create a new one.',
                ];
            }
            fatalError.AdminStore(0, [
                ['Unable to load admins.json', reason],
                ...details,
                ['Admin File Path', this.adminsFile],
            ]);
        };

        try {
            raw = await fsp.readFile(this.adminsFile, 'utf8');
            this.adminsFileHash = createHash('sha1').update(raw).digest('hex');
        } catch (error) {
            return callError('cannot read file');
        }

        if (!raw.length) {
            return callError('empty file');
        }

        try {
            jsonData = JSON.parse(raw);
        } catch (error) {
            return callError('json parse error');
        }

        if (!Array.isArray(jsonData)) {
            return callError('not an array');
        }

        if (!jsonData.length) {
            return callError('no admins');
        }

        const structureIntegrityTest = jsonData.some((x) => {
            if (typeof x.name !== 'string' || x.name.length < 3) return true;
            if (typeof x.master !== 'boolean') return true;
            if (typeof x.password_hash !== 'string' || !x.password_hash.startsWith('$2')) return true;
            if (typeof x.providers !== 'object') return true;
            const providersTest = Object.keys(x.providers).some((y) => {
                if (!Object.keys(this.providers).includes(y)) return true;
                if (typeof x.providers[y].id !== 'string' || x.providers[y].id.length < 3) return true;
                if (typeof x.providers[y].data !== 'object') return true;
                if (typeof x.providers[y].identifier === 'string') {
                    if (x.providers[y].identifier.length < 3) return true;
                } else {
                    migrateProviderIdentifiers(y, x.providers[y]);
                    hasMigration = true;
                }
            });
            if (providersTest) return true;
            if (!Array.isArray(x.permissions)) return true;
            return false;
        });
        if (structureIntegrityTest) {
            return callError('invalid data in the admins file');
        }

        const masters = jsonData.filter((x) => x.master);
        if (masters.length !== 1) {
            return callError('must have exactly 1 master account');
        }

        //Migrate admin stuff
        jsonData.forEach((admin) => {
            //Migration (tx v7.3.0)
            if (admin.$schema === undefined) {
                //adding schema version
                admin.$schema = ADMIN_SCHEMA_VERSION;
                hasMigration = true;

                //separate DM and Announcement permissions
                if (admin.permissions.includes('players.message')) {
                    hasMigration = true;
                    admin.permissions = admin.permissions.filter((perm) => perm !== 'players.message');
                    admin.permissions.push('players.direct_message');
                    admin.permissions.push('announcement');
                }

                //Adding the new permission, except if they have no permissions or all of them
                if (admin.permissions.length && !admin.permissions.includes('all_permissions')) {
                    admin.permissions.push('server.log.view');
                }
            }
        });

        this.admins = jsonData;
        if (hasMigration) {
            try {
                await this.writeAdminsFile();
                console.ok('The admins.json file was migrated to a new version.');
            } catch (error) {
                console.error(`Failed to migrate admins.json with error: ${error.message}`);
            }
        }

        return true;
    }


    /**
     * Notify game server about admin changes
     */
    async refreshOnlineAdmins() {
        //Refresh auth of all admins connected to socket.io
        txCore.webServer.webSocket.reCheckAdminAuths().catch((e) => { });

        try {
            //Getting all admin identifiers
            const adminIDs = this.admins.reduce((ids, adm) => {
                const adminIDs = Object.keys(adm.providers).map((pName) => adm.providers[pName].identifier);
                return ids.concat(adminIDs);
            }, []);

            //Finding online admins
            const playerList = txCore.fxPlayerlist.getPlayerList();
            const onlineIDs = playerList.filter((p) => {
                return p.ids.some((i) => adminIDs.includes(i));
            }).map((p) => p.netid);

            txCore.fxRunner.sendEvent('adminsUpdated', onlineIDs);
        } catch (error) {
            console.verbose.error('Failed to refreshOnlineAdmins() with error:');
            console.verbose.dir(error);
        }
    }


    /**
     * Returns a random token to be used as CSRF Token.
     */
    genCsrfToken() {
        return nanoid();
    }


    /**
     * Checks if there are admins configured or not.
     * Optionally, prints the master PIN on the console.
     */
    hasAdmins(printPin = false) {
        if (Array.isArray(this.admins) && this.admins.length) {
            return true;
        } else {
            if (printPin) {
                console.warn('Use this PIN to add a new master account: ' + chalkInversePad(this.addMasterPin));
            }
            return false;
        }
    }


    /**
     * Returns the public name to display for that particular purpose
     * TODO: maybe use enums for the purpose
     */
    getAdminPublicName(name, purpose) {
        if (!name || !purpose) throw new Error('Invalid parameters');
        const replacer = txConfig.general.serverName ?? 'txAdmin';

        if (purpose === 'punishment') {
            return txConfig.gameFeatures.hideAdminInPunishments ? replacer : name;
        } else if (purpose === 'message') {
            return txConfig.gameFeatures.hideAdminInMessages ? replacer : name;
        } else {
            throw new Error(`Invalid purpose: ${purpose}`);
        }
    }
};
