// 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 JSZip from 'jszip';
import path from 'path-browserify';
import _ from 'underscore';

import {unwrap} from '../shared/assert.js';
import {FiledataPair} from '../types/compilation/compilation.interfaces.js';
import {LanguageKey} from '../types/languages.interfaces.js';
import {Hub} from './hub.js';
import {languagesService} from './services/languages.service.js';
import {Alert} from './widgets/alert.js';

export interface MultifileFile {
    fileId: number;
    isIncluded: boolean;
    isOpen: boolean;
    isMainSource: boolean;
    filename: string;
    content: string;
    editorId: number;
    langId: LanguageKey;
}

export interface MultifileServiceState {
    isCMakeProject: boolean;
    compilerLanguageId: LanguageKey;
    files: MultifileFile[];
    newFileId: number;
}

export class MultifileService {
    private files: Array<MultifileFile>;
    private compilerLanguageId: LanguageKey;
    private isCMakeProject: boolean;
    private hub: Hub;
    private newFileId: number;
    private alertSystem: any;
    private validExtraFilenameExtensions: string[];
    private readonly defaultLangIdUnknownExt: LanguageKey;
    private readonly cmakeLangId: LanguageKey;
    private readonly cmakeMainSourceFilename: string;
    private readonly maxFilesize: number;

    constructor(hub: Hub, alertSystem: Alert, state: MultifileServiceState) {
        this.hub = hub;
        this.alertSystem = alertSystem;

        this.isCMakeProject = state.isCMakeProject || false;
        this.compilerLanguageId = state.compilerLanguageId;
        this.files = state.files || [];
        this.newFileId = state.newFileId || 1;

        this.validExtraFilenameExtensions = ['.txt', '.md', '.rst', '.sh', '.cmake', '.in'];
        this.defaultLangIdUnknownExt = 'c++';
        this.cmakeLangId = 'cmake';
        this.cmakeMainSourceFilename = 'CMakeLists.txt';
        this.maxFilesize = 1024000;
    }

    private static isHiddenFile(filename: string): boolean {
        return filename.length > 0 && filename[0] === '.';
    }

    private isValidFilename(filename: string): boolean {
        if (MultifileService.isHiddenFile(filename)) return false;

        const filenameExt = path.extname(filename);
        if (this.validExtraFilenameExtensions.includes(filenameExt)) {
            return true;
        }

        return _.any(languagesService.getLanguagesOrFail(), lang => {
            return lang.extensions.includes(filenameExt);
        });
    }

    private isCMakeFile(filename: string): boolean {
        const filenameExt = path.extname(filename);
        if (filenameExt === '.cmake' || filenameExt === '.in') {
            return true;
        }

        return path.basename(filename) === this.cmakeMainSourceFilename;
    }

    private getLanguageIdFromFilename(filename: string): LanguageKey {
        const filenameExt = path.extname(filename);

        const possibleLang = _.filter(languagesService.getLanguagesOrFail(), lang => {
            return lang.extensions.includes(filenameExt);
        });

        if (possibleLang.length > 0) {
            const sorted = _.sortBy(possibleLang, a => {
                return a.extensions.indexOf(filenameExt);
            });
            return sorted[0].id;
        }

        if (this.isCMakeFile(filename)) {
            return this.cmakeLangId;
        }

        return this.defaultLangIdUnknownExt;
    }

    public async loadProjectFromFile(f, callback) {
        this.files = [];
        this.newFileId = 1;

        const zipFilename = path.basename(f.name, '.zip');
        const mainSourcefilename = this.getDefaultMainCMakeFilename();
        const zip = await JSZip.loadAsync(f);

        zip.forEach(async (relativePath, zipEntry) => {
            if (!zipEntry.dir) {
                let removeFromName = 0;
                if (relativePath.indexOf(zipFilename + '/') === 0) {
                    removeFromName = zipFilename.length + 1;
                }

                const properName = relativePath.substring(removeFromName);
                if (!this.isValidFilename(properName)) {
                    return;
                }

                let content = await unwrap(zip.file(zipEntry.name)).async('string');
                if (content.length > this.maxFilesize) {
                    return;
                }

                // remove utf8-bom characters
                content = content.replace(/^(\ufeff)/, '');

                const file: MultifileFile = {
                    fileId: this.newFileId,
                    filename: properName,
                    isIncluded: true,
                    isOpen: false,
                    editorId: -1,
                    isMainSource: properName === mainSourcefilename,
                    content: content,
                    langId: this.getLanguageIdFromFilename(properName),
                };

                this.addFile(file);
                callback(file);
            }
        });
    }

    public async saveProjectToZipfile(callback: (any) => void) {
        const zip = new JSZip();

        this.forEachFile((file: MultifileFile) => {
            if (file.isIncluded) {
                zip.file(file.filename, this.getFileContents(file));
            }
        });

        zip.generateAsync({type: 'blob'}).then(
            blob => {
                callback(blob);
            },
            err => {
                throw err;
            },
        );
    }

