const os = require('os');
const fs = require('fs');
const path = require('path');
const net = require('net');
const { ipcMain, app } = require('electron');
const { Logger } = require('../logger');
const { getProcessInfo } = require('../util/process-utils');
const { ExtensionIds } = require('../const/extension-ids');
const browserExtensionInstaller = require('../util/browser-extension-installer');

ipcMain.handle('browserExtensionConnectorStart', browserExtensionConnectorStart);
ipcMain.handle('browserExtensionConnectorStop', browserExtensionConnectorStop);
ipcMain.handle('browserExtensionConnectorEnable', browserExtensionConnectorEnable);
ipcMain.handle('browserExtensionConnectorSocketResult', browserExtensionConnectorSocketResult);
ipcMain.handle('browserExtensionConnectorSocketEvent', browserExtensionConnectorSocketEvent);
ipcMain.handle('browserExtensionConnectorCloseSocket', browserExtensionConnectorCloseSocket);

const logger = new Logger('browser-extension-connector');

const BrowserExtensionNames = {
    KWC: 'KeeWeb Connect',
    KPXC: 'KeePassXC-Browser'
};

const MaxIncomingDataLength = 10_000;
const ExtensionOrigins = {
    [ExtensionIds.Origins.KeeWebConnectSafari]: BrowserExtensionNames.KWC,
    [ExtensionIds.Origins.KeeWebConnectFirefox]: BrowserExtensionNames.KWC,
    [ExtensionIds.Origins.KeeWebConnectChrome]: BrowserExtensionNames.KWC,
    [ExtensionIds.Origins.KeeWebConnectEdge]: BrowserExtensionNames.KWC,
    [ExtensionIds.Origins.KeePassXcBrowserFirefox]: BrowserExtensionNames.KPXC,
    [ExtensionIds.Origins.KeePassXcBrowserChrome]: BrowserExtensionNames.KPXC,
    [ExtensionIds.Origins.KeePassXcBrowserEdge]: BrowserExtensionNames.KPXC
};

for (const devExtId of process.env.KEEWEB_BROWSER_EXTENSION_IDS_CHROMIUM?.split(',') || []) {
    ExtensionOrigins[`chrome-extension://${devExtId}/`] = BrowserExtensionNames.KWC;
}

const AppNames = {
    'msedge': 'Microsoft Edge',
    'chrome': 'Google Chrome'
};

let connectedSockets = new Map();
let connectedSocketState = new WeakMap();
let server;
let serverSocketName;
let socketId = 0;

async function browserExtensionConnectorStart(e, config) {
    serverSocketName = getBrowserExtensionSocketName(config);
    await prepareBrowserExtensionSocket();

    if (isSocketNameTooLong(serverSocketName)) {
        logger.error(
            "Socket name is too long, browser connection won't be possible, probably OS username is very long.",
            serverSocketName
        );
        return;
    }

    server = net.createServer(async (socket) => {
        socketId++;

        logger.info(`New connection with socket ${socketId}`);

        connectedSockets.set(socketId, socket);
        connectedSocketState.set(socket, { socketId });

        socket.on('data', (data) => onSocketData(socket, data));
        socket.on('close', () => onSocketClose(socket));
    });
    server.listen(serverSocketName);

    logger.info('Started');
}

function browserExtensionConnectorStop() {
    for (const socket of connectedSockets.values()) {
        socket.destroy();
    }
    if (server) {
        server.close();
        server = null;
    }
    connectedSockets = new Map();
    connectedSocketState = new WeakMap();

    logger.info('Stopped');
}

async function browserExtensionConnectorEnable(e, browser, extension, enabled) {
    logger.info(enabled ? 'Enable' : 'Disable', browser, extension);

    try {
        if (enabled) {
            await browserExtensionInstaller.install(browser, extension);
        } else {
            await browserExtensionInstaller.uninstall(browser, extension);
        }
    } catch (e) {
        logger.error(`Error installing extension: ${e}`);
    }
}

function browserExtensionConnectorSocketResult(e, socketId, result) {
    sendResultToSocket(socketId, result);
}

function browserExtensionConnectorSocketEvent(e, data) {
    sendEventToAllSockets(data);
}

