// 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 $ from 'jquery';

import {assert, unwrapString} from '../shared/assert.js';
import {isString, keys} from '../shared/common-utils.js';
import {LanguageKey} from '../types/languages.interfaces.js';
import * as colour from './colour.js';
import {AppTheme, ColourScheme, ColourSchemeInfo} from './colour.js';
import {EventHub} from './event-hub.js';
import {Hub} from './hub.js';
import {localStorage} from './local.js';
import {options} from './options.js';
import {languagesService} from './services/languages.service.js';
import {Themes, themes} from './themes.js';

export type FormatBase = 'Google' | 'LLVM' | 'Mozilla' | 'Chromium' | 'WebKit' | 'Microsoft' | 'GNU';

export type Keymap = 'vscode' | 'jetbrains';

export interface SiteSettings {
    autoCloseBrackets: boolean;
    autoCloseQuotes: boolean;
    autoSurround: boolean;
    autoIndent: boolean;
    allowStoreCodeDebug: boolean;
    alwaysEnableAllSchemes: boolean;
    colouriseAsm: boolean;
    colourScheme: ColourScheme;
    compileOnChange: boolean;
    defaultLanguage?: LanguageKey;
    autoDelayBeforeCompile: boolean;
    delayAfterChange: number;
    enableCodeLens: boolean;
    colouriseBrackets: boolean;
    enableCommunityAds: boolean;
    enableCtrlS: string;
    enableSharingPopover: boolean;
    enableCtrlStree: boolean;
    editorsFFont: string;
    editorsFLigatures: boolean;
    executorCompileOnChange: boolean;
    shakeStatusIconOnWarnings: boolean;
    defaultFontScale?: number; // the font scale widget can check this setting before the default has been populated
    formatBase: FormatBase;
    formatOnCompile: boolean;
    hoverShowAsmDoc: boolean;
    hoverShowSource: boolean;
    indefiniteLineHighlight: boolean;
    keepMultipleTabs: boolean;
    keepSourcesOnLangChange: boolean;
    keymap: Keymap;
    newEditorLastLang: boolean;
    showMinimap: boolean;
    showQuickSuggestions: boolean;
    tabWidth: number;
    theme: Themes | undefined;
    useCustomContextMenu: boolean;
    useSpaces: boolean;
    useVim: boolean;
    wordWrap: boolean;
    relativeLineNumbers: boolean;
}

class BaseSetting {
    constructor(
        public elem: JQuery,
        public name: string,
    ) {}

    // Can be undefined if the element doesn't exist which is the case in embed mode
    protected val(): string | number | string[] | undefined {
        return this.elem.val();
    }

    getUi(): any {
        return this.val();
    }

    putUi(value: any): void {
        this.elem.val(value);
    }
}

class Checkbox extends BaseSetting {
    override getUi(): boolean {
        return !!this.elem.prop('checked');
    }

    override putUi(value: any) {
        this.elem.prop('checked', !!value);
    }
}

class Select extends BaseSetting {
    constructor(elem: JQuery, name: string, populate: {label: string; desc: string}[]) {
        super(elem, name);

        elem.empty();
        for (const e of populate) {
            elem.append($(`<option value="${e.label}">${e.desc}</option>`));
        }
    }

    override putUi(value: string | number | boolean | null) {
        this.elem.val(value?.toString() ?? '');
    }
}

class NumericSelect extends Select {
    override getUi(): number {
        return Number(this.val());
    }
}

interface SliderSettings {
    min: number;
    max: number;
    step: number;
    display: JQuery;
    formatter: (arg: number) => string;
}

class Slider extends BaseSetting {
    private readonly formatter: (arg: number) => string;
    private display: JQuery;
    private max: number;
    private min: number;

    constructor(elem: JQuery, name: string, sliderSettings: SliderSettings) {
        super(elem, name);

        this.formatter = sliderSettings.formatter;
        this.display = sliderSettings.display;

        this.max = sliderSettings.max || 100;
        this.min = sliderSettings.min || 1;

        elem.prop('max', this.max)
            .prop('min', this.min)
            .prop('step', sliderSettings.step || 1);

        elem.on('change', this.updateDisplay.bind(this));
    }

    override putUi(value: number) {
        this.elem.val(value);
        this.updateDisplay();
    }

    override getUi(): number {
        return Number.parseInt(this.val()?.toString() ?? '0', 10);
    }

    private updateDisplay() {
        this.display.text(this.formatter(this.getUi()));
    }
}

class Textbox extends BaseSetting {}

class Numeric extends BaseSetting {
    private readonly min: number;
    private readonly max: number;

