"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    var desc = Object.getOwnPropertyDescriptor(m, k);
    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
      desc = { enumerable: true, get: function() { return m[k]; } };
    }
    Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
    Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
    o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
    if (mod && mod.__esModule) return mod;
    var result = {};
    if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
    __setModuleDefault(result, mod);
    return result;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const lodash_1 = __importDefault(require("lodash"));
const ua_parser_js_1 = __importDefault(require("ua-parser-js"));
const escapeRegExp_1 = __importDefault(require("lodash/escapeRegExp"));
const crypto_1 = __importDefault(require("crypto"));
const chalk_1 = __importDefault(require("chalk"));
const log_1 = __importDefault(require("./log"));
const chan_1 = __importDefault(require("./models/chan"));
const msg_1 = __importDefault(require("./models/msg"));
const config_1 = __importDefault(require("./config"));
const irc_1 = require("../shared/irc");
const msg_2 = require("../shared/types/msg");
const inputs_1 = __importDefault(require("./plugins/inputs"));
const publicClient_1 = __importDefault(require("./plugins/packages/publicClient"));
const sqlite_1 = __importDefault(require("./plugins/messageStorage/sqlite"));
const text_1 = __importDefault(require("./plugins/messageStorage/text"));
const network_1 = __importDefault(require("./models/network"));
const storageCleaner_1 = require("./storageCleaner");
const chan_2 = require("../shared/types/chan");
const events = [
    "away",
    "cap",
    "connection",
    "unhandled",
    "ctcp",
    "chghost",
    "error",
    "help",
    "info",
    "invite",
    "join",
    "kick",
    "list",
    "mode",
    "modelist",
    "motd",
    "message",
    "names",
    "nick",
    "part",
    "quit",
    "sasl",
    "topic",
    "welcome",
    "whois",
];
class Client {
    awayMessage;
    lastActiveChannel;
    attachedClients;
    config;
    id;
    idMsg;
    idChan;
    name;
    networks;
    mentions;
    manager;
    messageStorage;
    highlightRegex;
    highlightExceptionRegex;
    messageProvider;
    fileHash;
    constructor(manager, name, config = {}) {
        this.id = crypto_1.default.randomUUID();
        lodash_1.default.merge(this, {
            awayMessage: "",
            lastActiveChannel: -1,
            attachedClients: {},
            config: config,
            idChan: 1,
            idMsg: 1,
            name: name,
            networks: [],
            mentions: [],
            manager: manager,
            messageStorage: [],
            highlightRegex: null,
            highlightExceptionRegex: null,
            messageProvider: undefined,
        });
        const client = this;
        client.config.log = Boolean(client.config.log);
        client.config.password = String(client.config.password);
        if (!config_1.default.values.public && client.config.log) {
            if (config_1.default.values.messageStorage.includes("sqlite")) {
                client.messageProvider = new sqlite_1.default(client.name);
                if (config_1.default.values.storagePolicy.enabled) {
                    log_1.default.info(`Activating storage cleaner. Policy: ${config_1.default.values.storagePolicy.deletionPolicy}. MaxAge: ${config_1.default.values.storagePolicy.maxAgeDays} days`);
                    const cleaner = new storageCleaner_1.StorageCleaner(client.messageProvider);
                    cleaner.start();
                }
                client.messageStorage.push(client.messageProvider);
            }
            if (config_1.default.values.messageStorage.includes("text")) {
                client.messageStorage.push(new text_1.default(client.name));
            }
            for (const messageStorage of client.messageStorage) {
                try {
                    messageStorage.enable();
                }
                catch (e) {
                    log_1.default.error(e);
                }
            }
        }
        if (!lodash_1.default.isPlainObject(client.config.sessions)) {
            client.config.sessions = {};
        }
        if (!lodash_1.default.isPlainObject(client.config.clientSettings)) {
            client.config.clientSettings = {};
        }
        if (!lodash_1.default.isPlainObject(client.config.browser)) {
            client.config.browser = {};
        }
        if (client.config.clientSettings.awayMessage) {
            client.awayMessage = client.config.clientSettings.awayMessage;
        }
        client.config.clientSettings.searchEnabled = client.messageProvider !== undefined;
        client.compileCustomHighlights();
        lodash_1.default.forOwn(client.config.sessions, (session) => {
            if (session.pushSubscription) {
                this.registerPushSubscription(session, session.pushSubscription, true);
            }
        });
    }
    connect() {
        const client = this;
        if (client.networks.length !== 0) {
            throw new Error(`${client.name} is already connected`);
        }
        (client.config.networks || []).forEach((network) => client.connectToNetwork(network, true));
        // Networks are stored directly in the client object
        // We don't need to keep it in the config object
        delete client.config.networks;
        if (client.name) {
            log_1.default.info(`User ${chalk_1.default.bold(client.name)} loaded`);
            // Networks are created instantly, but to reduce server load on startup
            // We randomize the IRC connections and channel log loading
            let delay = client.manager.clients.length * 500;
            client.networks.forEach((network) => {
                setTimeout(() => {
                    network.channels.forEach((channel) => channel.loadMessages(client, network));
                    if (!network.userDisconnected && network.irc) {
                        network.irc.connect();
                    }
                }, delay);
                delay += 1000 + Math.floor(Math.random() * 1000);
            });
            client.fileHash = client.manager.getDataToSave(client).newHash;
        }
    }
    createChannel(attr) {
        const chan = new chan_1.default(attr);
        chan.id = this.idChan++;
        return chan;
    }
    emit(event, ...args) {
        if (this.manager !== null) {
            this.manager.sockets.in(this.id).emit(event, ...args);
        }
    }
    find(channelId) {
        let network = null;
        let chan = null;
        for (const n of this.networks) {
            chan = lodash_1.default.find(n.channels, { id: channelId });
            if (chan) {
                network = n;
                break;
            }
        }
        if (network && chan) {
            return { network, chan };
        }
        return false;
    }
    networkFromConfig(args) {
        const client = this;
        let channels = [];
        if (Array.isArray(args.channels)) {
            let badChanConf = false;
            args.channels.forEach((chan) => {
                const type = chan_2.ChanType[(chan.type || "channel").toUpperCase()];
                if (!chan.name || !type) {
                    badChanConf = true;
                    return;
                }
                channels.push(client.createChannel({
                    name: chan.name,
                    key: chan.key || "",
                    type: type,
                    muted: chan.muted,
                }));
            });
            if (badChanConf && client.name) {
                log_1.default.warn("User '" +
                    client.name +
                    "' on network '" +
                    String(args.name) +
                    "' has an invalid channel which has been ignored");
            }
            // `join` is kept for backwards compatibility when updating from versions <2.0
            // also used by the "connect" window
        }
        else if (args.join) {
            channels = args.join
                .replace(/,/g, " ")
                .split(/\s+/g)
                .map((chan) => {
                if (!chan.match(/^[#&!+]/)) {
                    chan = `#${chan}`;
                }
                return client.createChannel({
                    name: chan,
                });
            });
        }
        // TODO; better typing for args
        return new network_1.default({
            uuid: args.uuid,
            name: String(args.name || (config_1.default.values.lockNetwork ? config_1.default.values.defaults.name : "") || ""),
            host: String(args.host || ""),
            port: parseInt(String(args.port), 10),
            tls: !!args.tls,
            userDisconnected: !!args.userDisconnected,
            rejectUnauthorized: !!args.rejectUnauthorized,
            password: String(args.password || ""),
            nick: String(args.nick || ""),
            username: String(args.username || ""),
            realname: String(args.realname || ""),
            leaveMessage: String(args.leaveMessage || ""),
            sasl: String(args.sasl || ""),
            saslAccount: String(args.saslAccount || ""),
            saslPassword: String(args.saslPassword || ""),
            commands: args.commands || [],
            channels: channels,
            ignoreList: args.ignoreList ? args.ignoreList : [],
            proxyEnabled: !!args.proxyEnabled,
            proxyHost: String(args.proxyHost || ""),
            proxyPort: parseInt(args.proxyPort, 10),
            proxyUsername: String(args.proxyUsername || ""),
            proxyPassword: String(args.proxyPassword || ""),
        });
    }
    connectToNetwork(args, isStartup = false) {
        const client = this;
        // Get channel id for lobby before creating other channels for nicer ids
        const lobbyChannelId = client.idChan++;
        const network = this.networkFromConfig(args);
        // Set network lobby channel id
        network.getLobby().id = lobbyChannelId;
        client.networks.push(network);
        client.emit("network", {
            network: network.getFilteredClone(this.lastActiveChannel, -1),
        });
        if (!network.validate(client)) {
            return;
        }
        network.createIrcFramework(client);
        // TODO
        // eslint-disable-next-line @typescript-eslint/no-misused-promises
        events.forEach(async (plugin) => {
            (await Promise.resolve(`${`./plugins/irc-events/${plugin}`}`).then(s => __importStar(require(s)))).default.apply(client, [
                network.irc,
                network,
            ]);
        });
        if (network.userDisconnected) {
            network.getLobby().pushMessage(client, new msg_1.default({
                text: "You have manually disconnected from this network before, use the /connect command to connect again.",
            }), true);
        }
        else if (!isStartup) {
            // irc is created in createIrcFramework
            // TODO; fix type
            network.irc.connect();
        }
        if (!isStartup) {
            client.save();
            network.channels.forEach((channel) => channel.loadMessages(client, network));
        }
    }
    generateToken(callback) {
        crypto_1.default.randomBytes(64, (err, buf) => {
            if (err) {
                throw err;
            }
            callback(buf.toString("hex"));
        });
    }
    calculateTokenHash(token) {
        return crypto_1.default.createHash("sha512").update(token).digest("hex");
    }
    updateSession(token, ip, request) {
        const client = this;
        const agent = (0, ua_parser_js_1.default)(request.headers["user-agent"] || "");
        let friendlyAgent = "";
        if (agent.browser.name) {
            friendlyAgent = `${agent.browser.name} ${agent.browser.major || ""}`;
        }
        else {
            friendlyAgent = "Unknown browser";
        }
        if (agent.os.name) {
            friendlyAgent += ` on ${agent.os.name}`;
            if (agent.os.version) {
                friendlyAgent += ` ${agent.os.version}`;
            }
        }
        client.config.sessions[token] = lodash_1.default.assign(client.config.sessions[token], {
            lastUse: Date.now(),
            ip: ip,
            agent: friendlyAgent,
        });
        client.save();
    }
    setPassword(hash, callback) {
        const client = this;
        const oldHash = client.config.password;
        client.config.password = hash;
        client.manager.saveUser(client, function (err) {
            if (err) {
                // If user file fails to write, reset it back
                client.config.password = oldHash;
                return callback(false);
            }
            return callback(true);
        });
    }
    input(data) {
        const client = this;
        data.text.split("\n").forEach((line) => {
            data.text = line;
            client.inputLine(data);
        });
    }
    inputLine(data) {
        const client = this;
        const target = client.find(data.target);
        if (!target) {
            return;
        }
        // Sending a message to a channel is higher priority than merely opening one
        // so that reloading the page will open this channel
        this.lastActiveChannel = target.chan.id;
        let text = data.text;
        // This is either a normal message or a command escaped with a leading '/'
        if (text.charAt(0) !== "/" || text.charAt(1) === "/") {
            if (target.chan.type === chan_2.ChanType.LOBBY) {
                target.chan.pushMessage(this, new msg_1.default({
                    type: msg_2.MessageType.ERROR,
                    text: "Messages can not be sent to lobbies.",
                }));
                return;
            }
            text = "say " + text.replace(/^\//, "");
        }
        else {
            text = text.substring(1);
        }
        const args = text.split(" ");
        const cmd = args?.shift()?.toLowerCase() || "";
        const irc = target.network.irc;
        const connected = irc?.connected;
        const emitFailureDisconnected = () => {
            target.chan.pushMessage(this, new msg_1.default({
                type: msg_2.MessageType.ERROR,
                text: "You are not connected to the IRC network, unable to send your command.",
            }));
        };
        const plugin = inputs_1.default.userInputs.get(cmd);
        if (plugin) {
            if (!connected && !plugin.allowDisconnected) {
                emitFailureDisconnected();
                return;
            }
            plugin.input.apply(client, [target.network, target.chan, cmd, args]);
            return;
        }
        const extPlugin = inputs_1.default.pluginCommands.get(cmd);
        if (extPlugin) {
            if (!connected && !extPlugin.allowDisconnected) {
                emitFailureDisconnected();
                return;
            }
            extPlugin.input(new publicClient_1.default(client, extPlugin.packageInfo), { network: target.network, chan: target.chan }, cmd, args);
            return;
        }
        if (!connected) {
            emitFailureDisconnected();
            return;
        }
        // TODO: fix
        irc.raw(text);
    }
    compileCustomHighlights() {
        function compileHighlightRegex(customHighlightString) {
            if (typeof customHighlightString !== "string") {
                return null;
            }
            // Ensure we don't have empty strings in the list of highlights
            const highlightsTokens = customHighlightString
                .split(",")
                .map((highlight) => (0, escapeRegExp_1.default)(highlight.trim()))
                .filter((highlight) => highlight.length > 0);
            if (highlightsTokens.length === 0) {
                return null;
            }
            return new RegExp(`(?:^|[ .,+!?|/:<>(){}'"@&~-])(?:${highlightsTokens.join("|")})(?:$|[ .,+!?|/:<>(){}'"-])`, "i");
        }
        this.highlightRegex = compileHighlightRegex(this.config.clientSettings.highlights);
        this.highlightExceptionRegex = compileHighlightRegex(this.config.clientSettings.highlightExceptions);
    }
    more(data) {
        const client = this;
        const target = client.find(data.target);
        if (!target) {
            return null;
        }
        const chan = target.chan;
        let messages = [];
        let index = 0;
        // If client requests -1, send last 100 messages
        if (data.lastId < 0) {
            index = chan.messages.length;
        }
        else {
            index = chan.messages.findIndex((val) => val.id === data.lastId);
        }
        // If requested id is not found, an empty array will be sent
        if (index > 0) {
            let startIndex = index;
            if (data.condensed) {
                // Limit to 1000 messages (that's 10x normal limit)
                const indexToStop = Math.max(0, index - 1000);
                let realMessagesLeft = 100;
                for (let i = index - 1; i >= indexToStop; i--) {
                    startIndex--;
                    // Do not count condensed messages towards the 100 messages
                    if (irc_1.condensedTypes.has(chan.messages[i].type)) {
                        continue;
                    }
                    // Count up actual 100 visible messages
                    if (--realMessagesLeft === 0) {
                        break;
                    }
                }
            }
            else {
                startIndex = Math.max(0, index - 100);
            }
            messages = chan.messages.slice(startIndex, index);
        }
        return {
            chan: chan.id,
            messages: messages,
            totalMessages: chan.messages.length,
        };
    }
    clearHistory(data) {
        const client = this;
        const target = client.find(data.target);
        if (!target) {
            return;
        }
        target.chan.messages = [];
        target.chan.unread = 0;
        target.chan.highlight = 0;
        target.chan.firstUnread = 0;
        client.emit("history:clear", {
            target: target.chan.id,
        });
        if (!target.chan.isLoggable()) {
            return;
        }
        for (const messageStorage of this.messageStorage) {
            try {
                messageStorage.deleteChannel(target.network, target.chan);
            }
            catch (e) {
                log_1.default.error(e);
            }
        }
    }
    search(query) {
        if (!this.messageProvider?.isEnabled) {
            return {
                ...query,
                results: [],
            };
        }
        return this.messageProvider.search(query);
    }
    open(socketId, target) {
        // Due to how socket.io works internally, normal events may arrive later than
        // the disconnect event, and because we can't control this timing precisely,
        // process this event normally even if there is no attached client anymore.
        const attachedClient = this.attachedClients[socketId] ||
            {};
        // Opening a window like settings
        if (target === null) {
            attachedClient.openChannel = -1;
            return;
        }
        const targetNetChan = this.find(target);
        if (!targetNetChan) {
            return;
        }
        targetNetChan.chan.unread = 0;
        targetNetChan.chan.highlight = 0;
        if (targetNetChan.chan.messages.length > 0) {
            targetNetChan.chan.firstUnread =
                targetNetChan.chan.messages[targetNetChan.chan.messages.length - 1].id;
        }
        attachedClient.openChannel = targetNetChan.chan.id;
        this.lastActiveChannel = targetNetChan.chan.id;
        this.emit("open", targetNetChan.chan.id);
    }
    sortChannels(netid, order) {
        const network = lodash_1.default.find(this.networks, { uuid: netid });
        if (!network) {
            return;
        }
        network.channels.sort((a, b) => {
            // Always sort lobby to the top regardless of what the client has sent
            // Because there's a lot of code that presumes channels[0] is the lobby
            if (a.type === chan_2.ChanType.LOBBY) {
                return -1;
            }
            else if (b.type === chan_2.ChanType.LOBBY) {
                return 1;
            }
            return order.indexOf(a.id) - order.indexOf(b.id);
        });
        this.save();
        // Sync order to connected clients
        this.emit("sync_sort:channels", {
            network: network.uuid,
            order: network.channels.map((obj) => obj.id),
        });
    }
    sortNetworks(order) {
        this.networks.sort((a, b) => order.indexOf(a.uuid) - order.indexOf(b.uuid));
        this.save();
        // Sync order to connected clients
        this.emit("sync_sort:networks", {
            order: this.networks.map((obj) => obj.uuid),
        });
    }
    names(data) {
        const client = this;
        const target = client.find(data.target);
        if (!target) {
            return;
        }
        client.emit("names", {
            id: target.chan.id,
            users: target.chan.getSortedUsers(target.network.irc),
        });
    }
    part(network, chan) {
        const client = this;
        network.channels = lodash_1.default.without(network.channels, chan);
        client.mentions = client.mentions.filter((msg) => !(msg.chanId === chan.id));
        chan.destroy();
        client.save();
        client.emit("part", {
            chan: chan.id,
        });
    }
    quit(signOut) {
        const sockets = this.manager.sockets.sockets;
        const room = sockets.adapter.rooms.get(this.id);
        if (room) {
            for (const user of room) {
                const socket = sockets.sockets.get(user);
                if (socket) {
                    if (signOut) {
                        socket.emit("sign-out");
                    }
                    socket.disconnect();
                }
            }
        }
        this.networks.forEach((network) => {
            network.quit();
            network.destroy();
        });
        for (const messageStorage of this.messageStorage) {
            try {
                messageStorage.close();
            }
            catch (e) {
                log_1.default.error(e);
            }
        }
    }
    clientAttach(socketId, token) {
        const client = this;
        if (client.awayMessage && lodash_1.default.size(client.attachedClients) === 0) {
            client.networks.forEach(function (network) {
                // Only remove away on client attachment if
                // there is no away message on this network
                if (network.irc && !network.awayMessage) {
                    network.irc.raw("AWAY");
                }
            });
        }
        const openChannel = client.lastActiveChannel;
        client.attachedClients[socketId] = { token, openChannel };
    }
    clientDetach(socketId) {
        const client = this;
        delete this.attachedClients[socketId];
        if (client.awayMessage && lodash_1.default.size(client.attachedClients) === 0) {
            client.networks.forEach(function (network) {
                // Only set away on client deattachment if
                // there is no away message on this network
                if (network.irc && !network.awayMessage) {
                    network.irc.raw("AWAY", client.awayMessage);
                }
            });
        }
    }
    // TODO: type session to this.attachedClients
    registerPushSubscription(session, subscription, noSave = false) {
        if (!lodash_1.default.isPlainObject(subscription) ||
            typeof subscription.endpoint !== "string" ||
            !/^https?:\/\//.test(subscription.endpoint) ||
            !lodash_1.default.isPlainObject(subscription.keys) ||
            !subscription.keys || // TS compiler doesn't understand isPlainObject
            typeof subscription.keys.p256dh !== "string" ||
            typeof subscription.keys.auth !== "string") {
            session.pushSubscription = null;
            return;
        }
        const data = {
            endpoint: subscription.endpoint,
            keys: {
                p256dh: subscription.keys.p256dh,
                auth: subscription.keys.auth,
            },
        };
        session.pushSubscription = data;
        if (!noSave) {
            this.save();
        }
        return data;
    }
    unregisterPushSubscription(token) {
        this.config.sessions[token].pushSubscription = undefined;
        this.save();
    }
    save = lodash_1.default.debounce(function SaveClient() {
        if (config_1.default.values.public) {
            return;
        }
        const client = this;
        client.manager.saveUser(client);
    }, 5000, { maxWait: 20000 });
}
exports.default = Client;
