// Copyright (c) 2021, Compiler Explorer Authors
// All rights reserved.
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are met:
//
//     * Redistributions of source code must retain the above copyright notice,
//       this list of conditions and the following disclaimer.
//     * Redistributions in binary form must reproduce the above copyright
//       notice, this list of conditions and the following disclaimer in the
//       documentation and/or other materials provided with the distribution.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
// POSSIBILITY OF SUCH DAMAGE.

import {saveAs} from 'file-saver';
import {Container} from 'golden-layout';
import $ from 'jquery';
import TomSelect from 'tom-select';
import _ from 'underscore';

import {assert, unwrap, unwrapString} from '../../shared/assert.js';
import {escapeHTML} from '../../shared/common-utils.js';
import {LanguageKey} from '../../types/languages.interfaces.js';
import {ResultLine} from '../../types/resultline/resultline.interfaces.js';
import * as BootstrapUtils from '../bootstrap-utils.js';
import * as Components from '../components.js';
import {createDragSource} from '../components.js';
import {EventHub} from '../event-hub.js';
import {Hub} from '../hub.js';
import {LineColouring} from '../line-colouring.js';
import {MultifileFile, MultifileService, MultifileServiceState} from '../multifile-service.js';
import {languagesService} from '../services/languages.service.js';
import {Settings, SiteSettings} from '../settings.js';
import * as utils from '../utils.js';
import {Alert} from '../widgets/alert.js';
import {PaneRenaming} from '../widgets/pane-renaming.js';
import {Toggles} from '../widgets/toggles.js';

export interface TreeState extends MultifileServiceState {
    id: number;
    cmakeArgs: string;
    customOutputFilename: string;
}

export class Tree {
    public readonly id: number;
    private container: Container;
    private domRoot: JQuery;
    private readonly hub: Hub;
    private eventHub: EventHub;
    private readonly settings: SiteSettings;
    private readonly alertSystem: Alert;
    private root: JQuery;
    private rowTemplate: JQuery;
    private namedItems: JQuery;
    private unnamedItems: JQuery;
    private langKeys: string[];
    private cmakeArgsInput: JQuery;
    private customOutputFilenameInput: JQuery;
    public multifileService: MultifileService;
    private lineColouring: LineColouring;
    private readonly ourCompilers: Record<number, boolean>;
    private readonly busyCompilers: Record<number, boolean>;
    private readonly asmByCompiler: Record<number, ResultLine[]>;
    private selectize: TomSelect;
    private languageBtn: JQuery;
    private toggleCMakeButton: Toggles;
    private debouncedEmitChange: () => void = () => {};
    private hideable: JQuery;
    private readonly topBar: JQuery;
    private paneName: string;
    private paneRenaming: PaneRenaming;

