// 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 _ from 'underscore';
import {LRUCache} from 'lru-cache';
import {EventEmitter} from 'golden-layout';

import {options} from './options.js';

import {ResultLine} from '../types/resultline/resultline.interfaces.js';

import jqXHR = JQuery.jqXHR;
import ErrorTextStatus = JQuery.Ajax.ErrorTextStatus;
import {CompilerInfo} from '../types/compiler.interfaces.js';
import {CompilationResult, FiledataPair} from '../types/compilation/compilation.interfaces.js';
import {CompilationStatus} from './compiler-service.interfaces.js';
import {IncludeDownloads, SourceAndFiles} from './download-service.js';
import {SentryCapture} from './sentry.js';
import {SiteSettings} from './settings.js';

const ASCII_COLORS_RE = new RegExp(/\x1B\[[\d;]*m(.\[K)?/g);

export class CompilerService {
    private readonly base = window.httpRoot;
    private allowStoreCodeDebug: boolean;
    private cache: LRUCache<string, CompilationResult>;
    private readonly compilersByLang: Record<string, Record<string, CompilerInfo>>;

    constructor(eventHub: EventEmitter) {
        this.allowStoreCodeDebug = true;
        this.cache = new LRUCache({
            maxSize: 200 * 1024,
            sizeCalculation: n => JSON.stringify(n).length,
        });

        this.compilersByLang = {};

        for (const compiler of options.compilers) {
            if (!(compiler.lang in this.compilersByLang)) this.compilersByLang[compiler.lang] = {};
            this.compilersByLang[compiler.lang][compiler.id] = compiler;
        }

        eventHub.on(
            'settingsChange',
            (newSettings: SiteSettings) => (this.allowStoreCodeDebug = newSettings.allowStoreCodeDebug),
        );
    }

    private getDefaultCompilerForLang(langId: string) {
        return options.defaultCompiler[langId];
    }

    public processFromLangAndCompiler(
        langId: string | null,
        compilerId: string,
    ): {langId: string | null; compiler: CompilerInfo | null} | null {
        try {
            if (langId) {
                if (!compilerId) {
                    compilerId = this.getDefaultCompilerForLang(langId);
                }

                const foundCompiler = this.findCompiler(langId, compilerId);
                if (!foundCompiler) {
                    const compilers = Object.values(this.getCompilersForLang(langId) ?? {});
                    if (compilers.length > 0) {
                        return {
                            compiler: compilers[0],
                            langId: langId,
                        };
                    } else {
                        return {
                            // There were no compilers, so return null, the selection will show up empty
                            compiler: null,
                            langId: langId,
                        };
                    }
                } else {
                    return {
                        compiler: foundCompiler,
                        langId: langId,
                    };
                }
            } else if (compilerId) {
                const matchingCompilers = Object.values(options.languages).map(lang => {
                    const compiler = this.findCompiler(lang.id, compilerId);
                    if (compiler) {
                        return {
                            langId: lang.id,
                            compiler: compiler,
                        };
                    }
                    return null;
                });
                // Ensure that if no compiler is present, we return null instead of undefined
                return matchingCompilers.find(compiler => compiler !== null) ?? null;
            } else {
                const languages = Object.values(options.languages);
                if (languages.length > 0) {
                    const firstLang = languages[0];
                    return this.processFromLangAndCompiler(firstLang.id, compilerId);
                } else {
                    // TODO: What now? No languages loaded
                    return null;
                }
            }
        } catch (e) {
            SentryCapture(e, 'processFromLangAndCompiler');
        }
        // TODO: What now? Found no compilers!
        return {
            langId: langId,
            compiler: null,
        };
    }

    public getGroupsInUse(langId: string): {value: string; label: string}[] {
        return _.uniq(Object.values(this.getCompilersForLang(langId) ?? {}), false, compiler => compiler.group)
            .map(compiler => {
                return {value: compiler.group, label: compiler.groupName || compiler.group};
            })
            .sort((a, b) =>
                a.label.localeCompare(b.label, undefined /* Ignore language */, {
                    sensitivity: 'base',
                }),
            );
    }

    getCompilersForLang(langId: string): Record<string, CompilerInfo> | undefined {
        return langId in this.compilersByLang ? this.compilersByLang[langId] : undefined;
    }

    private findCompilerInList(compilers: Record<string, CompilerInfo>, compilerId: string) {
        if (compilerId in compilers) {
            return compilers[compilerId];
        }
        for (const id in compilers) {
            if (compilers[id].alias.includes(compilerId)) {
                return compilers[id];
            }
        }
        return null;
    }

    findCompiler(langId: string, compilerId: string): CompilerInfo | null {
        if (!compilerId) return null;
        const compilers = this.getCompilersForLang(langId) ?? {};
        return this.findCompilerInList(compilers, compilerId);
    }

    private static handleRequestError(
        request: any,
        reject: (reason?: any) => void,
        xhr: jqXHR,
        textStatus: ErrorTextStatus,
        errorThrown: string,
    ) {
        let error = errorThrown;
        if (!error) {
            switch (textStatus) {
                case 'timeout':
                    error = 'Request timed out';
                    break;
                case 'abort':
                    error = 'Request was aborted';
                    break;
                case 'error':
                    switch (xhr.status) {
                        case 500:
                            error = 'Request failed: internal server error';
                            break;
                        case 504:
                            error = 'Request failed: gateway timeout';
                            break;
                        default:
                            error = 'Request failed: HTTP error code ' + xhr.status;
                            break;
                    }
                    break;
                default:
                    error = 'Error sending request';
                    break;
            }
        }
        reject({
            request: request,
            error: error,
        });
    }

    private getBaseUrl() {
        return window.location.origin + this.base;
    }

    public async submit(request: Record<string, any>) {
        request.allowStoreCodeDebug = this.allowStoreCodeDebug;
        const jsonRequest = JSON.stringify(request);
        if (options.doCache && !request.bypassCache) {
            const cachedResult = this.cache.get(jsonRequest);
            if (cachedResult) {
                return {
                    request: request,
                    result: cachedResult,
                    localCacheHit: true,
                };
            }
        }
        return new Promise((resolve, reject) => {
            const compilerId = encodeURIComponent(request.compiler);
            $.ajax({
                type: 'POST',
                url: `${this.getBaseUrl()}api/compiler/${compilerId}/compile`,
                dataType: 'json',
                contentType: 'application/json',
                data: jsonRequest,
                success: result => {
                    if (result && result.okToCache && options.doCache) {
                        this.cache.set(jsonRequest, result);
                    }
                    resolve({
                        request: request,
                        result: result,
                        localCacheHit: false,
                    });
                },
                error: (jqXHR, textStatus, errorThrown) => {
                    CompilerService.handleRequestError(request, reject, jqXHR, textStatus, errorThrown);
                },
            });
        });
    }

    public submitCMake(request: Record<string, any>) {
        request.allowStoreCodeDebug = this.allowStoreCodeDebug;
        const jsonRequest = JSON.stringify(request);
        if (options.doCache && !request.bypassCache) {
            const cachedResult = this.cache.get(jsonRequest);
            if (cachedResult) {
                return Promise.resolve({
                    request: request,
                    result: cachedResult,
                    localCacheHit: true,
                });
            }
        }
        return new Promise((resolve, reject) => {
            const compilerId = encodeURIComponent(request.compiler);
            $.ajax({
                type: 'POST',
                url: `${this.getBaseUrl()}api/compiler/${compilerId}/cmake`,
                dataType: 'json',
                contentType: 'application/json',
                data: jsonRequest,
                success: result => {
                    if (result && result.okToCache && options.doCache) {
                        this.cache.set(jsonRequest, result);
                    }
                    resolve({
                        request: request,
                        result: result,
                        localCacheHit: false,
                    });
                },
                error: (jqXHR, textStatus, errorThrown) => {
                    CompilerService.handleRequestError(request, reject, jqXHR, textStatus, errorThrown);
                },
            });
        });
    }

    public requestPopularArguments(compilerId: string, usedOptions: string) {
        return new Promise((resolve, reject) => {
            $.ajax({
                type: 'POST',
                url: `${this.getBaseUrl()}api/popularArguments/${compilerId}`,
                dataType: 'json',
                data: JSON.stringify({
                    usedOptions: usedOptions,
                    presplit: false,
                }),
                success: result => {
                    resolve({
                        request: compilerId,
                        result: result,
                        localCacheHit: false,
                    });
                },
                error: (jqXHR, textStatus, errorThrown) => {
                    CompilerService.handleRequestError(compilerId, reject, jqXHR, textStatus, errorThrown);
                },
            });
        });
    }

    public async expandToFiles(source: string): Promise<SourceAndFiles> {
        const includesOrEmbeds = new IncludeDownloads();

        const includeOrEmbedFind = /^\s*#\s*(include|embed)\s*["<](https?:\/\/[^">]+)[">]/;
        const lines = source.split('\n');
        for (const idx in lines) {
            const line = lines[idx];
            const match = line.match(includeOrEmbedFind);
            if (match) {
                const download = includesOrEmbeds.include(match[2]);
                lines[idx] = line.replace(includeOrEmbedFind, `#${match[1]} "${download.filename}"`);
            }
        }

        const files: FiledataPair[] = await includesOrEmbeds.allDownloadsAsFileDataPairs();

        return {
            source: lines.join('\n'),
            files: files,
        };
    }

    public static getSelectizerOrder() {
        return [{field: '$order'}, {field: '$score'}, {field: 'name'}];
    }

    public static doesCompilationResultHaveWarnings(result: CompilationResult) {
        // TODO: Types probably need to be updated here
        // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
        const stdout = result.stdout ?? [];
        // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
        const stderr = result.stderr ?? [];
        // TODO: Pass what compiler did this and check if it it's actually skippable
        // Right now we're ignoring outputs that match the input filename
        // Compiler & Executor are capable of giving us the info, but conformance view is not
        if (stdout.length === 1 && stderr.length === 0 && result.inputFilename) {
            // This code is a special case for MSVC which writes the filename to stdout
            // MSVC will use back-slashes, Wine will use forward slashes
            // We could also move this calculation to the server at some point
            const lastSlashPos = _.findLastIndex(result.inputFilename, ch => ch === '\\' || ch === '/');
            return result.inputFilename.substring(lastSlashPos + 1) !== stdout[0].text;
        }
        return stdout.length > 0 || stderr.length > 0;
    }

    public static calculateStatusIcon(result: CompilationResult) {
        let code = 1;
        if (result.code !== 0) {
            code = 3;
        } else if (this.doesCompilationResultHaveWarnings(result)) {
            code = 2;
        }
        return {code: code, compilerOut: result.code};
    }

    private static getAriaLabel(status: CompilationStatus) {
        // Compiling...
        if (status.code === 4) return 'Compiling';
        if (status.compilerOut === 0) {
            // StdErr.length > 0
            if (status.code === 3) return 'Compilation succeeded with errors';
            // StdOut.length > 0
            if (status.code === 2) return 'Compilation succeeded with warnings';
            return 'Compilation succeeded';
        } else {
            // StdErr.length > 0
            if (status.code === 3) return 'Compilation failed with errors';
            // StdOut.length > 0
            if (status.code === 2) return 'Compilation failed with warnings';
            return 'Compilation failed';
        }
    }

    private static getColor(status: CompilationStatus) {
        // Compiling...
        if (status.code === 4) return '#888888';
        if (status.compilerOut === 0) {
            // StdErr.length > 0
            if (status.code === 3) return '#FF6645';
            // StdOut.length > 0
            if (status.code === 2) return '#FF6500';
            return '#12BB12';
        } else {
            // StdErr.length > 0
            if (status.code === 3) return '#FF1212';
            // StdOut.length > 0
            if (status.code === 2) return '#BB8700';
            return '#FF6645';
        }
    }

    public static handleCompilationStatus(
        statusLabel: JQuery | null,
        statusIcon: JQuery | null,
        status: CompilationStatus,
    ) {
        if (statusLabel != null) {
            statusLabel.toggleClass('error', status.code === 3).toggleClass('warning', status.code === 2);
        }

        if (statusIcon != null) {
            statusIcon
                .removeClass()
                .addClass('status-icon fas')
                .css('color', this.getColor(status))
                .toggle(status.code !== 0)
                .attr('aria-label', this.getAriaLabel(status))
                .toggleClass('fa-spinner fa-spin', status.code === 4)
                .toggleClass('fa-times-circle', status.code === 3)
                .toggleClass('fa-check-circle', status.code === 1 || status.code === 2);
        }
    }

    public static handleOutputButtonTitle(element: JQuery, result: CompilationResult) {
        // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
        const stdout = result.stdout ?? [];
        // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
        const stderr = result.stderr ?? [];

        function filterAsciiColors(line: ResultLine) {
            return line.text.replace(ASCII_COLORS_RE, '');
        }

        const output = stdout.map(filterAsciiColors).concat(stderr.map(filterAsciiColors)).join('\n');

        element.prop('title', output);
    }
}