    constructor(elem: JQuery, name: string, params: Record<'min' | 'max', number>) {
        super(elem, name);

        this.min = params.min;
        this.max = params.max;

        elem.attr('min', this.min).attr('max', this.max);
    }

    override getUi(): number {
        return this.clampValue(Number.parseInt(this.val()?.toString() ?? '0', 10));
    }

    override putUi(value: number) {
        this.elem.val(this.clampValue(value));
    }

    private clampValue(value: number): number {
        return Math.min(Math.max(value, this.min), this.max);
    }
}

export class Settings {
    private readonly settingsObjs: BaseSetting[];
    private eventHub: EventHub;

    // The delay between a change and re-compilation is a substantial factor in site traffic load.
    // We want to be able to control it centrally - but only for users that didn't explicitly set it.
    // This is the place.
    private defaultDelayAfterChange = 2000;

    constructor(
        hub: Hub,
        private root: JQuery,
        private settings: SiteSettings,
        private onChange: (siteSettings: SiteSettings) => void,
        private subLangId: string | undefined,
    ) {
        this.eventHub = hub.createEventHub();
        this.settings = settings;
        this.settingsObjs = [];

        this.addCheckboxes();
        this.addSelectors();
        this.addSliders();
        this.addNumerics();
        this.addTextBoxes();

        // The color scheme dropdown needs to be populated otherwise the .val won't stick and it'll default
        this.fillColourSchemeSelector(this.root.find('.colourScheme'), this.settings.theme);
        this.setSettings(this.settings);
        this.handleThemes();

        this.eventHub.on('settingsChange', this.onSettingsChange.bind(this));
    }

    public static getStoredSettings(): SiteSettings {
        return JSON.parse(localStorage.get('settings', '{}'));
    }

    public static setStoredSettings(newSettings: SiteSettings) {
        localStorage.set('settings', JSON.stringify(newSettings));
    }

    public setSettings(newSettings: SiteSettings) {
        this.onSettingsChange(newSettings);
        this.onChange(newSettings);
    }

    private onUiChange() {
        for (const setting of this.settingsObjs) {
            this.settings[setting.name] = setting.getUi();
        }
        this.onChange(this.settings);
    }

    private onSettingsChange(settings: SiteSettings) {
        this.settings = settings;
        if (this.settings.autoDelayBeforeCompile) {
            this.settings.delayAfterChange = this.defaultDelayAfterChange;
        }
        const delaySliderElement = this.root.find('.delay');
        delaySliderElement.prop('disabled', this.settings.autoDelayBeforeCompile);

        for (const setting of this.settingsObjs) {
            setting.putUi(this.settings[setting.name]);
        }
    }

    private add<T extends BaseSetting>(setting: T, defaultValue: any) {
        const key = setting.name;
        if (this.settings[key] === undefined) this.settings[key] = defaultValue;
        this.settingsObjs.push(setting);
        setting.elem.on('change', this.onUiChange.bind(this));
    }

    private addCheckboxes() {
        // Known checkbox options in order [selector, key, defaultValue]
        const checkboxes: [string, keyof SiteSettings, boolean][] = [
            ['.allowStoreCodeDebug', 'allowStoreCodeDebug', true],
            ['.alwaysEnableAllSchemes', 'alwaysEnableAllSchemes', false],
            ['.autoCloseBrackets', 'autoCloseBrackets', true],
            ['.autoCloseQuotes', 'autoCloseQuotes', true],
            ['.autoSurround', 'autoSurround', true],
            ['.autoIndent', 'autoIndent', true],
            ['.colourise', 'colouriseAsm', true],
            ['.colouriseBrackets', 'colouriseBrackets', true],
            ['.compileOnChange', 'compileOnChange', true],
            ['.autoDelayBeforeCompile', 'autoDelayBeforeCompile', true],
            ['.editorsFLigatures', 'editorsFLigatures', false],
            ['.enableCodeLens', 'enableCodeLens', true],
            ['.enableCommunityAds', 'enableCommunityAds', true],
            ['.enableCtrlStree', 'enableCtrlStree', true],
            ['.enableSharingPopover', 'enableSharingPopover', true],
            ['.executorCompileOnChange', 'executorCompileOnChange', true],
            ['.shakeStatusIconOnWarnings', 'shakeStatusIconOnWarnings', true],
            ['.formatOnCompile', 'formatOnCompile', false],
            ['.hoverShowAsmDoc', 'hoverShowAsmDoc', true],
            ['.hoverShowSource', 'hoverShowSource', true],
            ['.indefiniteLineHighlight', 'indefiniteLineHighlight', false],
            ['.keepMultipleTabs', 'keepMultipleTabs', false],
            ['.keepSourcesOnLangChange', 'keepSourcesOnLangChange', false],
            ['.newEditorLastLang', 'newEditorLastLang', true],
            ['.showMinimap', 'showMinimap', true],
            ['.showQuickSuggestions', 'showQuickSuggestions', false],
            ['.useCustomContextMenu', 'useCustomContextMenu', true],
            ['.useSpaces', 'useSpaces', true],
            ['.useVim', 'useVim', false],
            ['.wordWrap', 'wordWrap', false],
            ['.relativeLineNumbers', 'relativeLineNumbers', false],
        ];

        for (const [selector, name, defaultValue] of checkboxes) {
            this.add(new Checkbox(this.root.find(selector), name), defaultValue);
        }
    }