    public getState(): MultifileServiceState {
        return {
            isCMakeProject: this.isCMakeProject,
            compilerLanguageId: this.compilerLanguageId,
            files: this.files,
            newFileId: this.newFileId,
        };
    }

    public getLanguageId() {
        return this.compilerLanguageId;
    }

    public isCompatibleWithCMake(): boolean {
        return (
            this.compilerLanguageId === 'c++' ||
            this.compilerLanguageId === 'c' ||
            this.compilerLanguageId === 'fortran' ||
            this.compilerLanguageId === 'cuda' ||
            this.compilerLanguageId === 'assembly'
        );
    }

    public setLanguageId(id: LanguageKey) {
        this.compilerLanguageId = id;
    }

    public isACMakeProject(): boolean {
        return this.isCompatibleWithCMake() && this.isCMakeProject;
    }

    public setAsCMakeProject(yes: boolean) {
        this.isCMakeProject = yes;
    }

    private checkFileEditor(file?: MultifileFile) {
        if (file && file.editorId > 0) {
            const editor = this.hub.getEditorById(file.editorId);
            if (!editor) {
                file.isOpen = false;
                file.editorId = -1;
            }
        }
    }

    public getFileContents(file: MultifileFile) {
        this.checkFileEditor(file);

        if (file.isOpen) {
            const editor = this.hub.getEditorById(file.editorId);
            return editor?.getSource() ?? '';
        }
        return file.content;
    }

    public isEditorPartOfProject(editorId: number) {
        const found = this.files.find((file: MultifileFile) => {
            return file.isIncluded && file.isOpen && editorId === file.editorId;
        });

        return !!found;
    }

    public getFileByFileId(fileId: number): MultifileFile | undefined {
        const file = this.files.find((file: MultifileFile) => {
            return file.fileId === fileId;
        });

        this.checkFileEditor(file);

        return file;
    }

    public setAsMainSource(mainFileId: number) {
        for (const file of this.files) {
            file.isMainSource = false;
        }

        const mainfile = this.getFileByFileId(mainFileId);
        if (mainfile) {
            mainfile.isMainSource = true;
        }
    }

    public getFiles(): Array<FiledataPair> {
        const filtered = this.files.filter((file: MultifileFile) => {
            return !file.isMainSource && file.isIncluded;
        });

        return filtered.map((file: MultifileFile) => {
            return {
                filename: file.filename,
                contents: this.getFileContents(file),
            };
        });
    }

    private isMainSourceFile(file: MultifileFile): boolean {
        if (this.isCMakeProject) {
            if (file.filename === this.getDefaultMainCMakeFilename()) {
                this.setAsMainSource(file.fileId);
            } else {
                return false;
            }
        } else {
            if (this.compilerLanguageId === 'pascal') {
                if (file.filename.endsWith('.dpr')) {
                    this.setAsMainSource(file.fileId);
                } else {
                    return false;
                }
            } else {
                if (file.filename === MultifileService.getDefaultMainSourceFilename(this.compilerLanguageId)) {
                    this.setAsMainSource(file.fileId);
                } else {
                    return false;
                }
            }
        }

        return file.isMainSource;
    }

    public getMainSource(): string {
        const mainFile = this.files.find((file: MultifileFile) => {
            return file.isIncluded && this.isMainSourceFile(file);
        });

        if (mainFile) {
            return this.getFileContents(mainFile);
        }
        return '';
    }

    public getFileByEditorId(editorId: number): MultifileFile | undefined {
        return this.files.find((file: MultifileFile) => {
            return file.editorId === editorId;
        });
    }

    public getEditorIdByFilename(filename: string | null): number | null {
        const file = this.files.find((file: MultifileFile) => {
            return file.isIncluded && file.filename === filename;
        });

        return file && file.editorId > 0 ? file.editorId : null;
    }

    private getFileByFilename(filename: string): MultifileFile | undefined {
        return this.files.find((file: MultifileFile) => {
            return file.filename === filename;
        });
    }

    public getMainSourceEditorId(): number | null {
        const file = this.files.find((file: MultifileFile) => {
            return file.isIncluded && this.isMainSourceFile(file);
        });

        this.checkFileEditor(file);

        return file && file.editorId > 0 ? file.editorId : null;
    }

    private addFile(file: MultifileFile) {
        this.newFileId++;
        this.files.push(file);
    }

    public addFileForEditorId(editorId: number) {
        const file: MultifileFile = {
            fileId: this.newFileId,
            isIncluded: false,
            isOpen: true,
            isMainSource: false,
            filename: '',
            content: '',
            editorId: editorId,
            langId: 'c++',
        };

        this.addFile(file);
    }

    public removeFileByFileId(fileId: number): MultifileFile | undefined {
        const file = this.getFileByFileId(fileId);
        if (file) {
            this.files = this.files.filter((obj: MultifileFile) => obj.fileId !== fileId);
        }
        return file;
    }

