// Copyright (c) 2022, 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 * as sifter from '@orchidjs/sifter';
import {Container} from 'golden-layout';
import $ from 'jquery';
import * as monaco from 'monaco-editor';
import scrollIntoView from 'scroll-into-view-if-needed';
import TomSelect from 'tom-select';
import _ from 'underscore';

import {unwrap, unwrapString} from '../../shared/assert.js';
import {escapeHTML} from '../../shared/common-utils.js';
import {CompilationResult} from '../../types/compilation/compilation.interfaces.js';
import {
    OptPipelineBackendOptions,
    OptPipelineOutput,
    OptPipelineResults,
} from '../../types/compilation/opt-pipeline-output.interfaces.js';
import {CompilerInfo} from '../../types/compiler.interfaces.js';
import {Hub} from '../hub.js';
import {extendConfig} from '../monaco-config.js';
import {SentryCapture} from '../sentry.js';
import * as utils from '../utils.js';
import {Toggles} from '../widgets/toggles.js';
import {OptPipelineViewState} from './opt-pipeline.interfaces.js';
import {MonacoPaneState} from './pane.interfaces.js';
import {MonacoPane} from './pane.js';

const MIN_SIDEBAR_WIDTH = 100;

export class OptPipeline extends MonacoPane<monaco.editor.IStandaloneDiffEditor, OptPipelineViewState> {
    results: OptPipelineResults = {};
    compiler: CompilerInfo | null;
    groupName: JQuery;
    passesColumn: JQuery;
    passesFilter: JQuery;
    passesList: JQuery;
    passesColumnResizer: JQuery;
    body: JQuery;
    clickCallback: (e: JQuery.ClickEvent) => void;
    keydownCallback: (e: JQuery.KeyDownEvent) => void;
    isPassListSelected = false;
    groupSelector: TomSelect;
    originalModel: any;
    modifiedModel: any;
    options: Toggles;
    filters: Toggles;
    state: OptPipelineViewState;
    lastOptions: OptPipelineBackendOptions = {
        filterDebugInfo: true,
        filterIRMetadata: false,
        fullModule: false,
        noDiscardValueNames: true,
        demangle: true,
        libraryFunctions: true,
    };
    resizeStartX: number;
    resizeStartWidth: number;
    resizeDragMoveBind: (e: MouseEvent) => void;
    resizeDragEndBind: (e: MouseEvent) => void;
    firstResults = true;

    constructor(hub: Hub, container: Container, state: OptPipelineViewState & MonacoPaneState) {
        super(hub, container, state);
        this.groupName = this.domRoot.find('.opt-group-name');
        this.updateGroupName();
        this.passesColumn = this.domRoot.find('.passes-column');
        this.passesFilter = this.domRoot.find('.passes-filter');
        this.passesList = this.domRoot.find('.passes-list');
        this.body = this.domRoot.find('.opt-pipeline-body');

        if (state.sidebarWidth === 0) {
            _.defer(() => {
                state.sidebarWidth = Number.parseInt(
                    unwrap(document.defaultView).getComputedStyle(this.passesColumn.get()[0]).width,
                    10,
                );
                state.sidebarWidth = Math.max(state.sidebarWidth, MIN_SIDEBAR_WIDTH);
                this.resize();
                this.updateState();
            });
        } else {
            state.sidebarWidth = Math.max(state.sidebarWidth, MIN_SIDEBAR_WIDTH);
            this.passesColumn.get()[0].style.width = state.sidebarWidth + 'px';
        }
        this.state = state;
        this.upgradeStateFields();
        const selector = this.domRoot.get()[0].getElementsByClassName('group-selector')[0];
        if (!(selector instanceof HTMLSelectElement)) {
            throw new Error('.group-selector is not an HTMLSelectElement');
        }
        this.groupSelector = new TomSelect(selector, {
            valueField: 'value',
            labelField: 'title',
            searchField: ['title'],
            dropdownParent: 'body',
            plugins: ['input_autogrow'],
            sortField: 'title',
            maxOptions: 1000,
            onChange: (e: string) => this.selectGroup(e),
        });
        this.groupSelector.on('dropdown_close', () => {
            // scroll back to the selection on the next open
            const selection = unwrap(this.groupSelector).getOption(this.state.selectedGroup);
            this.groupSelector.setActiveOption(selection);
        });
        this.clickCallback = this.onClickCallback.bind(this);
        this.keydownCallback = this.onKeydownCallback.bind(this);
        $(document).on('click', this.clickCallback);
        $(document).on('keydown', this.keydownCallback);
        this.eventHub.emit('optPipelineViewOpened', this.compilerInfo.compilerId);
        this.eventHub.emit('requestSettings');
        this.emitOptions(true);
        this.passesFilter.on('input', _.debounce(this.onFiltersChange.bind(this), 250));
        this.updateButtons();
    }