    constructor(hub: Hub, container: Container, state: TreeState) {
        this.id = state.id || hub.nextTreeId();
        this.container = container;
        this.domRoot = container.getElement();
        this.domRoot.html($('#tree').html());
        this.hub = hub;
        this.eventHub = hub.createEventHub();
        this.settings = Settings.getStoredSettings();
        this.alertSystem = new Alert();
        this.alertSystem.prefixMessage = 'Tree #' + this.id;

        this.root = this.domRoot.find('.tree');
        this.rowTemplate = $('#tree-editor-tpl');
        this.namedItems = this.domRoot.find('.named-editors');
        this.unnamedItems = this.domRoot.find('.unnamed-editors');
        this.hideable = this.domRoot.find('.hideable');
        this.topBar = this.domRoot.find('.top-bar.mainbar');

        this.langKeys = Object.keys(languagesService.getLanguagesOrFail());

        this.cmakeArgsInput = this.domRoot.find('.cmake-arguments');
        this.customOutputFilenameInput = this.domRoot.find('.cmake-customOutputFilename');

        const availableLangs = languagesService.getLanguagesOrFail();
        if (!(state.compilerLanguageId as any) || !(state.compilerLanguageId in availableLangs)) {
            const fallbackLang = this.settings.defaultLanguage ?? 'c++';
            state.compilerLanguageId = (fallbackLang in availableLangs ? fallbackLang : 'c++') as LanguageKey;
        }

        this.multifileService = new MultifileService(this.hub, this.alertSystem, state);
        this.lineColouring = new LineColouring(this.multifileService);
        this.ourCompilers = {};
        this.busyCompilers = {};
        this.asmByCompiler = {};

        this.paneRenaming = new PaneRenaming(this, state, hub);

        this.initInputs(state);
        this.initButtons(state);
        this.initCallbacks();
        this.onSettingsChange(this.settings);

        assert(this.languageBtn[0] instanceof HTMLSelectElement);
        this.selectize = new TomSelect(this.languageBtn[0], {
            sortField: 'name',
            valueField: 'id',
            labelField: 'name',
            searchField: ['name'],
            options: Object.values(languagesService.getLanguagesOrFail()),
            items: [this.multifileService.getLanguageId()],
            dropdownParent: 'body',
            plugins: ['input_autogrow'],
            maxOptions: 1000,
            onChange: (val: any) => this.onLanguageChange(val),
        });

        this.selectize.on('dropdown_close', () => {
            // scroll back to the selection on the next open
            const selection = this.selectize.getOption(this.multifileService.getLanguageId());
            this.selectize.setActiveOption(selection);
        });

        this.onLanguageChange(this.multifileService.getLanguageId());

        this.refresh();
        _.defer(() => {
            this.eventHub.emit('findEditors');
        });
    }

    private initInputs(state: TreeState) {
        if (state.cmakeArgs) {
            this.cmakeArgsInput.val(state.cmakeArgs);
        }

        if (state.customOutputFilename) {
            this.customOutputFilenameInput.val(state.customOutputFilename);
        }
    }

    private getCmakeArgs(): string {
        return unwrapString(this.cmakeArgsInput.val());
    }

    private getCustomOutputFilename(): string {
        return escapeHTML(unwrapString(this.customOutputFilenameInput.val()));
    }

    public currentState(): TreeState {
        const state = {
            id: this.id,
            cmakeArgs: this.getCmakeArgs(),
            customOutputFilename: this.getCustomOutputFilename(),
            ...this.multifileService.getState(),
        };
        this.paneRenaming.addState(state);
        return state;
    }

    private updateState() {
        const state = this.currentState();
        this.container.setState(state);

        this.updateButtons(state);
    }

    private paneRenamedExternally() {
        this.multifileService.forEachFile((file: MultifileFile) => {
            const editor = this.hub.getEditorById(file.editorId);
            if (editor) {
                file.filename = editor.getPaneName();
            }
        });
        this.refresh();
    }

    private initCallbacks() {
        this.container.on('resize', this.resize, this);
        this.container.on('shown', this.resize, this);
        this.container.on('open', () => {
            this.eventHub.emit('treeOpen', this.id);
        });
        this.container.on('destroy', this.close, this);

        this.eventHub.on('renamePane', this.paneRenamedExternally, this);

        this.eventHub.on('editorOpen', this.onEditorOpen, this);
        this.eventHub.on('editorClose', this.onEditorClose, this);
        this.eventHub.on('compilerOpen', this.onCompilerOpen, this);
        this.eventHub.on('compilerClose', this.onCompilerClose, this);

        this.eventHub.on('compileResult', this.onCompileResponse, this);

        this.toggleCMakeButton.on('change', this.onToggleCMakeChange.bind(this));

        this.cmakeArgsInput.on('change', this.updateCMakeArgs.bind(this));
        this.customOutputFilenameInput.on('change', this.updateCustomOutputFilename.bind(this));
    }

    private updateCMakeArgs() {
        this.updateState();

        this.debouncedEmitChange();
    }

    private updateCustomOutputFilename() {
        this.updateState();

        this.debouncedEmitChange();
    }

    private onToggleCMakeChange() {
        const isOn = this.toggleCMakeButton.get().isCMakeProject;
        this.multifileService.setAsCMakeProject(isOn);

        this.domRoot.find('.cmake-project').prop('title', '[' + (isOn ? 'ON' : 'OFF') + '] CMake project');
        this.updateState();
    }

