/*
 * Copyright (C) 2023  Yomitan Authors
 * Copyright (C) 2021-2022  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/>.
 */

import childProcess from 'child_process';
import fs from 'fs';
import {fileURLToPath} from 'node:url';
import path from 'path';
import {parseJson} from './json.js';

const dirname = path.dirname(fileURLToPath(import.meta.url));

/**
 * @template [T=unknown]
 * @param {T} value
 * @returns {T}
 */
function clone(value) {
    return parseJson(JSON.stringify(value));
}


export class ManifestUtil {
    constructor() {
        const fileName = path.join(dirname, 'data', 'manifest-variants.json');
        const {manifest, variants, defaultVariant} = /** @type {import('dev/manifest').ManifestConfig} */ (parseJson(fs.readFileSync(fileName, {encoding: 'utf8'})));
        /** @type {import('dev/manifest').Manifest} */
        this._manifest = manifest;
        /** @type {import('dev/manifest').ManifestVariant[]} */
        this._variants = variants;
        /** @type {string} */
        this._defaultVariant = defaultVariant;
        /** @type {Map<string, import('dev/manifest').ManifestVariant>} */
        this._variantMap = new Map();
        for (const variant of variants) {
            this._variantMap.set(variant.name, variant);
        }
    }

    /**
     * @param {?string} [variantName]
     * @returns {import('dev/manifest').Manifest}
     */
    getManifest(variantName) {
        if (typeof variantName === 'string') {
            const variant = this._variantMap.get(variantName);
            if (typeof variant !== 'undefined') {
                return this._createVariantManifest(this._manifest, variant);
            }
        }

        if (typeof this._defaultVariant === 'string') {
            const variant = this._variantMap.get(this._defaultVariant);
            if (typeof variant !== 'undefined') {
                return this._createVariantManifest(this._manifest, variant);
            }
        }

        return clone(this._manifest);
    }

    /**
     * @returns {import('dev/manifest').ManifestVariant[]}
     */
    getVariants() {
        return [...this._variants];
    }

    /**
     * @param {string} name
     * @returns {import('dev/manifest').ManifestVariant|undefined}
     */
    getVariant(name) {
        return this._variantMap.get(name);
    }

    /**
     * @param {import('dev/manifest').Manifest} manifest
     * @returns {string}
     */
    static createManifestString(manifest) {
        return JSON.stringify(manifest, null, 4) + '\n';
    }

    // Private

    /**
     * @param {import('dev/manifest').Command} data
     * @returns {string}
     * @throws {Error}
     */
    _evaluateModificationCommand(data) {
        const {command, args, trim} = data;
        const {stdout, stderr, status} = childProcess.spawnSync(command, args, {
            cwd: dirname,
            stdio: 'pipe',
            shell: false
        });
        if (status !== 0) {
            const message = stderr.toString('utf8').trim();
            throw new Error(`Failed to execute ${command} ${args.join(' ')}\nstatus=${status}\n${message}`);
        }
        let result = stdout.toString('utf8');
        if (trim) { result = result.trim(); }
        return result;
    }

    /**
     * @param {import('dev/manifest').Manifest} manifest
     * @param {import('dev/manifest').Modification[]|undefined} modifications
     * @returns {import('dev/manifest').Manifest}
     */
    _applyModifications(manifest, modifications) {
        if (Array.isArray(modifications)) {
            for (const modification of modifications) {
                // Rename to path2 to avoid clashing with imported `node:path` module.
                const {action, path: path2} = modification;
                switch (action) {
                    case 'set':
                        {
                            let {value, before, after, command} = modification;
                            /** @type {import('core').UnknownObject} */
                            const object = this._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 = this._getObjectKeyIndex(object, before);
                            }
                            if (typeof after === 'string') {
                                index = this._getObjectKeyIndex(object, after);
                                if (index >= 0) { ++index; }
                            }
                            if (typeof command === 'object' && command !== null) {
                                value = this._evaluateModificationCommand(command);
                            }

                            this._setObjectKeyAtIndex(object, key, value, index);
                        }
                        break;
                    case 'replace':
                        {
                            const {pattern, patternFlags, replacement} = modification;
                            /** @type {import('core').UnknownObject} */
                            const value = this._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':
                        {
                            /** @type {import('core').UnknownObject} */
                            const value = this._getObjectProperties(manifest, path2, path2.length - 1);
                            const last = path2[path2.length - 1];
                            delete value[last];
                        }
                        break;
                    case 'remove':
                        {
                            const {item} = modification;
                            /** @type {unknown[]} */
                            const value = this._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;
                            /** @type {unknown[]} */
                            const value = this._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];
                            /** @type {import('core').UnknownObject} */
                            const oldObject = this._getObjectProperties(manifest, path2, path2.length - 1);
                            /** @type {import('core').UnknownObject} */
                            const newObject = this._getObjectProperties(manifest, newPath, newPath.length - 1);
                            const oldObjectIsNewObject = this._arraysAreSame(path2, newPath, -1);
                            const value = oldObject[oldKey];

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

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

        return manifest;
    }

    /**
     * @template [T=unknown]
     * @param {T[]} array1
     * @param {T[]} array2
     * @param {number} lengthOffset
     * @returns {boolean}
     */
    _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;
    }

    /**
     * @param {import('core').UnknownObject} object
     * @param {string|number} key
     * @returns {number}
     */
    _getObjectKeyIndex(object, key) {
        return Object.keys(object).indexOf(typeof key === 'string' ? key : `${key}`);
    }

    /**
     * @param {import('core').UnknownObject} object
     * @param {string|number} key
     * @param {unknown} value
     * @param {number} index
     */
    _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;
        }
    }

    /**
     * @template [TReturn=unknown]
     * @param {unknown} object
     * @param {import('dev/manifest').PropertyPath} path2
     * @param {number} count
     * @returns {TReturn}
     */
    _getObjectProperties(object, path2, count) {
        for (let i = 0; i < count; ++i) {
            object = /** @type {import('core').UnknownObject} */ (object)[path2[i]];
        }
        return /** @type {TReturn} */ (object);
    }

    /**
     * @param {import('dev/manifest').ManifestVariant} variant
     * @returns {import('dev/manifest').ManifestVariant[]}
     */
    _getInheritanceChain(variant) {
        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 = this._variantMap.get(inherit);
            if (typeof nextVariant === 'undefined') { break; }

            variant = nextVariant;
        }
        return inheritance;
    }

    /**
     * @param {import('dev/manifest').Manifest} manifest
     * @param {import('dev/manifest').ManifestVariant} variant
     * @returns {import('dev/manifest').Manifest}
     */
    _createVariantManifest(manifest, variant) {
        let modifiedManifest = clone(manifest);
        for (const {modifications} of this._getInheritanceChain(variant)) {
            modifiedManifest = this._applyModifications(modifiedManifest, modifications);
        }
        return modifiedManifest;
    }
}