import assert from 'node:assert';
import apps from './apps.js';
import BoxError from './boxerror.js';
import crypto from 'node:crypto';
import database from './database.js';
import logger from './logger.js';
import jsdom from 'jsdom';
import safe from 'safetydance';
import superagent from '@cloudron/superagent';

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


const APPLINKS_FIELDS= [ 'id', 'accessRestrictionJson', 'creationTime', 'updateTime', 'ts', 'label', 'tagsJson', 'icon', 'upstreamUri' ].join(',');

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

    assert(result.tagsJson === null || typeof result.tagsJson === 'string');
    result.tags = safe.JSON.parse(result.tagsJson) || [];
    delete result.tagsJson;

    assert(result.accessRestrictionJson === null || typeof result.accessRestrictionJson === 'string');
    result.accessRestriction = safe.JSON.parse(result.accessRestrictionJson);
    if (result.accessRestriction && !result.accessRestriction.users) result.accessRestriction.users = [];
    delete result.accessRestrictionJson;

    result.ts = new Date(result.ts).getTime();

    result.icon = result.icon ? result.icon : null;

}

function validateUpstreamUri(upstreamUri) {
    assert.strictEqual(typeof upstreamUri, 'string');

    if (!upstreamUri) return new BoxError(BoxError.BAD_FIELD, 'upstreamUri cannot be empty');

    if (!upstreamUri.includes('://')) return new BoxError(BoxError.BAD_FIELD, 'upstreamUri has no schema');

    const uri = safe(() => new URL(upstreamUri));
    if (!uri) return new BoxError(BoxError.BAD_FIELD, `upstreamUri is invalid: ${safe.error.message}`);
    if (uri.protocol !== 'http:' && uri.protocol !== 'https:') return new BoxError(BoxError.BAD_FIELD, 'upstreamUri has an unsupported scheme');

    return null;
}

function validateAccessRestriction(accessRestriction) {
    assert.strictEqual(typeof accessRestriction, 'object');

    if (accessRestriction === null) return null;

    if (accessRestriction.users) {
        if (!Array.isArray(accessRestriction.users)) return new BoxError(BoxError.BAD_FIELD, 'users array property required');
        if (!accessRestriction.users.every(function (e) { return typeof e === 'string'; })) return new BoxError(BoxError.BAD_FIELD, 'All users have to be strings');
    }

    if (accessRestriction.groups) {
        if (!Array.isArray(accessRestriction.groups)) return new BoxError(BoxError.BAD_FIELD, 'groups array property required');
        if (!accessRestriction.groups.every(function (e) { return typeof e === 'string'; })) return new BoxError(BoxError.BAD_FIELD, 'All groups have to be strings');
    }

    // TODO: maybe validate if the users and groups actually exist
    return null;
}

async function list() {
    const results = await database.query(`SELECT ${APPLINKS_FIELDS} FROM applinks ORDER BY upstreamUri`);

    results.forEach(postProcess);

    return results;
}

async function listByUser(user) {
    assert.strictEqual(typeof user, 'object');

    const result = await list();
    return result.filter((link) => apps.canAccess(link, user));
}