    private onLanguageChange(newLangId: LanguageKey) {
        if (newLangId in languagesService.getLanguagesOrFail()) {
            this.multifileService.setLanguageId(newLangId);
            this.eventHub.emit('languageChange', false, newLangId, this.id);
        }

        this.toggleCMakeButton.enableToggle('isCMakeProject', this.multifileService.isCompatibleWithCMake());

        this.refresh();
    }

    private sendCompilerChangesToEditor(compilerId: number) {
        this.multifileService.forEachOpenFile((file: MultifileFile) => {
            if (file.isIncluded) {
                this.eventHub.emit('treeCompilerEditorIncludeChange', this.id, file.editorId, compilerId);
            } else {
                this.eventHub.emit('treeCompilerEditorExcludeChange', this.id, file.editorId, compilerId);
            }
        });

        this.eventHub.emit('resendCompilation', compilerId);
    }

    private sendCompileRequests() {
        this.eventHub.emit('requestCompilation', false, this.id);
    }

    private sendChangesToAllEditors() {
        for (const compilerId in this.ourCompilers) {
            this.sendCompilerChangesToEditor(Number.parseInt(compilerId, 10));
        }
    }

    private onCompilerOpen(compilerId: number, unused: number, treeId: number | boolean) {
        if (treeId === this.id) {
            this.ourCompilers[compilerId] = true;
            this.sendCompilerChangesToEditor(compilerId);
        }
    }

    private onCompilerClose(compilerId: number, treeId: number | boolean) {
        if (treeId === this.id) {
            delete this.ourCompilers[compilerId];
        }
    }

    private onEditorOpen(editorId: number) {
        const file = this.multifileService.getFileByEditorId(editorId);
        if (!file) this.multifileService.addFileForEditorId(editorId);
        this.refresh();
        this.sendChangesToAllEditors();
    }

    private onEditorClose(editorId: number) {
        const file = this.multifileService.getFileByEditorId(editorId);

        if (file) {
            if (!file.isIncluded) {
                this.multifileService.removeFileByFileId(file.fileId);
            } else {
                file.isOpen = false;
                const editor = this.hub.getEditorById(editorId);
                file.langId = editor?.currentLanguage?.id ?? 'c++';
                file.content = editor?.getSource() ?? '';
                file.editorId = -1;
            }
        }

        this.refresh();
    }

    private removeFile(fileId: number) {
        const file = this.multifileService.removeFileByFileId(fileId);
        if (file) {
            if (file.isOpen) {
                const editor = this.hub.getEditorById(file.editorId);
                if (editor) {
                    editor.container.close();
                }
            }
        }

        this.refresh();
    }

    private removeFileByFilename(filename: string) {
        const file = this.multifileService.removeFileByFilename(filename);
        if (file) {
            if (file.isOpen) {
                const editor = this.hub.getEditorById(file.editorId);
                if (editor) {
                    editor.container.close();
                }
            }
        }

        this.refresh();
    }