function browserExtensionConnectorCloseSocket(e, socketId) {
    const socket = connectedSockets.get(socketId);
    socket?.destroy();
}

function getBrowserExtensionSocketName(config) {
    const { username, uid } = os.userInfo();
    if (process.platform === 'darwin') {
        const appleTeamId = config.appleTeamId;
        return `/Users/${username}/Library/Group Containers/${appleTeamId}.keeweb/conn.sock`;
    } else if (process.platform === 'win32') {
        return `\\\\.\\pipe\\keeweb-connect-${username}`;
    } else {
        const sockFileName = `keeweb-connect-${uid}.sock`;
        return path.join(app.getPath('temp'), sockFileName);
    }
}

function prepareBrowserExtensionSocket() {
    return new Promise((resolve) => {
        if (process.platform === 'darwin') {
            fs.access(serverSocketName, fs.constants.F_OK, (err) => {
                if (err) {
                    const dir = path.dirname(serverSocketName);
                    fs.mkdir(dir, () => resolve());
                } else {
                    fs.unlink(serverSocketName, () => resolve());
                }
            });
        } else if (process.platform === 'win32') {
            return resolve();
        } else {
            fs.unlink(serverSocketName, () => resolve());
        }
    });
}

function isSocketNameTooLong(socketName) {
    const maxLength = process.platform === 'win32' ? 256 : 104;
    return socketName.length > maxLength;
}

function onSocketClose(socket) {
    const state = connectedSocketState.get(socket);
    connectedSocketState.delete(socket);

    if (state?.socketId) {
        connectedSockets.delete(state.socketId);
        sendToRenderer('browserExtensionSocketClosed', state.socketId);
    }

    logger.info(`Socket ${state?.socketId} closed`);
}

function onSocketData(socket, data) {
    const state = connectedSocketState.get(socket);
    if (!state) {
        logger.warn('Received data without connection state');
        return;
    }

    if (data.byteLength > MaxIncomingDataLength) {
        logger.warn(`Too many bytes rejected from socket ${state.socketId}`, data.byteLength);
        socket.destroy();
        return;
    }
    if (state.pendingData) {
        state.pendingData = Buffer.concat([state.pendingData, data]);
    } else {
        state.pendingData = data;
    }
    processPendingSocketData(socket);
}

async function processPendingSocketData(socket) {
    const state = connectedSocketState.get(socket);
    if (!state) {
        return;
    }
    if (!state.pendingData || state.processingData) {
        return;
    }

    if (state.pendingData.length < 4) {
        return;
    }

    const lengthBuffer = state.pendingData.buffer.slice(
        state.pendingData.byteOffset,
        state.pendingData.byteOffset + 4
    );
    const length = new Uint32Array(lengthBuffer)[0];

    if (length > MaxIncomingDataLength) {
        logger.warn(`Large message rejected from socket ${state.socketId}`, length);
        socket.destroy();
        return;
    }

    if (state.pendingData.byteLength < length + 4) {
        return;
    }

    const messageBytes = state.pendingData.slice(4, length + 4);
    if (state.pendingData.byteLength > length + 4) {
        state.pendingData = state.pendingData.slice(length + 4);
    } else {
        state.pendingData = null;
    }

    const str = messageBytes.toString();
    let request;
    try {
        request = JSON.parse(str);
    } catch {
        logger.warn(`Failed to parse message from socket ${state.socketId}`, str);
        socket.destroy();
        return;
    }

    if (!state.active) {
        await processFirstMessageFromSocket(socket, request);
        return;
    }

    logger.debug(`Extension[${state.socketId}] -> KeeWeb`, request);

    if (!request) {
        logger.warn(`Empty request for socket ${state.socketId}`, request);
        socket.destroy();
        return;
    }

    if (request.clientID) {
        const clientId = request.clientID;
        if (!state.clientId) {
            state.clientId = clientId;
        } else if (state.clientId !== clientId) {
            logger.warn(
                `Changing client ID for socket ${state.socketId} is not allowed`,
                `${state.clientId} => ${clientId}`
            );
            socket.destroy();
            return;
        }
    } else {
        if (request.action !== 'ping') {
            logger.warn(`Empty client ID in socket request ${state.socketId}`, request);
            socket.destroy();
            return;
        }
    }

    state.processingData = true;

    sendToRenderer('browserExtensionSocketRequest', state.socketId, request);
}

