import apps from './apps.js';
import assert from 'node:assert';
import BoxError from './boxerror.js';
import constants from './constants.js';
import crypto from 'node:crypto';
import database from './database.js';
import eventlog from './eventlog.js';
import safe from 'safetydance';

const GROUPS_FIELDS = [ 'id', 'name', 'source' ].join(',');

// keep this in sync with validateUsername
function validateName(name) {
    assert.strictEqual(typeof name, 'string');

    if (name.length < 1) return new BoxError(BoxError.BAD_FIELD, 'name must be atleast 1 char');
    if (name.length >= 200) return new BoxError(BoxError.BAD_FIELD, 'name too long');

    if (constants.RESERVED_NAMES.indexOf(name) !== -1) return new BoxError(BoxError.BAD_FIELD, 'name is reserved');

    // need to consider valid LDAP characters here (e.g '+' is reserved)
    if (/[^a-zA-Z0-9.-]/.test(name)) return new BoxError(BoxError.BAD_FIELD, 'name can only contain alphanumerals, hyphen and dot');

    return null;
}

function validateSource(source) {
    assert.strictEqual(typeof source, 'string');

    if (source !== '' && source !== 'ldap') return new BoxError(BoxError.BAD_FIELD, 'source must be "" or "ldap"');

    return null;
}

async function add(group, auditSource) {
    assert.strictEqual(typeof group, 'object');
    assert(auditSource && typeof auditSource === 'object');

    let { name, source } = group;

    name = name.toLowerCase(); // we store names in lowercase
    source = source || '';

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

    error = validateSource(source);
    if (error) throw error;

    const id = `gid-${crypto.randomUUID()}`;

    [error] = await safe(database.query('INSERT INTO userGroups (id, name, source) VALUES (?, ?, ?)', [ id, name, source ]));
    if (error && error.sqlCode === 'ER_DUP_ENTRY') throw new BoxError(BoxError.ALREADY_EXISTS, error);
    if (error) throw error;

    await eventlog.add(eventlog.ACTION_GROUP_ADD, auditSource, { id, name, source });

    return { id, name, source, appIds: [] };
}

async function del(group, auditSource) {
    assert.strictEqual(typeof group, 'object');
    assert(auditSource && typeof auditSource === 'object');

    const arSearch = `JSON_SEARCH(accessRestrictionJson, 'one', ?, NULL, '$.groups')`;
    const opSearch = `JSON_SEARCH(operatorsJson, 'one', ?, NULL, '$.groups')`;

    const queries = [
        { query: `UPDATE apps SET accessRestrictionJson=JSON_REMOVE(accessRestrictionJson, REPLACE(${arSearch}, '"', '')) WHERE ${arSearch} IS NOT NULL`, args: [ group.id, group.id ] },
        { query: `UPDATE apps SET operatorsJson=JSON_REMOVE(operatorsJson, REPLACE(${opSearch}, '"', '')) WHERE ${opSearch} IS NOT NULL`, args: [ group.id, group.id ] },
        { query: 'DELETE FROM groupMembers WHERE groupId = ?', args: [ group.id ] },
        { query: 'DELETE FROM userGroups WHERE id = ?', args: [ group.id ] }, // keep this the last query as we check affectedRows below
    ];

    const result = await database.transaction(queries);
    if (result[queries.length-1].affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'Group not found');

    await eventlog.add(eventlog.ACTION_GROUP_REMOVE, auditSource, { group });
}

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

    const results = await database.query('SELECT id FROM apps WHERE JSON_CONTAINS(accessRestrictionJson, ?, "$.groups")', [ `"${group.id}"` ]);
    group.appIds = results.map(r => r.id);
}

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

    const result = await database.query(`SELECT ${GROUPS_FIELDS} FROM userGroups WHERE id = ? ORDER BY name`, [ id ]);
    if (result.length === 0) return null;

    await postProcess(result[0]);

    return result[0];
}

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

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

    await postProcess(result[0]);

    return result[0];
}

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

    const results = await database.query('SELECT ' + GROUPS_FIELDS + ',GROUP_CONCAT(groupMembers.userId) AS userIds ' +
        ' FROM userGroups LEFT OUTER JOIN groupMembers ON userGroups.id = groupMembers.groupId ' +
        ' WHERE userGroups.id = ? ' +
        ' GROUP BY userGroups.id', [ id ]);

    if (results.length === 0) return null;

    const result = results[0];
    result.userIds = result.userIds ? result.userIds.split(',') : [ ];

    await postProcess(result);

    return result;
}

async function list() {
    const results = await database.query('SELECT ' + GROUPS_FIELDS + ' FROM userGroups ORDER BY name');

    for (const r of results) {
        await postProcess(r);
    }

    return results;
}

async function listWithMembers() {
    const results = await database.query('SELECT ' + GROUPS_FIELDS + ',GROUP_CONCAT(groupMembers.userId) AS userIds ' +
        ' FROM userGroups LEFT OUTER JOIN groupMembers ON userGroups.id = groupMembers.groupId ' +
        ' GROUP BY userGroups.id ORDER BY name');

    results.forEach(function (result) { result.userIds = result.userIds ? result.userIds.split(',') : []; });

    for (const r of results) {
        await postProcess(r);
    }

    return results;
}

async function getMemberIds(groupId) {
    assert.strictEqual(typeof groupId, 'string');

    const result = await database.query('SELECT userId FROM groupMembers WHERE groupId=?', [ groupId ]);

    return result.map(function (r) { return r.userId; });
}

async function getMembership(userId) {
    assert.strictEqual(typeof userId, 'string');

    const result = await database.query('SELECT groupId FROM groupMembers WHERE userId=? ORDER BY groupId', [ userId ]);

    return result.map(function (r) { return r.groupId; });
}