    upgradeStateFields() {
        // `selectedGroup` replaces `selectedFunction`
        if (this.state.selectedFunction) {
            this.state.selectedGroup = this.state.selectedFunction;
            delete this.state.selectedFunction;
        }
    }

    override initializeStateDependentProperties(state: OptPipelineViewState & MonacoPaneState) {
        // The compiler value is fetched async via postInit. Reads of `this.compiler`
        // during synchronous construction (createEditor → getMonacoLanguage,
        // updateButtons) will see null and fall back to defaults; postInit re-applies
        // the correct values once the fetch resolves.
        this.compiler = null;
        const {lang: langId, compiler: compilerId} = state;
        if (langId && compilerId) this.postInit(langId, compilerId);
    }

    private async postInit(langId: string, compilerId: string) {
        try {
            const result = await this.hub.compilerService.processFromLangAndCompiler(langId, compilerId);
            this.compiler = result?.compiler ?? null;
            this.updateGroupName();
            this.updateButtons();
            this.updateEditor();
        } catch (e) {
            SentryCapture(e, 'OptPipeline.postInit');
        }
    }

    override getInitialHTML(): string {
        return $('#opt-pipeline').html();
    }

    getMonacoLanguage(): string {
        let monacoLanguage = 'llvm-ir';
        if (this.compiler) {
            monacoLanguage = this.compiler.optPipeline?.monacoLanguage ?? 'llvm-ir';
        }
        return monacoLanguage;
    }

    override createEditor(editorRoot: HTMLElement): void {
        const monacoLanguage = this.getMonacoLanguage();
        this.editor = monaco.editor.createDiffEditor(
            editorRoot,
            extendConfig({
                language: monacoLanguage,
                readOnly: true,
                glyphMargin: true,
                lineNumbersMinChars: 3,
            }),
        );
        this.originalModel = monaco.editor.createModel('', monacoLanguage);
        this.modifiedModel = monaco.editor.createModel('', monacoLanguage);
        this.editor.setModel({original: this.originalModel, modified: this.modifiedModel});
    }

    updateEditor() {
        const monacoLanguage = this.getMonacoLanguage();
        monaco.editor.setModelLanguage(this.originalModel, monacoLanguage);
        monaco.editor.setModelLanguage(this.modifiedModel, monacoLanguage);
    }

    override getPrintName() {
        return '<Unimplemented>';
    }

    override sendPrintData() {
        // nop
    }

    override getDefaultPaneName(): string {
        return 'Opt Pipeline Viewer';
    }

    override registerButtons(state: OptPipelineViewState) {
        super.registerButtons(state);
        this.options = new Toggles(this.domRoot.find('.options'), state as unknown as Record<string, boolean>);
        this.options.on('change', this.onOptionsChange.bind(this));
        this.filters = new Toggles(this.domRoot.find('.filters'), state as unknown as Record<string, boolean>);
        this.filters.on('change', this.onOptionsChange.bind(this));

        this.passesColumnResizer = this.domRoot.find('.passes-column-resizer');
        this.passesColumnResizer.get()[0].addEventListener('mousedown', this.initResizeDrag.bind(this), false);
    }

