/*
 * Copyright (C) 2020-2021  Yomichan Authors
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

const fs = require('fs');
const path = require('path');
const assert = require('assert');
const readline = require('readline');
const childProcess = require('child_process');
const util = require('./util');
const {getAllFiles, getDefaultManifestAndVariants, createManifestString, getArgs, testMain} = util;


function clone(value) {
    return JSON.parse(JSON.stringify(value));
}

async function createZip(directory, excludeFiles, outputFileName, sevenZipExes, onUpdate, dryRun) {
    try {
        fs.unlinkSync(outputFileName);
    } catch (e) {
        // NOP
    }

    if (!dryRun) {
        for (const exe of sevenZipExes) {
            try {
                const excludeArguments = excludeFiles.map((excludeFilePath) => `-x!${excludeFilePath}`);
                childProcess.execFileSync(
                    exe,
                    [
                        'a',
                        outputFileName,
                        '.',
                        ...excludeArguments
                    ],
                    {
                        cwd: directory
                    }
                );
                return;
            } catch (e) {
                // NOP
            }
        }
    }
    return await createJSZip(directory, excludeFiles, outputFileName, onUpdate, dryRun);
}

async function createJSZip(directory, excludeFiles, outputFileName, onUpdate, dryRun) {
    const JSZip = util.JSZip;
    const files = getAllFiles(directory);
    removeItemsFromArray(files, excludeFiles);
    const zip = new JSZip();
    for (const fileName of files) {
        zip.file(
            fileName.replace(/\\/g, '/'),
            fs.readFileSync(path.join(directory, fileName), {encoding: null, flag: 'r'}),
            {}
        );
    }

    if (typeof onUpdate !== 'function') {
        onUpdate = () => {}; // NOP
    }

    const data = await zip.generateAsync({
        type: 'nodebuffer',
        compression: 'DEFLATE',
        compressionOptions: {level: 9}
    }, onUpdate);
    process.stdout.write('\n');

    if (!dryRun) {
        fs.writeFileSync(outputFileName, data, {encoding: null, flag: 'w'});
    }
}

function removeItemsFromArray(array, removeItems) {
    for (const item of removeItems) {
        const index = getIndexOfFilePath(array, item);
        if (index >= 0) {
            array.splice(index, 1);
        }
    }
}

function getIndexOfFilePath(array, item) {
    const pattern = /\\/g;
    const separator = '/';
    item = item.replace(pattern, separator);
    for (let i = 0, ii = array.length; i < ii; ++i) {
        if (array[i].replace(pattern, separator) === item) {
            return i;
        }
    }
    return -1;
}

function applyModifications(manifest, modifications) {
    if (Array.isArray(modifications)) {
        for (const modification of modifications) {
            const {action, path: path2} = modification;
            switch (action) {
                case 'set':
                    {
                        const {value, before, after} = modification;
                        const object = getObjectProperties(manifest, path2, path2.length - 1);
                        const key = path2[path2.length - 1];

                        let {index} = modification;
                        if (typeof index !== 'number') {
                            index = -1;
                        }
                        if (typeof before === 'string') {
                            index = getObjectKeyIndex(object, before);
                        }
                        if (typeof after === 'string') {
                            index = getObjectKeyIndex(object, after);
                            if (index >= 0) { ++index; }
                        }

                        setObjectKeyAtIndex(object, key, value, index);
                    }
                    break;
                case 'replace':
                    {
                        const {pattern, patternFlags, replacement} = modification;
                        const value = getObjectProperties(manifest, path2, path2.length - 1);
                        const regex = new RegExp(pattern, patternFlags);
                        const last = path2[path2.length - 1];
                        let value2 = value[last];
                        value2 = `${value2}`.replace(regex, replacement);
                        value[last] = value2;
                    }
                    break;
                case 'delete':
                    {
                        const value = getObjectProperties(manifest, path2, path2.length - 1);
                        const last = path2[path2.length - 1];
                        delete value[last];
                    }
                    break;
                case 'remove':
                    {
                        const {item} = modification;
                        const value = getObjectProperties(manifest, path2, path2.length);
                        const index = value.indexOf(item);
                        if (index >= 0) { value.splice(index, 1); }
                    }
                    break;
                case 'splice':
                    {
                        const {start, deleteCount, items} = modification;
                        const value = getObjectProperties(manifest, path2, path2.length);
                        const itemsNew = items.map((v) => clone(v));
                        value.splice(start, deleteCount, ...itemsNew);
                    }
                    break;
                case 'copy':
                case 'move':
                    {
                        const {newPath, before, after} = modification;
                        const oldKey = path2[path2.length - 1];
                        const newKey = newPath[newPath.length - 1];
                        const oldObject = getObjectProperties(manifest, path2, path2.length - 1);
                        const newObject = getObjectProperties(manifest, newPath, newPath.length - 1);
                        const oldObjectIsNewObject = arraysAreSame(path2, newPath, -1);
                        const value = oldObject[oldKey];

                        let {index} = modification;
                        if (typeof index !== 'number' || index < 0) {
                            index = (oldObjectIsNewObject && action !== 'copy') ? getObjectKeyIndex(oldObject, oldKey) : -1;
                        }
                        if (typeof before === 'string') {
                            index = getObjectKeyIndex(newObject, before);
                        }
                        if (typeof after === 'string') {
                            index = getObjectKeyIndex(newObject, after);
                            if (index >= 0) { ++index; }
                        }

                        setObjectKeyAtIndex(newObject, newKey, value, index);
                        if (action !== 'copy' && (!oldObjectIsNewObject || oldKey !== newKey)) {
                            delete oldObject[oldKey];
                        }
                    }
                    break;
                case 'add':
                    {
                        const {items} = modification;
                        const value = getObjectProperties(manifest, path2, path2.length);
                        const itemsNew = items.map((v) => clone(v));
                        value.push(...itemsNew);
                    }
                    break;
            }
        }
    }

    return manifest;
}

function arraysAreSame(array1, array2, lengthOffset) {
    let ii = array1.length;
    if (ii !== array2.length) { return false; }
    ii += lengthOffset;
    for (let i = 0; i < ii; ++i) {
        if (array1[i] !== array2[i]) { return false; }
    }
    return true;
}

function getObjectKeyIndex(object, key) {
    return Object.keys(object).indexOf(key);
}

function setObjectKeyAtIndex(object, key, value, index) {
    if (index < 0 || typeof key === 'number' || Object.prototype.hasOwnProperty.call(object, key)) {
        object[key] = value;
        return;
    }

    const entries = Object.entries(object);
    index = Math.min(index, entries.length);
    for (let i = index, ii = entries.length; i < ii; ++i) {
        const [key2] = entries[i];
        delete object[key2];
    }
    entries.splice(index, 0, [key, value]);
    for (let i = index, ii = entries.length; i < ii; ++i) {
        const [key2, value2] = entries[i];
        object[key2] = value2;
    }
}

function getObjectProperties(object, path2, count) {
    for (let i = 0; i < count; ++i) {
        object = object[path2[i]];
    }
    return object;
}

function getInheritanceChain(variant, variantMap) {
    const visited = new Set();
    const inheritance = [];
    while (true) {
        const {name, inherit} = variant;
        if (visited.has(name)) { break; }

        visited.add(name);
        inheritance.unshift(variant);

        if (typeof inherit !== 'string') { break; }

        const nextVariant = variantMap.get(inherit);
        if (typeof nextVariant === 'undefined') { break; }

        variant = nextVariant;
    }
    return inheritance;
}

function createVariantManifest(manifest, variant, variantMap) {
    let modifiedManifest = clone(manifest);
    for (const {modifications} of getInheritanceChain(variant, variantMap)) {
        modifiedManifest = applyModifications(modifiedManifest, modifications);
    }
    return modifiedManifest;
}

async function build(manifest, buildDir, extDir, manifestPath, variantMap, variantNames, dryRun, dryRunBuildZip) {
    const sevenZipExes = ['7za', '7z'];

    // Create build directory
    if (!fs.existsSync(buildDir) && !dryRun) {
        fs.mkdirSync(buildDir, {recursive: true});
    }

    const dontLogOnUpdate = !process.stdout.isTTY;
    const onUpdate = (metadata) => {
        if (dontLogOnUpdate) { return; }

        let message = `Progress: ${metadata.percent.toFixed(2)}%`;
        if (metadata.currentFile) {
            message += ` (${metadata.currentFile})`;
        }

        readline.clearLine(process.stdout);
        readline.cursorTo(process.stdout, 0);
        process.stdout.write(message);
    };

    for (const variantName of variantNames) {
        const variant = variantMap.get(variantName);
        if (typeof variant === 'undefined') { continue; }

        const {name, fileName, fileCopies} = variant;
        let {excludeFiles} = variant;
        if (!Array.isArray(excludeFiles)) { excludeFiles = []; }

        process.stdout.write(`Building ${name}...\n`);

        const modifiedManifest = createVariantManifest(manifest, variant, variantMap);

        const fileNameSafe = path.basename(fileName);
        const fullFileName = path.join(buildDir, fileNameSafe);
        ensureFilesExist(extDir, excludeFiles);
        if (!dryRun) {
            fs.writeFileSync(manifestPath, createManifestString(modifiedManifest));
        }

        if (!dryRun || dryRunBuildZip) {
            await createZip(extDir, excludeFiles, fullFileName, sevenZipExes, onUpdate, dryRun);
        }

        if (!dryRun) {
            if (Array.isArray(fileCopies)) {
                for (const fileName2 of fileCopies) {
                    const fileName2Safe = path.basename(fileName2);
                    fs.copyFileSync(fullFileName, path.join(buildDir, fileName2Safe));
                }
            }
        }

        process.stdout.write('\n');
    }
}

function ensureFilesExist(directory, files) {
    for (const file of files) {
        assert.ok(fs.existsSync(path.join(directory, file)));
    }
}


async function main(argv) {
    const args = getArgs(argv, new Map([
        ['all', false],
        ['default', false],
        ['manifest', null],
        ['dry-run', false],
        ['dry-run-build-zip', false],
        [null, []]
    ]));

    const dryRun = args.get('dry-run');
    const dryRunBuildZip = args.get('dry-run-build-zip');

    const {manifest, variants} = getDefaultManifestAndVariants();

    const rootDir = path.join(__dirname, '..');
    const extDir = path.join(rootDir, 'ext');
    const buildDir = path.join(rootDir, 'builds');
    const manifestPath = path.join(extDir, 'manifest.json');

    const variantMap = new Map();
    for (const variant of variants) {
        variantMap.set(variant.name, variant);
    }

    try {
        const variantNames = (argv.length === 0 || args.get('all') ? variants.map(({name}) => name) : args.get(null));
        await build(manifest, buildDir, extDir, manifestPath, variantMap, variantNames, dryRun, dryRunBuildZip);
    } finally {
        // Restore manifest
        let restoreManifest = manifest;
        if (!args.get('default') && args.get('manifest') !== null) {
            const variant = variantMap.get(args.get('manifest'));
            if (typeof variant !== 'undefined') {
                restoreManifest = createVariantManifest(manifest, variant, variantMap);
            }
        }
        process.stdout.write('Restoring manifest...\n');
        if (!dryRun) {
            fs.writeFileSync(manifestPath, createManifestString(restoreManifest));
        }
    }
}


if (require.main === module) {
    testMain(main, process.argv.slice(2));
}


module.exports = {
    main
};