async function setLocalMembership(user, localGroupIds) {
    assert.strictEqual(typeof user, 'object'); // can be local or external
    assert(Array.isArray(localGroupIds));

    // ensure groups are actually local
    for (const groupId of localGroupIds) {
        const group = await get(groupId);
        if (!group) throw new BoxError(BoxError.NOT_FOUND, `Group ${groupId} not found`);
        if (group.source) throw new BoxError(BoxError.BAD_STATE, 'Cannot set members of external group');
    }

    const queries = [];
    // a remote user may already be part of some external groups. do not clear those because remote groups are non-editable
    queries.push({ query: 'DELETE FROM groupMembers WHERE userId = ? AND groupId IN (SELECT id FROM userGroups WHERE source = ?)', args: [ user.id, '' ] });
    for (const gid of localGroupIds) {
        queries.push({ query: 'INSERT INTO groupMembers (groupId, userId) VALUES (? , ?)', args: [ gid, user.id ] });
    }

    const [error] = await safe(database.transaction(queries));
    if (error && error.sqlCode === 'ER_NO_REFERENCED_ROW_2') throw new BoxError(BoxError.NOT_FOUND, 'Group not found');
    if (error && error.sqlCode === 'ER_DUP_ENTRY') throw new BoxError(BoxError.CONFLICT, 'Already member');
    if (error) throw error;
}

async function setMembers(group, userIds, options, auditSource) {
    assert.strictEqual(typeof group, 'object');
    assert(Array.isArray(userIds));
    assert.strictEqual(typeof options, 'object');
    assert(auditSource && typeof auditSource === 'object');

    if (!options.skipSourceCheck && group.source === 'ldap') throw new BoxError(BoxError.BAD_STATE, 'Cannot set members of external group');

    const queries = [];
    queries.push({ query: 'DELETE FROM groupMembers WHERE groupId = ?', args: [ group.id ] });
    for (let i = 0; i < userIds.length; i++) {
        queries.push({ query: 'INSERT INTO groupMembers (groupId, userId) VALUES (?, ?)', args: [ group.id, userIds[i] ] });
    }

    const [error] = await safe(database.transaction(queries));
    if (error && error.sqlCode === 'ER_NO_REFERENCED_ROW_2') throw new BoxError(BoxError.NOT_FOUND, 'Group not found');
    if (error && error.sqlCode === 'ER_DUP_ENTRY') throw new BoxError(BoxError.CONFLICT, 'Duplicate member in list');
    if (error) throw error;

    await eventlog.add(eventlog.ACTION_GROUP_MEMBERSHIP, auditSource, { group, userIds });
}

async function isMember(groupId, userId) {
    assert.strictEqual(typeof groupId, 'string');
    assert.strictEqual(typeof userId, 'string');

    const result = await database.query('SELECT 1 FROM groupMembers WHERE groupId=? AND userId=?', [ groupId, userId ]);
    return result.length !== 0;
}

async function update(id, data) {
    assert.strictEqual(typeof id, 'string');
    assert(data && typeof data === 'object');

    if ('name' in data) {
        assert.strictEqual(typeof data.name, 'string');
        data.name = data.name.toLowerCase();
        const error = validateName(data.name);
        if (error) throw error;
    }

    if ('source' in data) {
        assert.strictEqual(typeof data.source, 'string');
        const error = validateSource(data.source);
        if (error) throw error;
    }

    const args = [];
    const fields = [];
    for (const k in data) {
        if (k === 'name' || k === 'source') {
            fields.push(k + ' = ?');
            args.push(data[k]);
        }
    }
    args.push(id);

    const [updateError, result] = await safe(database.query('UPDATE userGroups SET ' + fields.join(', ') + ' WHERE id = ?', args));
    if (updateError && updateError.code === 'ER_DUP_ENTRY' && updateError.sqlMessage.indexOf('userGroups_name') !== -1) throw new BoxError(BoxError.ALREADY_EXISTS, 'name already exists');
    if (updateError) throw updateError;
    if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'Group not found');
}

async function setName(group, name, auditSource) {
    assert.strictEqual(typeof group, 'object');
    assert.strictEqual(typeof name, 'string');
    assert(auditSource && typeof auditSource === 'object');

    if (group.source === 'ldap') throw new BoxError(BoxError.BAD_STATE, 'Cannot set name of external group');

    await update(group.id, { name });

    await eventlog.add(eventlog.ACTION_GROUP_UPDATE, auditSource, { oldName: group.name, group });
}

async function resetSources() {
    await database.query('UPDATE userGroups SET source = ?', [ '' ]);
}

async function setAllowedApps(group, appIds, auditSource) {
    assert.strictEqual(typeof group, 'object');
    assert(Array.isArray(appIds));
    assert.strictEqual(typeof auditSource, 'object');

    const result = await apps.list();
    for (const app of result) {
        const accessRestriction = app.accessRestriction || { users: [], groups: [] };
        if (appIds.includes(app.id)) { // add
            if (accessRestriction.groups.includes(group.id)) continue;
            accessRestriction.groups.push(group.id);
        } else { // remove
            if (!accessRestriction.groups?.includes(group.id)) continue;
            accessRestriction.groups = accessRestriction.groups.filter(gid => gid !== group.id);
        }
        await apps.update(app.id, { accessRestriction });
    }
}

const _getMembership = getMembership;

export default {
    add,
    del,
    get,
    getByName,
    update,
    setName,
    getWithMembers,
    list,
    listWithMembers,
    getMemberIds,
    setMembers,
    isMember,
    setLocalMembership,
    resetSources,
    setAllowedApps,
    _getMembership,
};
