import assert from 'node:assert';
import BoxError from './boxerror.js';
import crypto from 'node:crypto';
import database from './database.js';
import hat from './hat.js';
import ipaddr from './ipaddr.js';
import safe from 'safetydance';

const SCOPES = ['*'];//, 'apps', 'domains'];


const TOKENS_FIELDS = [ 'id', 'accessToken', 'identifier', 'clientId', 'scopeJson', 'expires', 'name', 'lastUsedTime', 'allowedIpRanges' ].join(',');

const gParsedRangesCache = new Map(); // indexed by token.id

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

    result.scope = safe.JSON.parse(result.scopeJson) || {};
    delete result.scopeJson;

    return result;
}

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

    if (name.length > 64) return new BoxError(BoxError.BAD_FIELD, 'name too long');

    return null;
}

function validateScope(scope) {
    assert.strictEqual(typeof scope, 'object');

    for (const key in scope) {
        if (SCOPES.indexOf(key) === -1) return new BoxError(BoxError.BAD_FIELD, `Unkown token scope ${key}. Valid scopes are ${SCOPES.join(',')}`);
        if (scope[key] !== 'rw' && scope[key] !== 'r') return new BoxError(BoxError.BAD_FIELD, `Unkown token scope value ${scope[key]} for ${key}. Valid values are 'rw' or 'r'`);
    }

    return null;
}

function hasScope(token, method, path) {
    assert.strictEqual(typeof token, 'object');
    assert.strictEqual(typeof method, 'string');
    assert.strictEqual(typeof path, 'string');

    // TODO path checking in the future
    const matchAll = token.scope['*'];
    if (matchAll === 'rw') {
        return true;
    } else if (matchAll === 'r' && (method === 'GET' || method === 'HEAD' || method === 'OPTIONS')) {
        return true;
    } else {
        return false;
    }
}

function parseIpRanges(ipRanges) {
    assert.strictEqual(typeof ipRanges, 'string');

    if (ipRanges.length === 0) return ['0.0.0.0/0', '::/0'];

    const result = [];
    for (const line of ipRanges.split('\n')) {
        if (!line || line.startsWith('#')) continue;
        // each line can have comma separated list. this complexity is because we changed the UI to take a line input instead of textarea
        for (const entry of line.split(',')) {
            const rangeOrIP = entry.trim();
            if (!ipaddr.isValid(rangeOrIP) && !ipaddr.isValidCIDR(rangeOrIP)) throw new BoxError(BoxError.BAD_FIELD, `'${rangeOrIP}' is not a valid IP or range`);
            result.push(rangeOrIP);
        }
    }

    return result;
}

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

    const { clientId, identifier, expires, allowedIpRanges } = token;
    const name = token.name || '';
    const scope = token.scope || { '*': 'rw' };

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

    error = validateScope(scope);
    if (error) throw error;

    parseIpRanges(allowedIpRanges); // validate

    const id = 'tid-' + crypto.randomUUID();
    const accessToken = token.accessToken || hat(8 * 32);

    await database.query('INSERT INTO tokens (id, accessToken, identifier, clientId, expires, scopeJson, name, allowedIpRanges) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [ id, accessToken, identifier, clientId, expires, JSON.stringify(scope), name, allowedIpRanges ]);

    return { id, accessToken, scope, clientId, identifier, expires, name, allowedIpRanges };
}

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

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

    return postProcess(result[0]);
}

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

    const result = await database.query('DELETE FROM tokens WHERE id = ?', [ id ]);
    if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'Token not found');
}

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

    const results = await database.query(`SELECT ${TOKENS_FIELDS} FROM tokens WHERE identifier = ?`, [ userId ]);
    results.forEach(postProcess);

    return results;
}

async function getByAccessToken(accessToken) {
    assert.strictEqual(typeof accessToken, 'string');

    const result = await database.query(`SELECT ${TOKENS_FIELDS} FROM tokens WHERE accessToken = ? AND expires > ?`, [ accessToken, Date.now() ]);
    if (result.length === 0) return null;
    return postProcess(result[0]);
}

async function delByAccessToken(accessToken) {
    assert.strictEqual(typeof accessToken, 'string');

    const result = await database.query('DELETE FROM tokens WHERE accessToken = ?', [ accessToken ]);
    if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'Token not found');
}

async function delExpired() {
    const result = await database.query('DELETE FROM tokens WHERE expires <= ?', [ Date.now() ]);
    return result.affectedRows;
}

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

    const result = await database.query('DELETE FROM tokens WHERE identifier=? AND clientId=?', [ userId, type ]);
    return result.affectedRows;
}

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

    const args = [ ];
    const fields = [ ];
    for (const k in values) {
        fields.push(k + ' = ?');
        args.push(values[k]);
    }
    args.push(id);

    const result = await database.query(`UPDATE tokens SET ${fields.join(', ')} WHERE id = ?`, args);
    if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'Token not found');
}

function isIpAllowedSync(token, ip) {
    assert.strictEqual(typeof token, 'object');
    assert.strictEqual(typeof ip, 'string');

    let allowedIpRanges = gParsedRangesCache.get(token.id); // returns undefined if not found
    if (!allowedIpRanges) {
        allowedIpRanges = parseIpRanges(token.allowedIpRanges || '');
        gParsedRangesCache.set(token.id, allowedIpRanges);
    }

    for (const ipOrRange of allowedIpRanges) {
        if (!ipOrRange.includes('/')) {
            if (ipaddr.isEqual(ipOrRange, ip)) return true;
        } else {
            if (ipaddr.includes(ipOrRange, ip)) return true;
        }
    }

    return false;
}

export default {
    add,
    get,
    update,
    del,
    delByAccessToken,
    delExpired,
    delByUserIdAndType,
    listByUserId,
    getByAccessToken,
    hasScope,
    isIpAllowedSync,
    SCOPES,
};
