import assert from 'node:assert';
import BoxError from './boxerror.js';
import constants from './constants.js';
import logger from './logger.js';
import ejs from 'ejs';
import fs from 'node:fs';
import path from 'node:path';
import paths from './paths.js';
import safe from 'safetydance';
import shellModule from './shell.js';

const { log } = logger('mounts');
const shell = shellModule('mounts');

const MOUNT_TYPE_FILESYSTEM = 'filesystem';
const MOUNT_TYPE_MOUNTPOINT = 'mountpoint';
const MOUNT_TYPE_CIFS = 'cifs';
const MOUNT_TYPE_NFS = 'nfs';
const MOUNT_TYPE_SSHFS = 'sshfs';
const MOUNT_TYPE_EXT4 = 'ext4'; // raw disk path
const MOUNT_TYPE_XFS = 'xfs'; // raw disk path
const MOUNT_TYPE_DISK = 'disk'; // this provides a selector of block devices


const ADD_MOUNT_CMD = path.join(import.meta.dirname, 'scripts/addmount.sh');
const RM_MOUNT_CMD = path.join(import.meta.dirname, 'scripts/rmmount.sh');
const REMOUNT_MOUNT_CMD = path.join(import.meta.dirname, 'scripts/remountmount.sh');
const SYSTEMD_MOUNT_EJS = fs.readFileSync(path.join(import.meta.dirname, 'systemd-mount.ejs'), { encoding: 'utf8' });

// https://man7.org/linux/man-pages/man8/mount.8.html
async function validateMountOptions(type, options) {
    assert.strictEqual(typeof type, 'string');
    assert.strictEqual(typeof options, 'object');

    switch (type) {
    case MOUNT_TYPE_FILESYSTEM:
    case MOUNT_TYPE_MOUNTPOINT:
        if (typeof options.hostPath !== 'string') return new BoxError(BoxError.BAD_FIELD, 'hostPath is not a string');
        return null;
    case MOUNT_TYPE_CIFS:
        if (typeof options.username !== 'string') return new BoxError(BoxError.BAD_FIELD, 'username is not a string');
        if (typeof options.password !== 'string') return new BoxError(BoxError.BAD_FIELD, 'password is not a string');
        if (typeof options.host !== 'string') return new BoxError(BoxError.BAD_FIELD, 'host is not a string');
        if (typeof options.remoteDir !== 'string') return new BoxError(BoxError.BAD_FIELD, 'remoteDir is not a string');
        if ('seal' in options && typeof options.seal !== 'boolean') return new BoxError(BoxError.BAD_FIELD, 'seal is not a boolean');
        return null;
    case MOUNT_TYPE_NFS:
        if (typeof options.host !== 'string') return new BoxError(BoxError.BAD_FIELD, 'host is not a string');
        if (typeof options.remoteDir !== 'string') return new BoxError(BoxError.BAD_FIELD, 'remoteDir is not a string');
        return null;
    case MOUNT_TYPE_SSHFS:
        if (typeof options.user !== 'string') return new BoxError(BoxError.BAD_FIELD, 'user is not a string');
        if (typeof options.privateKey !== 'string') return new BoxError(BoxError.BAD_FIELD, 'privateKey is not a string');
        if (typeof options.port !== 'number') return new BoxError(BoxError.BAD_FIELD, 'port is not a number');
        if (typeof options.host !== 'string') return new BoxError(BoxError.BAD_FIELD, 'host is not a string');
        if (typeof options.remoteDir !== 'string') return new BoxError(BoxError.BAD_FIELD, 'remoteDir is not a string');
        return null;
    case MOUNT_TYPE_EXT4:
    case MOUNT_TYPE_XFS:
    case MOUNT_TYPE_DISK: {
        if (typeof options.diskPath !== 'string') return new BoxError(BoxError.BAD_FIELD, 'diskPath is not a string');
        const [error, output] = await safe(shell.spawn('lsblk', ['--paths', '--json', '--list', '--fs', options.diskPath ], { encoding: 'utf8' }));
        if (error) return new BoxError(BoxError.BAD_FIELD, `Bad disk path: ${error.message}`);
        const info = safe.JSON.parse(output);
        if (!info) return new BoxError(BoxError.BAD_FIELD, `Bad disk path: ${safe.error.message}`);

        // see system.js getBlockDevices()
        const mountpoints = Array.isArray(info.blockdevices[0].mountpoints)
            ? (info.blockdevices[0].mountpoints[0] === null ? [] : info.blockdevices[0].mountpoints) // convert [ null ] to []
            : (info.blockdevices[0].mountpoint ? [ info.blockdevices[0].mountpoint ] : []); // old lsblk only exposed one .mountpoint

        for (const mountpoint of mountpoints) {
            if (mountpoint === null) break; // [ null ] means not mounted anywhere
            if (mountpoint === '/' || mountpoint.startsWith('/home') || mountpoint.startsWith('/boot')) return new BoxError(BoxError.BAD_FIELD, 'Disk is mounted in a protected location');
        }
        return null;
    }
    default:
        return new BoxError(BoxError.BAD_FIELD, 'Bad mount type');
    }
}