    private addSelectors() {
        const addSelector = <Name extends keyof SiteSettings>(
            selector: string,
            name: Name,
            populate: {label: string; desc: string}[],
            defaultValue: SiteSettings[Name],
            component = Select,
        ) => {
            const instance = new component(this.root.find(selector), name, populate);
            this.add(instance, defaultValue);
            return instance;
        };

        // We need theme data to populate the colour schemes; We don't add the selector until later
        const themesData = keys(themes).map((theme: Themes) => {
            return {label: themes[theme].id, desc: themes[theme].name};
        });
        const defaultThemeId = themes.system.id;

        const colourSchemesData = colour.schemes
            .filter(scheme => this.isSchemeUsable(scheme, defaultThemeId))
            .map(scheme => ({label: scheme.name, desc: scheme.desc}));
        let defaultColourScheme = colour.schemes[0].name;
        if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
            defaultColourScheme = 'gray-shade';
        }
        addSelector('.colourScheme', 'colourScheme', colourSchemesData, defaultColourScheme);

        // Now add the theme selector
        addSelector('.theme', 'theme', themesData, defaultThemeId);

        const langs = languagesService.getLanguagesOrFail();
        const defaultLanguageSelector = this.root.find('.defaultLanguage');
        const defLang = this.settings.defaultLanguage || Object.keys(langs)[0] || 'c++';

        const defaultLanguageData = Object.keys(langs).map(lang => {
            return {label: langs[lang].id, desc: langs[lang].name};
        });
        defaultLanguageData.sort((a, b) => a.desc.localeCompare(b.desc));
        addSelector('.defaultLanguage', 'defaultLanguage', defaultLanguageData, defLang as LanguageKey);

        if (this.subLangId) {
            defaultLanguageSelector
                .prop('disabled', true)
                .prop('title', 'Default language inherited from subdomain')
                .css('cursor', 'not-allowed');
        }

        const defaultFontScale = options.defaultFontScale;
        const fontScales: {label: string; desc: string}[] = [];
        for (let i = 8; i <= 30; i++) {
            fontScales.push({label: i.toString(), desc: i.toString()});
        }
        const defaultFontScaleSelector = addSelector(
            '.defaultFontScale',
            'defaultFontScale',
            fontScales,
            defaultFontScale,
            NumericSelect,
        ).elem;
        defaultFontScaleSelector.on('change', e => {
            assert(e.target instanceof HTMLSelectElement);
            this.eventHub.emit('broadcastFontScale', Number.parseInt(e.target.value, 10));
        });

        const formats: FormatBase[] = ['Google', 'LLVM', 'Mozilla', 'Chromium', 'WebKit', 'Microsoft', 'GNU'];
        const formatsData = formats.map(format => {
            return {label: format, desc: format};
        });
        addSelector('.formatBase', 'formatBase', formatsData, formats[0]);

        const enableCtrlSData = [
            {label: 'true', desc: 'Save To Local File'},
            {label: 'false', desc: 'Create Short Link'},
            {label: '2', desc: 'Reformat code'},
            {label: '3', desc: 'Do nothing'},
            {label: '4', desc: 'Compile'},
        ];
        addSelector('.enableCtrlS', 'enableCtrlS', enableCtrlSData, 'true');