    private addRowToTreelist(file: MultifileFile) {
        const item = $(this.rowTemplate.children()[0].cloneNode(true));
        const stageButton = item.find('.stage-file');
        const unstageButton = item.find('.unstage-file');
        const renameButton = item.find('.rename-file');
        const deleteButton = item.find('.delete-file');

        item.data('fileId', file.fileId);
        if (file.filename) {
            item.find('.filename').text(file.filename);
        } else if (file.editorId > 0) {
            const editor = this.hub.getEditorById(file.editorId);
            if (editor) {
                item.find('.filename').text(editor.getPaneName());
            } else {
                // wait for editor to appear first
                return;
            }
        } else {
            item.find('.filename').text('Unknown file');
        }

        item.on('click', e => {
            const fileId = $(e.currentTarget).data('fileId');
            this.editFile(fileId);
        });

        renameButton.on('click', async e => {
            const fileId = $(e.currentTarget).parent('li').data('fileId');
            await this.multifileService.renameFile(fileId);
            this.refresh();
        });

        deleteButton.on('click', e => {
            const fileId = $(e.currentTarget).parent('li').data('fileId');
            const file = this.multifileService.getFileByFileId(fileId);
            if (file) {
                this.alertSystem.ask(
                    'Delete file',
                    `Are you sure you want to delete ${file.filename ? escapeHTML(file.filename) : 'this file'}?`,
                    {
                        yes: () => {
                            this.removeFile(fileId);
                        },
                        yesClass: 'btn-danger',
                        yesHtml: 'Delete',
                        noClass: 'btn-primary',
                        noHtml: 'Cancel',
                    },
                );
            }
        });

        stageButton.on('click', async e => {
            const fileId = $(e.currentTarget).parent('li').data('fileId');
            await this.moveToInclude(fileId);
        });
        unstageButton.on('click', async e => {
            const fileId = $(e.currentTarget).parent('li').data('fileId');
            await this.moveToExclude(fileId);
        });
        stageButton.toggle(!file.isIncluded);
        unstageButton.toggle(file.isIncluded);
        item.appendTo(file.isIncluded ? this.namedItems : this.unnamedItems);
    }

    refresh() {
        this.updateState();

        this.namedItems.html('');
        this.unnamedItems.html('');

        this.multifileService.forEachFile((file: MultifileFile) => this.addRowToTreelist(file));
    }

    private editFile(fileId: number) {
        const file = this.multifileService.getFileByFileId(fileId);
        if (file) {
            if (!file.isOpen) {
                const dragConfig = this.getConfigForNewEditor(file);
                file.isOpen = true;

                this.hub.addInEditorStackIfPossible(dragConfig);
            } else {
                const editor = this.hub.getEditorById(file.editorId);
                this.hub.activateTabForContainer(editor?.container);
            }

            this.sendChangesToAllEditors();
        }
    }

    private async moveToInclude(fileId: number) {
        await this.multifileService.includeByFileId(fileId);

        this.refresh();
        this.sendChangesToAllEditors();
    }

    private async moveToExclude(fileId: number) {
        await this.multifileService.excludeByFileId(fileId);
        this.refresh();
        this.sendChangesToAllEditors();
    }

    private bindClickToOpenPane(dragSource, dragConfig) {
        createDragSource(this.container.layoutManager, dragSource, () => dragConfig.bind(this)()).on(
            'dragStart',
            () => {
                const dropdown = this.domRoot.find('.add-pane');
                const dropdownInstance = BootstrapUtils.getDropdownInstance(dropdown);
                if (dropdownInstance) {
                    dropdownInstance.toggle();
                }
            },
        );

        dragSource.on('click', () => {
            this.hub.addInEditorStackIfPossible(dragConfig.bind(this));
        });
    }

    private getConfigForNewCompiler() {
        return Components.getCompilerForTree(this.id, this.currentState().compilerLanguageId);
    }

    private getConfigForNewExecutor() {
        return Components.getExecutorForTree(this.id, this.currentState().compilerLanguageId);
    }

    private getConfigForNewEditor(file: MultifileFile | undefined) {
        let editor;
        const editorId = this.hub.nextEditorId();

        if (!file) {
            this.multifileService.addFileForEditorId(editorId);
            editor = Components.getEditor(this.multifileService.getLanguageId(), editorId);
        } else {
            file.editorId = editorId;
            editor = Components.getEditor(file.langId, editorId);

            editor.componentState.source = file.content;
            if (file.filename) {
                editor.componentState.filename = file.filename;
            }
        }

        return editor;
    }

    private static triggerSaveAs(blob) {
        const dt = utils.formatDateTimeWithSpaces(new Date());
        saveAs(blob, `project-${dt}.zip`);
    }