// managed providers are those for which we setup systemd mount file under /mnt/volumes
function isManagedProvider(provider) {
    switch (provider) {
    case MOUNT_TYPE_SSHFS:
    case MOUNT_TYPE_CIFS:
    case MOUNT_TYPE_NFS:
    case MOUNT_TYPE_EXT4:
    case MOUNT_TYPE_XFS:
    case MOUNT_TYPE_DISK:
        return true;
    default:
        return false;
    }
}

// https://www.man7.org/linux/man-pages/man8/mount.8.html for various mount option flags
// nfs - no_root_squash is mode on server to map all root to 'nobody' user. all_squash does this for all users (making it like ftp)
// sshfs - supports users/permissions
// cifs - does not support permissions
async function renderMountFile(mount) {
    assert.strictEqual(typeof mount, 'object');

    const { description, hostPath, mountType, mountOptions } = mount;

    let options, what, type, dependsOn;
    switch (mountType) {
    case MOUNT_TYPE_CIFS: {
        const out = await shell.spawn('systemd-escape', [ '-p', hostPath ], { encoding: 'utf8' }); // this ensures uniqueness of creds file
        const credentialsFilePath = path.join(paths.CIFS_CREDENTIALS_DIR, `${out.trim()}.cred`);
        if (!safe.fs.writeFileSync(credentialsFilePath, `username=${mountOptions.username}\npassword=${mountOptions.password}\n`, { mode: 0o600 })) throw new BoxError(BoxError.FS_ERROR, `Could not write credentials file: ${safe.error.message}`);

        type = 'cifs';
        what = `//${mountOptions.host}` + path.join('/', mountOptions.remoteDir);
        options = `credentials=${credentialsFilePath},rw,${mountOptions.seal ? 'seal,' : ''}iocharset=utf8,file_mode=0666,dir_mode=0777,uid=yellowtent,gid=yellowtent`;
        dependsOn = 'network-online.target';
        break;
    }
    case MOUNT_TYPE_NFS:
        type = 'nfs';
        what = `${mountOptions.host}:${mountOptions.remoteDir}`;
        options = 'noauto'; // noauto means it is not a blocker for local-fs.target. _netdev is implicit. rw,hard,tcp,rsize=8192,wsize=8192,timeo=14
        dependsOn = 'network-online.target';
        break;
    case MOUNT_TYPE_EXT4:
        type = 'ext4';
        what = mountOptions.diskPath; // like /dev/disk/by-uuid/uuid or /dev/disk/by-id/scsi-id
        options = 'discard,defaults,noatime';
        break;
    case MOUNT_TYPE_XFS:
        type = 'xfs';
        what = mountOptions.diskPath; // like /dev/disk/by-uuid/uuid or /dev/disk/by-id/scsi-id
        options = 'discard,defaults,noatime,pquota';
        break;
    case MOUNT_TYPE_DISK:
        type = 'auto';
        what = mountOptions.diskPath; // like /dev/disk/by-uuid/uuid or /dev/disk/by-id/scsi-id
        options = 'discard,defaults,noatime';
        break;
    case MOUNT_TYPE_SSHFS: {
        const keyFilePath = path.join(paths.SSHFS_KEYS_DIR, `identity_file_${path.basename(hostPath)}`);
        if (!safe.fs.writeFileSync(keyFilePath, `${mount.mountOptions.privateKey}\n`, { mode: 0o600 })) throw new BoxError(BoxError.FS_ERROR, `Could not write private key: ${safe.error.message}`);

        type = 'fuse.sshfs';
        what = `${mountOptions.user}@${mountOptions.host}:${mountOptions.remoteDir}`;
        options = `allow_other,port=${mountOptions.port},IdentityFile=${keyFilePath},StrictHostKeyChecking=no,reconnect`; // allow_other means non-root users can access it
        dependsOn = 'network-online.target';
        break;
    }
    case MOUNT_TYPE_FILESYSTEM:
    case MOUNT_TYPE_MOUNTPOINT:
        return;
    }

    return ejs.render(SYSTEMD_MOUNT_EJS, { description, what, where: hostPath, options, type, dependsOn });
}