        const keymapData: {label: Keymap; desc: string}[] = [
            {label: 'vscode', desc: 'VS Code'},
            {label: 'jetbrains', desc: 'JetBrains'},
        ];
        addSelector('.keymap', 'keymap', keymapData, 'vscode');
    }

    private addSliders() {
        // Handle older settings
        if (localStorage.get('checkedAutoDelay', 'false') === 'false') {
            if (this.settings.delayAfterChange === 0 || this.settings.delayAfterChange === 750) {
                // 750 was the default before we added the autoDelayBeforeCompile checkbox
                // 0 was something much older. Check those values to handle older settings
                this.settings.autoDelayBeforeCompile = true;
            }
            localStorage.set('checkedAutoDelay', 'true');
        }

        if (this.settings.autoDelayBeforeCompile) {
            this.settings.delayAfterChange = this.defaultDelayAfterChange;
        }

        const delayAfterChangeSettings: SliderSettings = {
            max: 3000,
            step: 250,
            min: 250,
            display: this.root.find('.delay-current-value'),
            formatter: x => (x / 1000.0).toFixed(2) + 's',
        };
        this.add(
            new Slider(this.root.find('.delay'), 'delayAfterChange', delayAfterChangeSettings),
            this.settings.delayAfterChange,
        );
    }

    private addNumerics() {
        this.add(
            new Numeric(this.root.find('.tabWidth'), 'tabWidth', {
                min: 1,
                max: 80,
            }),
            4,
        );
    }

    private addTextBoxes() {
        this.add(
            new Textbox(this.root.find('.editorsFFont'), 'editorsFFont'),
            'Consolas, "Liberation Mono", Courier, monospace',
        );
    }

    private handleThemes() {
        const themeSelect = this.root.find('.theme');
        themeSelect.on('change', () => {
            this.onThemeChange();
            $.data(themeSelect, 'last-theme', unwrapString(themeSelect.val()));
        });

        const colourSchemeSelect = this.root.find('.colourScheme');
        colourSchemeSelect.on('change', _e => {
            const currentTheme = this.settings.theme;
            $.data(themeSelect, 'theme-' + currentTheme, unwrapString<ColourScheme>(colourSchemeSelect.val()));
        });

        const enableAllSchemesCheckbox = this.root.find('.alwaysEnableAllSchemes');
        enableAllSchemesCheckbox.on('change', this.onThemeChange.bind(this));

        // In embed mode themeSelect.length can be zero and thus themeSelect.val() isn't a string
        // TODO(jeremy-rifkin) Is last-theme ever read? Can it just be removed?
        $.data(themeSelect, 'last-theme', themeSelect.val() ?? '');
        this.onThemeChange();
    }

    private fillColourSchemeSelector(colourSchemeSelect: JQuery, theme?: AppTheme) {
        colourSchemeSelect.empty();
        if (theme === 'system') {
            if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
                theme = themes.dark.id;
            } else {
                theme = themes.default.id;
            }
        }
        for (const scheme of colour.schemes) {
            if (this.isSchemeUsable(scheme, theme)) {
                colourSchemeSelect.append($(`<option value="${scheme.name}">${scheme.desc}</option>`));
            }
        }
    }

    private isSchemeUsable(scheme: ColourSchemeInfo, newTheme?: AppTheme): boolean {
        return (
            this.settings.alwaysEnableAllSchemes ||
            scheme.themes.length === 0 ||
            (newTheme && scheme.themes.includes(newTheme)) ||
            scheme.themes.includes('all')
        );
    }

    private selectorHasOption(selector: JQuery, option: string): boolean {
        return selector.children(`[value=${option}]`).length > 0;
    }

    private onThemeChange() {
        // We can be called when:
        // Site is initializing (settings and dropdowns are already done)
        // "Make all colour schemes available" changes
        // Selected theme changes
        const themeSelect = this.root.find('.theme');
        const colourSchemeSelect = this.root.find('.colourScheme');

        const oldScheme = colourSchemeSelect.val() as colour.AppTheme | undefined;
        const newTheme = themeSelect.val() as colour.AppTheme | undefined;

        // Small check to make sure we aren't getting something completely unexpected, like a string[] or number
        assert(
            isString(oldScheme) || oldScheme === undefined || oldScheme == null,
            'Unexpected value received from colourSchemeSelect.val()',
        );
        assert(
            isString(newTheme) || newTheme === undefined || newTheme == null,
            'Unexpected value received from colourSchemeSelect.val()',
        );

        this.fillColourSchemeSelector(colourSchemeSelect, newTheme);
        const newThemeStoredScheme = $.data(themeSelect, 'theme-' + newTheme) as colour.AppTheme | undefined;

        // If nothing else, set the new scheme to the first of the available ones
        let newScheme = colourSchemeSelect.first().val() as colour.AppTheme | undefined;
        // If we have one old one stored, check if it's still valid and set it if so
        if (newThemeStoredScheme && this.selectorHasOption(colourSchemeSelect, newThemeStoredScheme)) {
            newScheme = newThemeStoredScheme;
        } else if (isString(oldScheme) && this.selectorHasOption(colourSchemeSelect, oldScheme)) {
            newScheme = oldScheme;
        }

        if (newScheme) {
            colourSchemeSelect.val(newScheme);
        }

        colourSchemeSelect.trigger('change');
    }
}