    private initButtons(state: TreeState) {
        const addCompilerButton = this.domRoot.find('.add-compiler');
        const addExecutorButton = this.domRoot.find('.add-executor');
        const addEditorButton = this.domRoot.find('.add-editor');
        const saveProjectButton = this.domRoot.find('.save-project-to-file');

        saveProjectButton.on('click', async () => {
            await this.multifileService.saveProjectToZipfile(Tree.triggerSaveAs.bind(this));
        });

        const loadProjectFromFile = this.domRoot.find('.load-project-from-file') as JQuery<HTMLInputElement>;
        loadProjectFromFile.on('change', async e => {
            const files = e.target.files;
            if (files && files.length > 0) {
                await this.openZipFile(files[0]);
            }

            loadProjectFromFile.val('');
        });

        this.bindClickToOpenPane(addCompilerButton, this.getConfigForNewCompiler);
        this.bindClickToOpenPane(addExecutorButton, this.getConfigForNewExecutor);
        this.bindClickToOpenPane(addEditorButton, this.getConfigForNewEditor);

        this.languageBtn = this.domRoot.find('.change-language');
        if (!(this.languageBtn[0] instanceof HTMLSelectElement)) {
            throw new Error('.language-button is not an HTMLSelectElement');
        }
        if (this.langKeys.length <= 1) {
            this.languageBtn.prop('disabled', true);
        }

        this.toggleCMakeButton = new Toggles(
            this.domRoot.find('.options'),
            state as unknown as Record<string, boolean>,
        );

        let drophereHideTimeout;
        this.root.on('dragover', ev => {
            ev.preventDefault();

            if (drophereHideTimeout) clearTimeout(drophereHideTimeout);

            const drophere = this.root.find('.drophere');
            drophere.show();
        });

        this.root.on('dragleave', () => {
            const drophere = this.root.find('.drophere');
            drophereHideTimeout = setTimeout(() => {
                drophere.hide();
            }, 1000);
        });

        this.root.on('drop', async (ev: any) => {
            ev.preventDefault();

            const drophere = this.root.find('.drophere');
            drophere.hide();

            const dataTransfer = ev.originalEvent.dataTransfer;
            if (dataTransfer.items) {
                [...dataTransfer.items].forEach(async (item, i) => {
                    if (item.kind === 'file') {
                        const file = item.getAsFile();
                        if (file.name.endsWith('.zip')) {
                            this.openZipFile(file);
                        } else {
                            await this.addSingleFile(file);
                        }
                    }
                });
            } else {
                [...dataTransfer.files].forEach(async (file, i) => {
                    if (file.name.endsWith('.zip')) {
                        this.openZipFile(file);
                    } else {
                        await this.addSingleFile(file);
                    }
                });
            }
        });
    }

    private async openZipFile(htmlfile) {
        if (!htmlfile.name.toLowerCase().endsWith('.zip')) {
            this.alertSystem.alert('Load project file', 'Projects can only be loaded from .zip files', {isError: true});
            return;
        }

        this.multifileService.forEachFile((file: MultifileFile) => {
            this.removeFile(file.fileId);
        });

        await this.multifileService.loadProjectFromFile(htmlfile, (file: MultifileFile) => {
            this.refresh();
            if (file.filename === 'CMakeLists.txt') {
                // todo: find a way to toggle on CMake checkbox...
                this.editFile(file.fileId);
            }
        });
    }

    private async askForOverwriteAndDo(filename): Promise<void> {
        return new Promise((resolve, reject) => {
            if (this.multifileService.fileExists(filename)) {
                this.alertSystem.ask('Overwrite file', `${escapeHTML(filename)} already exists, overwrite this file?`, {
                    yes: () => {
                        this.removeFileByFilename(filename);
                        resolve();
                    },
                    no: () => {
                        reject(new Error(`User cancelled file overwrite for: ${filename}`));
                    },
                    onClose: () => {
                        reject(new Error(`User cancelled file overwrite for: ${filename}`));
                    },
                    yesClass: 'btn-danger',
                    yesHtml: 'Yes',
                    noClass: 'btn-primary',
                    noHtml: 'No',
                });
            } else {
                resolve();
            }
        });
    }