async function processFirstMessageFromSocket(socket, message) {
    const state = connectedSocketState.get(socket);
    if (!state) {
        return;
    }

    logger.debug(`Init connection ${state.socketId}`, message);

    state.processingData = true;

    if (!message.origin) {
        logger.error('Empty origin');
        socket.destroy();
        return;
    }
    if (!message.pid) {
        logger.error('Empty pid');
        socket.destroy();
        return;
    }

    const extensionName = ExtensionOrigins[message.origin] || 'unknown';
    const isSafari = message.origin === ExtensionIds.Origins.KeeWebConnectSafari;
    let appName;

    if (isSafari) {
        appName = 'Safari';
    } else {
        if (!message.ppid) {
            logger.error('Empty ppid');
            socket.destroy();
            return;
        }

        let parentProcessInfo;
        try {
            try {
                parentProcessInfo = await getProcessInfo(message.ppid);
            } catch (e) {
                logger.error(`Cannot get info for PID ${message.ppid}: ${e}`);
                throw e;
            }

            if (process.platform === 'win32' && parentProcessInfo.appName === 'cmd') {
                try {
                    parentProcessInfo = await getProcessInfo(parentProcessInfo.ppid);
                } catch (e) {
                    logger.error(
                        `Cannot get info for PID ${parentProcessInfo.ppid}: ${e}, assuming cmd is the launcher`
                    );
                }
            }
        } catch (e) {
            logger.warn('Cannot get process info, assuming the connection is not identified');
        }

        appName = parentProcessInfo
            ? AppNames[parentProcessInfo.appName] ?? parentProcessInfo.appName
            : 'Unidentified browser';
        appName = appName[0].toUpperCase() + appName.substr(1);
    }

    state.active = true;
    state.appName = appName;
    state.extensionName = extensionName;
    state.pid = message.pid;
    state.ppid = message.ppid;
    state.supportsNotifications = !isSafari;
    state.processingData = false;

    logger.info(
        `Socket ${state.socketId} activated for ` +
            `app: "${state.appName}", ` +
            `extension: "${state.extensionName}", ` +
            `pid: ${state.pid}, ` +
            `ppid: ${state.ppid}`
    );

    sendToRenderer('browserExtensionSocketConnected', state.socketId, {
        connectionId: state.socketId,
        appName: state.appName,
        extensionName: state.extensionName,
        pid: state.pid,
        supportsNotifications: state.supportsNotifications
    });

    processPendingSocketData(socket);
}

function sendResultToSocket(socketId, result) {
    const socket = connectedSockets.get(socketId);
    if (socket) {
        sendMessageToSocket(socket, result);
        const state = connectedSocketState.get(socket);
        if (state.processingData) {
            state.processingData = false;
            processPendingSocketData(socket);
        }
    }
}

function sendEventToAllSockets(data) {
    for (const socket of connectedSockets.values()) {
        const state = connectedSocketState.get(socket);
        if (state?.active && state?.supportsNotifications) {
            sendMessageToSocket(socket, data);
        }
    }
}

function sendMessageToSocket(socket, message) {
    const state = connectedSocketState.get(socket);
    if (!state) {
        logger.warn('Ignoring a socket message without connection state');
        return;
    }
    if (!state.active) {
        logger.warn(`Ignoring a message to inactive socket ${state.socketId}`);
        return;
    }

    logger.debug(`KeeWeb -> Extension[${state.socketId}]`, message);

    const responseData = Buffer.from(JSON.stringify(message));
    const lengthBuf = Buffer.from(new Uint32Array([responseData.byteLength]).buffer);
    const lengthBytes = Buffer.from(lengthBuf);
    const data = Buffer.concat([lengthBytes, responseData]);

    try {
        socket.write(data);
    } catch (e) {
        logger.error(`Error writing to socket ${state.socketId}`, e);
    }
}

function sendToRenderer(event, socketId, data) {
    app.getMainWindow().webContents.send(event, socketId, data);
}