    updateButtons() {
        if (!this.compiler?.optPipeline) return;

        const {supportedOptions, supportedFilters, initialOptionsState, initialFiltersState} =
            this.compiler.optPipeline;
        if (supportedOptions) {
            for (const key of ['dump-full-module', '-fno-discard-value-names', 'demangle-symbols']) {
                this.options.enableToggle(key, supportedOptions.includes(key));
            }
        }
        if (supportedFilters) {
            for (const key of ['filter-debug-info', 'filter-instruction-metadata']) {
                this.filters.enableToggle(key, supportedFilters.includes(key));
            }
        }
        if (initialOptionsState) {
            for (const key in initialOptionsState) {
                this.options.set(key, initialOptionsState[key]);
            }
        }
        if (initialFiltersState) {
            for (const key in initialFiltersState) {
                this.filters.set(key, initialFiltersState[key]);
            }
        }
    }

    initResizeDrag(e: MouseEvent) {
        // taken from SO
        this.resizeStartX = e.clientX;
        this.resizeStartWidth = Number.parseInt(
            unwrap(document.defaultView).getComputedStyle(this.passesColumn.get()[0]).width,
            10,
        );
        this.resizeDragMoveBind = this.resizeDragMove.bind(this);
        this.resizeDragEndBind = this.resizeDragEnd.bind(this);
        document.documentElement.addEventListener('mousemove', this.resizeDragMoveBind, false);
        document.documentElement.addEventListener('mouseup', this.resizeDragEndBind, false);
    }

    resizeDragMove(e: MouseEvent) {
        let width = this.resizeStartWidth + e.clientX - this.resizeStartX;
        if (width < MIN_SIDEBAR_WIDTH) {
            width = MIN_SIDEBAR_WIDTH;
        }
        this.passesColumn.get()[0].style.width = width + 'px';
        this.state.sidebarWidth = width;
        this.resize();
    }

    resizeDragEnd(e: MouseEvent) {
        document.documentElement.removeEventListener('mousemove', this.resizeDragMoveBind, false);
        document.documentElement.removeEventListener('mouseup', this.resizeDragEndBind, false);
    }

    emitOptions(force = false) {
        const options = this.options.get();
        const filters = this.filters.get();
        // TODO: Make use of filter-inconsequential-passes on the back end? Maybe provide a specific function arg to
        // the backend? Would be a data transfer optimization.
        const newOptions: OptPipelineBackendOptions = {
            //'filter-inconsequential-passes': options['filter-inconsequential-passes'],
            filterDebugInfo: filters['filter-debug-info'],
            filterIRMetadata: filters['filter-instruction-metadata'],
            fullModule: options['dump-full-module'],
            noDiscardValueNames: options['-fno-discard-value-names'],
            demangle: options['demangle-symbols'],
            libraryFunctions: options['library-functions'],
        };
        let changed = false;
        for (const k in newOptions) {
            const key = k as keyof OptPipelineBackendOptions;
            if (newOptions[key] !== this.lastOptions[key]) {
                changed = true;
            }
        }
        this.lastOptions = newOptions;
        if (changed || force) {
            this.eventHub.emit('optPipelineViewOptionsUpdated', this.compilerInfo.compilerId, newOptions, true);
        }
    }

    onOptionsChange() {
        // Redo pass sidebar
        this.selectGroup(this.state.selectedGroup);
        // Inform compiler of the options
        this.emitOptions();
    }

