import assert from 'node:assert';
import BoxError from './boxerror.js';
import crypto from 'node:crypto';
import database from './database.js';
import logger from './logger.js';
import eventlog from './eventlog.js';
import mounts from './mounts.js';
import path from 'node:path';
import paths from './paths.js';
import safe from 'safetydance';
import services from './services.js';

const { log } = logger('volumes');

const VOLUMES_FIELDS = [ 'id', 'name', 'hostPath', 'creationTime', 'mountType', 'mountOptionsJson' ].join(',');

function postProcess(result) {
    assert.strictEqual(typeof result, 'object');

    result.mountOptions = safe.JSON.parse(result.mountOptionsJson) || {};
    delete result.mountOptionsJson;

    return result;
}

function validateName(name) {
    assert.strictEqual(typeof name, 'string');

    if (!/^[-\w^&'@{}[\],$=!#().%+~ ]+$/.test(name)) return new BoxError(BoxError.BAD_FIELD, 'Invalid name');

    return null;
}

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

    if (path.normalize(hostPath) !== hostPath) return new BoxError(BoxError.BAD_FIELD, 'hostPath must contain a normalized path');
    if (!path.isAbsolute(hostPath)) return new BoxError(BoxError.BAD_FIELD, 'hostPath must be an absolute path');
    // otherwise, we could follow some symlink to mount paths outside the allowed paths
    if (safe.fs.realpathSync(hostPath) !== hostPath) return new BoxError(BoxError.BAD_FIELD, 'hostPath must be a realpath without symlinks');

    if (hostPath === '/') return new BoxError(BoxError.BAD_FIELD, 'hostPath cannot be /');

    const allowedPaths = [ '/mnt', '/media', '/srv', '/opt' ];
    if (!allowedPaths.some(p => hostPath.startsWith(p + '/'))) return new BoxError(BoxError.BAD_FIELD, 'hostPath must be under /mnt, /media, /opt or /srv');

    const reservedPaths = [ `${paths.VOLUMES_MOUNT_DIR}` ];
    if (reservedPaths.some(p => hostPath === p || hostPath.startsWith(p + '/'))) return new BoxError(BoxError.BAD_FIELD, 'hostPath is reserved');

    const stat = safe.fs.lstatSync(hostPath);
    if (!stat) return new BoxError(BoxError.BAD_FIELD, 'hostPath does not exist. Please create it on the server first');
    if (!stat.isDirectory()) return new BoxError(BoxError.BAD_FIELD, 'hostPath is not a directory');

    return null;
}

async function add(volume, auditSource) {
    assert.strictEqual(typeof volume, 'object');
    assert.strictEqual(typeof auditSource, 'object');

    const {name, mountType, mountOptions} = volume;

    let error = validateName(name);
    if (error) throw error;

    error = await mounts.validateMountOptions(mountType, mountOptions);
    if (error) throw error;

    const id = crypto.randomUUID().replace(/-/g, ''); // to make systemd mount file names more readable

    let hostPath;
    if (mountType === mounts.MOUNT_TYPE_MOUNTPOINT || mountType === mounts.MOUNT_TYPE_FILESYSTEM) {
        error = validateHostPath(mountOptions.hostPath);
        if (error) throw error;
        hostPath = mountOptions.hostPath;
    } else {
        hostPath = path.join(paths.VOLUMES_MOUNT_DIR, id);
        const mount = { description: name, hostPath, mountType, mountOptions };
        await mounts.tryAddMount(mount, { timeout: 10 }); // 10 seconds
    }

    [error] = await safe(database.query('INSERT INTO volumes (id, name, hostPath, mountType, mountOptionsJson) VALUES (?, ?, ?, ?, ?)', [ id, name, hostPath, mountType, JSON.stringify(mountOptions) ]));
    if (error && error.sqlCode === 'ER_DUP_ENTRY' && error.sqlMessage.indexOf('name') !== -1) throw new BoxError(BoxError.ALREADY_EXISTS, 'name already exists');
    if (error && error.sqlCode === 'ER_DUP_ENTRY' && error.sqlMessage.indexOf('hostPath') !== -1) throw new BoxError(BoxError.ALREADY_EXISTS, 'directory or mountpoint already in use');
    if (error && error.sqlCode === 'ER_DUP_ENTRY' && error.sqlMessage.indexOf('PRIMARY') !== -1) throw new BoxError(BoxError.ALREADY_EXISTS, 'id already exists');
    if (error) throw error;

    await eventlog.add(eventlog.ACTION_VOLUME_ADD, auditSource, { id, name, hostPath });
    // in theory, we only need to do this mountpoint volumes. but for some reason a restart is required to detect new "mounts"
    safe(services.rebuildService('sftp', auditSource), { debug: log });

    return id;
}

async function remount(volume, auditSource) {
    assert.strictEqual(typeof volume, 'object');
    assert.strictEqual(typeof auditSource, 'object');

    await eventlog.add(eventlog.ACTION_VOLUME_REMOUNT, auditSource, { ...volume });

    if (!mounts.isManagedProvider(volume.mountType)) throw new BoxError(BoxError.NOT_SUPPORTED, 'Volume does not support remount');

    await mounts.remount(volume.hostPath);
}

async function getStatus(volume) {
    assert.strictEqual(typeof volume, 'object');

    return await mounts.getStatus(volume.mountType, volume.hostPath); // { state, message }
}

async function get(id) {
    assert.strictEqual(typeof id, 'string');

    const result = await database.query(`SELECT ${VOLUMES_FIELDS} FROM volumes WHERE id=?`, [ id ]);
    if (result.length === 0) return null;

    return postProcess(result[0]);
}

function removePrivateFields(volume) {
    const newVolume = Object.assign({}, volume);

    if (newVolume.mountType === mounts.MOUNT_TYPE_SSHFS) {
        delete newVolume.mountOptions.privateKey;
    } else if (newVolume.mountType === mounts.MOUNT_TYPE_CIFS) {
        delete newVolume.mountOptions.password;
    }

    return newVolume;
}

function injectPrivateFields(newVolume, currentVolume) {
    if (currentVolume.mountType === mounts.MOUNT_TYPE_SSHFS) {
        if (!Object.hasOwn(newVolume.mountOptions, 'privateKey')) newVolume.mountOptions.privateKey = currentVolume.mountOptions.privateKey;
    } else if (currentVolume.mountType === mounts.MOUNT_TYPE_CIFS) {
        if (!Object.hasOwn(newVolume.mountOptions, 'password')) newVolume.mountOptions.password = currentVolume.mountOptions.password;
    }
}

// only network mounts can be updated here through mountOptions to update logon information
async function update(volume, mountOptions, auditSource) {
    assert.strictEqual(typeof volume, 'object');
    assert.strictEqual(typeof mountOptions, 'object');
    assert.strictEqual(typeof auditSource, 'object');

    const { name, mountType } = volume;

    if (mountType !== mounts.MOUNT_TYPE_CIFS && mountType !== mounts.MOUNT_TYPE_SSHFS && mountType !== mounts.MOUNT_TYPE_NFS) throw new BoxError(BoxError.BAD_FIELD, 'Only network mounts can be updated');

    injectPrivateFields({ name, mountType, mountOptions }, volume);

    const error = await mounts.validateMountOptions(mountType, mountOptions);
    if (error) throw error;

    const hostPath = path.join(paths.VOLUMES_MOUNT_DIR, volume.id);
    const mount = { description: name, hostPath, mountType, mountOptions };

    // first try to mount at /mnt/volumes/<volumeId>-validation
    const testMount = Object.assign({}, mount, { hostPath: `${hostPath}-validation` });
    await mounts.tryAddMount(testMount, { timeout: 10 }); // 10 seconds
    await mounts.removeMount(testMount);

    // update the mount
    await mounts.tryAddMount(mount, { timeout: 10 }); // 10 seconds
    await database.query('UPDATE volumes SET hostPath=?,mountOptionsJson=? WHERE id=?', [ hostPath, JSON.stringify(mountOptions), volume.id ]);
    await eventlog.add(eventlog.ACTION_VOLUME_UPDATE, auditSource, { id: volume.id, name, hostPath });
    // in theory, we only need to do this mountpoint volumes. but for some reason a restart is required to detect new "mounts"
    safe(services.rebuildService('sftp', auditSource), { debug: log });
}

async function list() {
    const results = await database.query(`SELECT ${VOLUMES_FIELDS} FROM volumes ORDER BY name`);
    results.forEach(postProcess);
    return results;
}

async function del(volume, auditSource) {
    assert.strictEqual(typeof volume, 'object');
    assert.strictEqual(typeof auditSource, 'object');

    const [error, result] = await safe(database.query('DELETE FROM volumes WHERE id=?', [ volume.id ]));
    if (error && error.sqlCode === 'ER_ROW_IS_REFERENCED_2') throw new BoxError(BoxError.CONFLICT, 'Volume is in use');
    if (error) throw error;
    if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'Volume not found');

    await eventlog.add(eventlog.ACTION_VOLUME_REMOVE, auditSource, { ...volume });

    if (volume.mountType === mounts.MOUNT_TYPE_MOUNTPOINT || volume.mountType === mounts.MOUNT_TYPE_FILESYSTEM) {
        safe(services.rebuildService('sftp', auditSource), { debug: log });
    } else {
        await safe(mounts.removeMount(volume));
    }
}

async function mountAll() {
    log('mountAll: mouting all volumes');

    for (const volume of await list()) {
        if (volume.mountType === mounts.MOUNT_TYPE_MOUNTPOINT || volume.mountType === mounts.MOUNT_TYPE_FILESYSTEM) continue;
        const mount = { description: volume.name, ...volume };
        await mounts.tryAddMount(mount, { timeout: 10, skipCleanup: true }); // have to wait to avoid race with apptask
    }
}

const _validateHostPath = validateHostPath;

export default {
    add,
    get,
    update,
    del,
    list,
    remount,
    getStatus,
    removePrivateFields,
    mountAll,
    _validateHostPath,
};