    public removeFileByFilename(filename: string): MultifileFile | undefined {
        const file = this.getFileByFilename(filename);
        if (file) {
            this.files = this.files.filter((obj: MultifileFile) => obj.fileId !== file.fileId);
        }
        return file;
    }

    public async excludeByFileId(fileId: number): Promise<void> {
        const file = this.getFileByFileId(fileId);
        if (file) {
            file.isIncluded = false;
        }
    }

    public async includeByFileId(fileId: number): Promise<void> {
        const file = this.getFileByFileId(fileId);
        if (file) {
            file.isIncluded = true;

            if (file.filename === '') {
                const isRenamed = await this.renameFile(fileId);
                if (isRenamed) {
                    await this.includeByFileId(fileId);
                } else {
                    file.isIncluded = false;
                }
            } else {
                file.isIncluded = true;
            }
        }
    }

    public async includeByEditorId(editorId: number): Promise<void> {
        const file = this.getFileByEditorId(editorId);
        if (file) {
            return this.includeByFileId(file.fileId);
        }
        return Promise.reject(new Error(`File not found for editorId: ${editorId}`));
    }

    public forEachOpenFile(callback: (File) => void) {
        for (const file of this.files) {
            if (file.isOpen && file.editorId > 0) {
                callback(file);
            }
        }
    }

    public forEachFile(callback: (File) => void) {
        for (const file of this.files) {
            callback(file);
        }
    }

    private getDefaultMainCMakeFilename() {
        return this.cmakeMainSourceFilename;
    }

    private static getDefaultMainSourceFilename(langId) {
        const languages = languagesService.getLanguagesOrFail();
        // Language may be missing if the link was shared from an instance with
        // a language no longer configured here. Fall back to a c++-style name.
        const ext0 = languages[langId]?.extensions[0] ?? '.cpp';
        return 'example' + ext0;
    }

    private getSuggestedFilename(file: MultifileFile, editor: any): string {
        let suggestedFilename = file.filename;
        if (file.filename === '') {
            let langId: string = file.langId;
            if (editor) {
                langId = editor.currentLanguage.id;
                if (editor.filename) {
                    suggestedFilename = editor.filename;
                }
            }

            if (!suggestedFilename) {
                if (langId === this.cmakeLangId) {
                    suggestedFilename = this.getDefaultMainCMakeFilename();
                } else {
                    suggestedFilename = MultifileService.getDefaultMainSourceFilename(langId);
                }
            }
        }

        return suggestedFilename;
    }

    public fileExists(filename: string, excludeFile?: MultifileFile): boolean {
        return this.files.some((file: MultifileFile) => {
            if (excludeFile && file === excludeFile) return false;

            return file.filename === filename;
        });
    }

    public addNewTextFile(filename: string, content: string) {
        const file: MultifileFile = {
            fileId: this.newFileId,
            isIncluded: false,
            isOpen: false,
            isMainSource: false,
            filename: filename,
            content: content,
            editorId: -1,
            langId: this.getLanguageIdFromFilename(filename),
        };

        this.addFile(file);
    }

    public async renameFile(fileId: number): Promise<boolean> {
        const file = this.getFileByFileId(fileId);
        if (!file) return Promise.reject(new Error(`File could not be found for fileId: ${fileId}`));

        let editor: any = null;
        if (file.isOpen && file.editorId > 0) {
            editor = this.hub.getEditorById(file.editorId);
        }

        const suggestedFilename = this.getSuggestedFilename(file, editor);

        return new Promise(resolve => {
            this.alertSystem.enterSomething('Rename file', 'Please enter new filename', suggestedFilename, {
                yes: (value: string) => {
                    if (value !== '' && value[0] !== '/') {
                        if (!this.fileExists(value, file)) {
                            file.filename = value;

                            // The rename click opened the editor if it was closed
                            if (file.isOpen && file.editorId > 0) {
                                editor = this.hub.getEditorById(file.editorId);
                                if (editor) {
                                    editor.setFilename(file.filename);
                                }
                            }
                            resolve(true);
                        } else {
                            this.alertSystem.alert('Rename file', 'Filename already exists');
                            resolve(false);
                        }
                    } else {
                        this.alertSystem.alert('Rename file', 'Filename cannot be empty or start with a "/"');
                        resolve(false);
                    }
                },
                no: () => {
                    resolve(false);
                },
                yesClass: 'btn btn-primary',
                yesHtml: 'Rename',
                noClass: 'btn-outline-info',
                noHtml: 'Cancel',
            });
        });
    }

    public async renameFileByEditorId(editorId: number): Promise<boolean> {
        const file = this.getFileByEditorId(editorId);
        if (file) {
            return this.renameFile(file.fileId);
        }
        return Promise.reject(new Error(`File not found for editorId: ${editorId}`));
    }
}