    override onCompileResult(compilerId: number, compiler: CompilerInfo, result: CompilationResult): void {
        if (this.compilerInfo.compilerId !== compilerId) return;
        if (result.optPipelineOutput) {
            const output: OptPipelineOutput = unwrap(result.optPipelineOutput);
            if (output.error) {
                this.editor
                    .getModel()
                    ?.original.setValue(
                        `<An error occurred while generating the optimization pipeline output: ${output.error}>`,
                    );
                this.editor.getModel()?.modified.setValue('');
            }
            this.updateResults(output.results);
        } else if (compiler.optPipeline) {
            this.updateResults({});
            this.editor.getModel()?.original.setValue('<Error>');
            this.editor.getModel()?.modified.setValue('');
        }
    }

    override onCompiler(
        compilerId: number,
        compiler: CompilerInfo | null,
        options: string,
        editorId: number,
        treeId: number,
    ): void {
        if (this.compilerInfo.compilerId !== compilerId) return;
        this.compilerInfo.compilerName = compiler ? compiler.name : '';
        this.compilerInfo.editorId = editorId;
        this.compilerInfo.treeId = treeId;
        this.updateTitle();
        this.compiler = compiler;
        this.updateGroupName();
        this.updateButtons();
        this.updateEditor();
        if (compiler && !compiler.optPipeline) {
            //this.editor.setValue('<Opt pipeline output is not supported for this compiler>');
        }
    }

    updateGroupName() {
        if (!this.compiler) return;
        const groupNameText = this.compiler.optPipeline?.groupName || 'Function';
        this.groupName.text(`${groupNameText}: `);
    }

    updateResults(results: OptPipelineResults): void {
        this.results = results;
        //const groups = Object.keys(result);
        let selectedGroup = this.state.selectedGroup; // one of the .clear calls below will end up resetting this
        this.groupSelector.clear();
        this.groupSelector.clearOptions();
        const keys = Object.keys(results);
        if (keys.length === 0) {
            this.groupSelector.addOption({
                title: '<No groups available>',
                value: '<No groups available>',
            });
        }
        for (const fn of keys) {
            this.groupSelector.addOption({
                title: fn,
                value: fn,
            });
        }
        this.passesList.empty();
        if (keys.length > 0) {
            if (selectedGroup === '' || !(selectedGroup in results)) {
                selectedGroup = keys[0];
            }
            this.groupSelector.setValue(selectedGroup);
        } else {
            // restore this.selectedGroup, next time the compilation results aren't errors the selected group will
            // still be the same
            this.state.selectedGroup = selectedGroup;
        }
    }

    selectGroup(name: string) {
        this.state.selectedGroup = name;
        if (!(name in this.results)) {
            return;
        }
        const filterInconsequentialPasses = this.filters.get()['filter-inconsequential-passes'];
        const passes = this.results[name];
        this.passesList.empty();
        let isFirstMachinePass = true;
        const newPasses: any = [];
        for (const [i, pass] of passes.entries()) {
            if (filterInconsequentialPasses && !pass.irChanged) {
                continue;
            }
            let className = pass.irChanged ? 'changed' : '';
            if (pass.machine && isFirstMachinePass) {
                className += ' firstMachinePass';
                isFirstMachinePass = false;
            }
            newPasses.push({
                name: pass.name,
                div: `<div data-i="${i}" class="pass ${className}">${escapeHTML(pass.name)}</div>`,
            });
        }
        this.filterPasses(newPasses);
        const passDivs = this.passesList.find('.pass');
        passDivs.on('click', e => {
            const target = e.target;
            this.passesList.find('.active').removeClass('active');
            $(target).addClass('active');
            this.displayPass(Number.parseInt(unwrap(target.getAttribute('data-i')), 10));
        });
        // try to select a pass
        if (this.state.selectedIndex >= passes.length) {
            this.state.selectedIndex = 0;
        }
        const selectedPassDiv = this.passesList.find(`[data-i=${this.state.selectedIndex}]`);
        selectedPassDiv.addClass('active');
        // displayPass updates state
        this.displayPass(this.state.selectedIndex);
        // if loading from a url center the active pass
        if (this.firstResults) {
            this.firstResults = false;
            const activePass = this.passesList.find('.active').get(0);
            if (activePass) {
                scrollIntoView(activePass, {
                    scrollMode: 'if-needed',
                    block: 'center',
                });
            }
        }
    }