    private async addSingleFile(htmlfile): Promise<void> {
        try {
            await this.askForOverwriteAndDo(htmlfile.name);

            return new Promise(resolve => {
                const fr = new FileReader();
                fr.onload = () => {
                    this.multifileService.addNewTextFile(htmlfile.name, fr.result?.toString() || '');
                    this.refresh();
                    resolve();
                };
                fr.readAsText(htmlfile);
            });
        } catch {
            // expected when user says no
        }
    }

    private numberUsedLines() {
        if (_.any(this.busyCompilers)) return;

        if (!this.settings.colouriseAsm) {
            this.updateColoursNone();
            return;
        }

        this.lineColouring.clear();

        for (const [compilerId, asm] of Object.entries(this.asmByCompiler)) {
            this.lineColouring.addFromAssembly(Number.parseInt(compilerId, 10), asm);
        }

        this.lineColouring.calculate();

        this.updateColours();
    }

    private updateColours() {
        for (const compilerId in this.ourCompilers) {
            const id: number = Number.parseInt(compilerId, 10);
            this.eventHub.emit(
                'coloursForCompiler',
                id,
                this.lineColouring.getColoursForCompiler(id),
                this.settings.colourScheme,
            );
        }

        this.multifileService.forEachOpenFile((file: MultifileFile) => {
            this.eventHub.emit(
                'coloursForEditor',
                file.editorId,
                this.lineColouring.getColoursForEditor(file.editorId),
                this.settings.colourScheme,
            );
        });
    }

    private updateColoursNone() {
        for (const compilerId in this.ourCompilers) {
            this.eventHub.emit('coloursForCompiler', Number.parseInt(compilerId, 10), {}, this.settings.colourScheme);
        }

        this.multifileService.forEachOpenFile((file: MultifileFile) => {
            this.eventHub.emit('coloursForEditor', file.editorId, {}, this.settings.colourScheme);
        });
    }

    private onCompileResponse(compilerId: number, compiler, result) {
        if (!this.ourCompilers[compilerId]) return;

        this.busyCompilers[compilerId] = false;

        // todo: parse errors and warnings and relate them to lines in the code
        // note: requires info about the filename, do we currently have that?

        // {"text":"/tmp/compiler-explorer-compiler2021428-7126-95g4xc.zfo8p/example.cpp:4:21: error: expected ‘;’ before ‘}’ token"}

        if (result.result?.asm) {
            this.asmByCompiler[compilerId] = result.result.asm;
        } else {
            this.asmByCompiler[compilerId] = result.asm;
        }

        this.numberUsedLines();
    }

    private updateButtons(state: TreeState) {
        if (state.isCMakeProject) {
            this.cmakeArgsInput.parent().removeClass('d-none');
            this.customOutputFilenameInput.parent().removeClass('d-none');
        } else {
            this.cmakeArgsInput.parent().addClass('d-none');
            this.customOutputFilenameInput.parent().addClass('d-none');
        }
    }

    private resize() {
        utils.updateAndCalcTopBarHeight(this.domRoot, this.topBar, this.hideable);

        const mainbarHeight = unwrap(this.topBar.outerHeight(true));
        const argsHeight = unwrap(this.domRoot.find('.panel-args').outerHeight(true));
        const outputfileHeight = unwrap(this.domRoot.find('.panel-outputfile').outerHeight(true));
        const innerHeight = unwrap(this.domRoot.innerHeight());

        this.root.height(innerHeight - mainbarHeight - argsHeight - outputfileHeight);
    }

    private onSettingsChange(newSettings) {
        this.debouncedEmitChange = _.debounce(() => {
            this.sendCompileRequests();
        }, newSettings.delayAfterChange);
    }

    private getPaneName() {
        return `Tree #${this.id}`;
    }

    updateTitle() {
        const name = this.paneName ? this.paneName : this.getPaneName();
        this.container.setTitle(escapeHTML(name));
    }

    private close() {
        this.eventHub.unsubscribe();
        this.eventHub.emit('treeClose', this.id);
        this.hub.removeTree(this.id);
        $('#add-tree').prop('disabled', false);
    }
}