async function removeMount(mount) {
    assert.strictEqual(typeof mount, 'object');

    const { hostPath, mountType } = mount;

    if (constants.TEST) return;

    await safe(shell.sudo([ RM_MOUNT_CMD, hostPath ], {}), { debug: log }); // ignore any error

    if (mountType === MOUNT_TYPE_SSHFS) {
        const keyFilePath = path.join(paths.SSHFS_KEYS_DIR, `identity_file_${path.basename(hostPath)}`);
        safe.fs.unlinkSync(keyFilePath);
    } else if (mountType === MOUNT_TYPE_CIFS) {
        const out = await shell.spawn('systemd-escape', [ '-p', hostPath ], { encoding: 'utf8' });
        const credentialsFilePath = path.join(paths.CIFS_CREDENTIALS_DIR, `${out.trim()}.cred`);
        safe.fs.unlinkSync(credentialsFilePath);
    }
}

async function getStatus(mountType, hostPath) {
    assert.strictEqual(typeof mountType, 'string');
    assert.strictEqual(typeof hostPath, 'string');

    if (mountType === MOUNT_TYPE_FILESYSTEM) {
        const exists = safe.fs.existsSync(hostPath);
        return { state: exists ? 'active' : 'inactive', message: exists ? '' : `${hostPath} not found` };
    }

    const [error] = await safe(shell.spawn('mountpoint', [ '-q', '--', hostPath ], { timeout: 5000, encoding: 'utf8' }));
    const state = error ? 'inactive' : 'active';

    if (mountType === 'mountpoint') return { state, message: state === 'active' ? '' : 'Not mounted' };

    // we used to rely on "systemctl show -p ActiveState" output before but some mounts like sshfs.fuse show the status as "active" event though the mount commant failed (on ubuntu 18)
    let message = '';

    if (state !== 'active') { // find why it failed
        const unitName = await shell.spawn('systemd-escape', ['-p', '--suffix=mount', hostPath], { encoding: 'utf8' });
        const logsJson = await shell.spawn('journalctl', ['-u', unitName, '-n', '10', '--no-pager', '-o', 'json'], { encoding: 'utf8' });

        if (logsJson) {
            const lines = logsJson.trim().split('\n').map(l => JSON.parse(l)); // array of json
            let start = -1, end = -1; // start and end of error message block
            for (let idx = lines.length - 1; idx >= 0; idx--) { // reverse
                const line = lines[idx];
                const match = line['SYSLOG_IDENTIFIER'] === 'mount' || (line['_EXE'] && line['_EXE'].includes('mount')) || (line['_COMM'] && line['_COMM'].includes('mount'));
                if (match) {
                    if (end === -1) end = idx;
                    start = idx;
                    continue;
                }

                if (end !== -1) break; // no match and we already found a block
            }

            if (end !== -1) message = lines.slice(start, end+1).map(line => line['MESSAGE']).join('\n');
        }
        if (!message) message = `Could not determine mount failure reason. ${safe.error ? safe.error.message : ''}`;
    }

    return { state, message };
}

async function tryAddMount(mount, options) {
    assert.strictEqual(typeof mount, 'object'); // { description, hostPath, mountType, mountOptions }
    assert.strictEqual(typeof options, 'object'); // { timeout, skipCleanup }

    assert(isManagedProvider(mount.mountType));

    if (constants.TEST) return;

    const mountFileContents = await renderMountFile(mount);
    const [error] = await safe(shell.sudo([ ADD_MOUNT_CMD, mountFileContents, options.timeout ], {}));
    if (error && error.code === 2) throw new BoxError(BoxError.MOUNT_ERROR, 'Failed to unmount existing mount'); // at this point, the old mount config is still there

    if (options.skipCleanup) return;

    const status = await getStatus(mount.mountType, mount.hostPath);
    if (status.state !== 'active') {  // cleanup
        await removeMount(mount);
        throw new BoxError(BoxError.MOUNT_ERROR, `Failed to mount (${status.state}): ${status.message}`);
    }
}

async function remount(hostPath) {
    assert.strictEqual(typeof hostPath, 'string');

    if (constants.TEST) return;

    const [error] = await safe(shell.sudo([ REMOUNT_MOUNT_CMD, hostPath ], {}));
    if (error && error.code === 2) throw new BoxError(BoxError.MOUNT_ERROR, 'Failed to remount existing mount'); // at this point, the old mount config is still there
}

export default {
    isManagedProvider,
    tryAddMount,
    removeMount,
    validateMountOptions,
    getStatus,
    remount,
    MOUNT_TYPE_FILESYSTEM,
    MOUNT_TYPE_MOUNTPOINT,
    MOUNT_TYPE_CIFS,
    MOUNT_TYPE_NFS,
    MOUNT_TYPE_SSHFS,
    MOUNT_TYPE_EXT4,
    MOUNT_TYPE_XFS,
    MOUNT_TYPE_DISK,
};