    private filterPasses(newPasses: any) {
        const filterValue = unwrapString(this.passesFilter.val()).trim();
        if (filterValue === '') {
            for (const pass of newPasses) {
                this.passesList.append(pass.div);
            }
        } else {
            const searcher = new sifter.Sifter(newPasses, {diacritics: false});
            const filteredPasses = searcher.search(filterValue, {
                fields: ['name'],
                conjunction: 'and',
                sort: 'name',
            });
            for (const result of filteredPasses.items) {
                this.passesList.append(newPasses[result.id].div);
            }
        }
    }

    displayPass(i: number) {
        if (this.state.selectedGroup in this.results && i < this.results[this.state.selectedGroup].length) {
            this.state.selectedIndex = i;
            const pass = this.results[this.state.selectedGroup][i];
            const before = pass.before.map(x => x.text).join('\n');
            const after = pass.after.map(x => x.text).join('\n');
            this.editor.getModel()?.original.setValue(before);
            this.editor.getModel()?.modified.setValue(after);
            this.updateState();
        }
    }

    onClickCallback(e: JQuery.ClickEvent) {
        this.isPassListSelected = unwrap(this.passesList.get(0)).contains(e.target);
    }

    onKeydownCallback(e: JQuery.KeyDownEvent) {
        if (this.isPassListSelected) {
            if (e.key === 'ArrowUp') {
                e.preventDefault();
                const active = this.passesList.find('.active');
                const prev = active.prev().get(0);
                if (prev) {
                    active.removeClass('active');
                    $(prev).addClass('active');
                    scrollIntoView(prev, {
                        scrollMode: 'if-needed',
                        block: 'nearest',
                    });
                    this.displayPass(Number.parseInt(unwrap(prev.getAttribute('data-i')), 10));
                }
            }
            if (e.key === 'ArrowDown') {
                e.preventDefault();
                const active = this.passesList.find('.active');
                const next = active.next().get(0);
                if (next) {
                    active.removeClass('active');
                    $(next).addClass('active');
                    scrollIntoView(next, {
                        scrollMode: 'if-needed',
                        block: 'nearest',
                    });
                    this.displayPass(Number.parseInt(unwrap(next.getAttribute('data-i')), 10));
                }
            }
        }
    }

    onFiltersChange(_: any): void {
        this.selectGroup(this.state.selectedGroup);
    }

    override getCurrentState() {
        return {
            ...this.options.get(),
            ...this.filters.get(),
            ...super.getCurrentState(),
            selectedGroup: this.state.selectedGroup,
            selectedIndex: this.state.selectedIndex,
            sidebarWidth: this.state.sidebarWidth,
        };
    }

    override resize() {
        _.defer(() => {
            const topBarHeight = utils.updateAndCalcTopBarHeight(this.domRoot, this.topBar, this.hideable);
            const otherWidth = unwrap(this.passesColumn.width()) + unwrap(this.passesColumnResizer.width());
            const domWidth = unwrap(this.domRoot.width());
            if (otherWidth > domWidth) {
                this.passesColumn.get()[0].style.width = domWidth + 'px';
            }
            this.editor.layout({
                width: domWidth - otherWidth,
                height: unwrap(this.domRoot.height()) - topBarHeight,
            });
            unwrap(this.body.get(0)).style.height = unwrap(this.domRoot.height()) - topBarHeight + 'px';
        });
    }

    override close(): void {
        $(document).off('click', this.clickCallback);
        $(document).off('keydown', this.keydownCallback);
        this.eventHub.unsubscribe();
        this.eventHub.emit('optPipelineViewClosed', this.compilerInfo.compilerId);
        this.editor.dispose();
    }
}