async function detectMetaInfo(upstreamUri) {
    assert.strictEqual(typeof upstreamUri, 'string');

    const [upstreamError, upstreamRespose] = await safe(superagent.get(upstreamUri).set('User-Agent', 'Mozilla').timeout(10*1000));
    if (upstreamError) {
        log(`detectMetaInfo: error fetching ${upstreamUri}: ${upstreamError.status}`);
        return null;
    }

    const virtualConsole = new jsdom.VirtualConsole();
    virtualConsole.on('error', () => {
      // No-op to skip console errors.
    });

    const [jsdomError, dom] = await safe(jsdom.JSDOM.fromURL(upstreamUri, { virtualConsole }));
    if (jsdomError || !dom) {
        console.error('detectMetaInfo: jsdomError', jsdomError);
        return null;
    }

    let icon = null, label = '';

    // icon detection
    let favicon = '';
    if (dom.window.document.querySelector('link[rel="apple-touch-icon"]')) favicon = dom.window.document.querySelector('link[rel="apple-touch-icon"]').href;
    if (!favicon && dom.window.document.querySelector('meta[name="msapplication-TileImage"]')) favicon = dom.window.document.querySelector('meta[name="msapplication-TileImage"]').content;
    if (!favicon && dom.window.document.querySelector('link[rel="shortcut icon"]')) favicon = dom.window.document.querySelector('link[rel="shortcut icon"]').href;
    if (!favicon && dom.window.document.querySelector('link[rel="icon"]')) {
        let iconElements = dom.window.document.querySelectorAll('link[rel="icon"]');
        if (iconElements.length) {
            favicon = iconElements[0].href; // choose first one for a start

            // check if we have sizes attributes and then choose the largest one
            iconElements = Array.from(iconElements).filter(function (e) {
                return e.attributes.getNamedItem('sizes') && e.attributes.getNamedItem('sizes').value;
            }).sort(function (a, b) {
                return parseInt(b.attributes.getNamedItem('sizes').value.split('x')[0]) - parseInt(a.attributes.getNamedItem('sizes').value.split('x')[0]);
            });
            if (iconElements.length) favicon = iconElements[0].href;
        }
    }
    if (!favicon && dom.window.document.querySelector('meta[itemprop="image"]')) favicon = dom.window.document.querySelector('meta[itemprop="image"]').content;

    if (favicon) {
        favicon = new URL(favicon, upstreamRespose.url).toString();

        log(`detectMetaInfo: found icon: ${favicon}`);

        const [error, response] = await safe(superagent.get(favicon).timeout(10*1000));
        if (error) log(`Failed to fetch icon ${favicon}: ${error.message} ${error.status}`);
        else if (response.headers['content-type']?.indexOf('image') !== -1) icon = response.body || response.text;
        else log(`Failed to fetch icon ${favicon}: status=${response.status}`);
    }

    if (!favicon) {
        log(`Unable to find a suitable icon for ${upstreamUri}, try fallback favicon.ico`);

        const [error, response] = await safe(superagent.get(`${upstreamUri}/favicon.ico`).timeout(10*1000));
        if (error) log(`Failed to fetch icon ${favicon}: ${error.message}`);
        else if (response.headers['content-type']?.indexOf('image') !== -1) icon = response.body || response.text;
        else log(`Failed to fetch icon ${favicon}: status=${response.status} content type ${response.headers['content-type']}`);
    }

    // detect label
    if (dom.window.document.querySelector('meta[property="og:title"]')) label = dom.window.document.querySelector('meta[property="og:title"]').content;
    else if (dom.window.document.querySelector('meta[property="og:site_name"]')) label = dom.window.document.querySelector('meta[property="og:site_name"]').content;
    else if (dom.window.document.title) label = dom.window.document.title;

    return { icon, label };
}

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

    log(`add: ${applink.upstreamUri}`);

    let error = validateUpstreamUri(applink.upstreamUri);
    if (error) throw error;

    error = validateAccessRestriction(applink.accessRestriction);
    if (error) throw error;

    if (applink.icon) {
        applink.icon = Buffer.from(applink.icon, 'base64');
        if (applink.icon.length === 0) throw new BoxError(BoxError.BAD_FIELD, 'icon is not base64');
    }

    if (!applink.icon || !applink.label) {
        const meta = await detectMetaInfo(applink.upstreamUri);
        if (!applink.label) applink.label = meta?.label;
        if (!applink.icon) applink.icon = meta?.icon;
    }

    const data = {
        id: crypto.randomUUID(),
        accessRestrictionJson: applink.accessRestriction ? JSON.stringify(applink.accessRestriction) : null,
        label: applink.label || '',
        tagsJson: applink.tags ? JSON.stringify(applink.tags) : null,
        icon: applink.icon || null,
        upstreamUri: applink.upstreamUri
    };

    const query = 'INSERT INTO applinks (id, accessRestrictionJson, label, tagsJson, icon, upstreamUri) VALUES (?, ?, ?, ?, ?, ?)';
    const args = [ data.id, data.accessRestrictionJson, data.label, data.tagsJson, data.icon, data.upstreamUri ];

    [error] = await safe(database.query(query, args));
    if (error) throw error;

    return data.id;
}

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

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

    postProcess(result[0]);

    return result[0];
}

async function update(applink, data) {
    assert.strictEqual(typeof applink, 'object');
    assert.strictEqual(typeof data, 'object');

    let error;
    if ('upstreamUri' in data) {
        error = validateUpstreamUri(data.upstreamUri);
        if (error) throw error;
    }

    if ('accessRestriction' in data) {
        error = validateAccessRestriction(data.accessRestriction);
        if (error) throw error;
    }

    if ('icon' in data && data.icon) {
        data.icon = Buffer.from(data.icon, 'base64');
        if (data.icon.length === 0) throw new BoxError(BoxError.BAD_FIELD, 'icon is not base64');
    }

    // we don't track if label/icon in db is user-set or was auto detected
    if (data.upstreamUri || data.label === '' || data.icon === '') {
        const meta = await detectMetaInfo(data.upstreamUri || applink.upstreamUri);

        if (data.label === '') data.label = meta?.label;
        if (data.icon === '') data.icon = meta?.icon;
    }

    const args = [], fields = [];
    for (const k in data) {
        if (k === 'accessRestriction' || k === 'tags') {
            fields.push(`${k}Json = ?`);
            args.push(JSON.stringify(data[k]));
        } else {
            fields.push(k + ' = ?');
            args.push(data[k]);
        }
    }
    args.push(applink.id);

    const [updateError, result] = await safe(database.query('UPDATE applinks SET ' + fields.join(', ') + ' WHERE id = ?', args));
    if (updateError) throw updateError;
    if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'Applink not found');
}

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

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

export default {
    list,
    listByUser,
    add,
    get,
    update,
    del,
};
