import * as documents from "./_namespaces/documents.js";
import * as fakes from "./_namespaces/fakes.js";
import * as Harness from "./_namespaces/Harness.js";
import * as ts from "./_namespaces/ts.js";
import * as Utils from "./_namespaces/Utils.js";
import * as vfs from "./_namespaces/vfs.js";
import * as vpath from "./_namespaces/vpath.js";

// Test case is json of below type in tests/cases/project/
interface ProjectRunnerTestCase {
    scenario: string;
    projectRoot: string; // project where it lives - this also is the current directory when compiling
    inputFiles: readonly string[]; // list of input files to be given to program
    resolveMapRoot?: boolean; // should we resolve this map root and give compiler the absolute disk path as map root?
    resolveSourceRoot?: boolean; // should we resolve this source root and give compiler the absolute disk path as map root?
    baselineCheck?: boolean; // Verify the baselines of output files, if this is false, we will write to output to the disk but there is no verification of baselines
    runTest?: boolean; // Run the resulting test
    bug?: string; // If there is any bug associated with this test case
}

interface ProjectRunnerTestCaseResolutionInfo extends ProjectRunnerTestCase {
    // Apart from actual test case the results of the resolution
    resolvedInputFiles: readonly string[]; // List of files that were asked to read by compiler
    emittedFiles: readonly string[]; // List of files that were emitted by the compiler
}

interface CompileProjectFilesResult {
    configFileSourceFiles: readonly ts.SourceFile[];
    moduleKind: ts.ModuleKind;
    program?: ts.Program;
    compilerOptions?: ts.CompilerOptions;
    errors: readonly ts.Diagnostic[];
    sourceMapData?: readonly ts.SourceMapEmitResult[];
}

interface BatchCompileProjectTestCaseResult extends CompileProjectFilesResult {
    outputFiles?: readonly documents.TextDocument[];
}

export class ProjectRunner extends Harness.RunnerBase {
    public enumerateTestFiles(): string[] {
        const all = this.enumerateFiles("tests/cases/project", /\.json$/, { recursive: true });
        if (Harness.shards === 1) {
            return all;
        }
        return all.filter((_val, idx) => idx % Harness.shards === (Harness.shardId - 1));
    }

    public kind(): Harness.TestRunnerKind {
        return "project";
    }

    public initializeTests(): void {
        describe("projects tests", () => {
            const tests = this.tests.length === 0 ? this.enumerateTestFiles() : this.tests;
            for (const test of tests) {
                this.runProjectTestCase(test);
            }
        });
    }

    private runProjectTestCase(testCaseFileName: string) {
        for (const { name, payload } of ProjectTestCase.getConfigurations(testCaseFileName)) {
            describe("Compiling project for " + payload.testCase.scenario + ": testcase " + testCaseFileName + (name ? ` (${name})` : ``), () => {
                let projectTestCase: ProjectTestCase | undefined;
                before(() => {
                    projectTestCase = new ProjectTestCase(testCaseFileName, payload);
                });
                it(`Correct module resolution tracing for ${testCaseFileName}`, () => projectTestCase && projectTestCase.verifyResolution());
                it(`Correct errors for ${testCaseFileName}`, () => projectTestCase && projectTestCase.verifyDiagnostics());
                it(`Correct JS output for ${testCaseFileName}`, () => projectTestCase && projectTestCase.verifyJavaScriptOutput());
                // NOTE: This check was commented out in previous code. Leaving this here to eventually be restored if needed.
                // it(`Correct sourcemap content for ${testCaseFileName}`, () => projectTestCase && projectTestCase.verifySourceMapRecord());
                it(`Correct declarations for ${testCaseFileName}`, () => projectTestCase && projectTestCase.verifyDeclarations());
                after(() => {
                    projectTestCase = undefined;
                });
            });
        }
    }
}

class ProjectCompilerHost extends fakes.CompilerHost {
    private _testCase: ProjectRunnerTestCase & ts.CompilerOptions;
    private _projectParseConfigHost: ProjectParseConfigHost | undefined;

    constructor(sys: fakes.System | vfs.FileSystem, compilerOptions: ts.CompilerOptions, _testCaseJustName: string, testCase: ProjectRunnerTestCase & ts.CompilerOptions, _moduleKind: ts.ModuleKind) {
        super(sys, compilerOptions);
        this._testCase = testCase;
    }

    public override get parseConfigHost(): fakes.ParseConfigHost {
        return this._projectParseConfigHost || (this._projectParseConfigHost = new ProjectParseConfigHost(this.sys, this._testCase));
    }

    public override getDefaultLibFileName(_options: ts.CompilerOptions) {
        return vpath.resolve(this.getDefaultLibLocation(), "lib.es5.d.ts");
    }
}

class ProjectParseConfigHost extends fakes.ParseConfigHost {
    private _testCase: ProjectRunnerTestCase & ts.CompilerOptions;

    constructor(sys: fakes.System, testCase: ProjectRunnerTestCase & ts.CompilerOptions) {
        super(sys);
        this._testCase = testCase;
    }

    public override readDirectory(path: string, extensions: string[], excludes: string[], includes: string[], depth: number): string[] {
        const result = super.readDirectory(path, extensions, excludes, includes, depth);
        const projectRoot = vpath.resolve(vfs.srcFolder, this._testCase.projectRoot);
        return result.map(item =>
            vpath.relative(
                projectRoot,
                vpath.resolve(projectRoot, item),
                this.vfs.ignoreCase,
            )
        );
    }
}

interface ProjectTestConfiguration {
    name: string;
    payload: ProjectTestPayload;
}

interface ProjectTestPayload {
    testCase: ProjectRunnerTestCase & ts.CompilerOptions;
    moduleKind: ts.ModuleKind;
    vfs: vfs.FileSystem;
}

class ProjectTestCase {
    private testCase: ProjectRunnerTestCase & ts.CompilerOptions;
    private testCaseJustName: string;
    private sys: fakes.System;
    private compilerOptions: ts.CompilerOptions;
    private compilerResult: BatchCompileProjectTestCaseResult;

    constructor(testCaseFileName: string, { testCase, moduleKind, vfs }: ProjectTestPayload) {
        this.testCase = testCase;
        this.testCaseJustName = testCaseFileName.replace(/^.*[\\/]/, "").replace(/\.json/, "");
        this.compilerOptions = createCompilerOptions(testCase, moduleKind);
        this.sys = new fakes.System(vfs);

        let configFileName: string | undefined;
        let inputFiles = testCase.inputFiles;
        if (this.compilerOptions.project) {
            // Parse project
            configFileName = ts.normalizePath(ts.combinePaths(this.compilerOptions.project, "tsconfig.json"));
            assert(!inputFiles || inputFiles.length === 0, "cannot specify input files and project option together");
        }
        else if (!inputFiles || inputFiles.length === 0) {
            configFileName = ts.findConfigFile("", path => this.sys.fileExists(path));
        }

        let errors: ts.Diagnostic[] | undefined;
        const configFileSourceFiles: ts.SourceFile[] = [];
        if (configFileName) {
            const result = ts.readJsonConfigFile(configFileName, path => this.sys.readFile(path));
            configFileSourceFiles.push(result);
            const configParseHost = new ProjectParseConfigHost(this.sys, this.testCase);
            const configParseResult = ts.parseJsonSourceFileConfigFileContent(result, configParseHost, ts.getDirectoryPath(configFileName), this.compilerOptions);
            inputFiles = configParseResult.fileNames;
            this.compilerOptions = configParseResult.options;
            errors = [...result.parseDiagnostics, ...configParseResult.errors];
        }

        const compilerHost = new ProjectCompilerHost(this.sys, this.compilerOptions, this.testCaseJustName, this.testCase, moduleKind);
        const projectCompilerResult = this.compileProjectFiles(moduleKind, configFileSourceFiles, () => inputFiles, compilerHost, this.compilerOptions);

        this.compilerResult = {
            configFileSourceFiles,
            moduleKind,
            program: projectCompilerResult.program,
            compilerOptions: this.compilerOptions,
            sourceMapData: projectCompilerResult.sourceMapData,
            outputFiles: compilerHost.outputs,
            errors: errors ? ts.concatenate(errors, projectCompilerResult.errors) : projectCompilerResult.errors,
        };
    }

    private get vfs() {
        return this.sys.vfs;
    }

    public static getConfigurations(testCaseFileName: string): ProjectTestConfiguration[] {
        let testCase: ProjectRunnerTestCase & ts.CompilerOptions;

        let testFileText: string | undefined;
        try {
            testFileText = Harness.IO.readFile(testCaseFileName);
        }
        catch (e) {
            assert(false, "Unable to open testcase file: " + testCaseFileName + ": " + e.message);
        }

        try {
            testCase = JSON.parse(testFileText!) as ProjectRunnerTestCase & ts.CompilerOptions;
        }
        catch (e) {
            throw assert(false, "Testcase: " + testCaseFileName + " does not contain valid json format: " + e.message);
        }

        function makeFileSystem() {
            const fs = vfs.createFromFileSystem(Harness.IO, /*ignoreCase*/ false);
            fs.mountSync(vpath.resolve(Harness.IO.getWorkspaceRoot(), "tests"), vpath.combine(vfs.srcFolder, "tests"), vfs.createResolver(Harness.IO));
            fs.mkdirpSync(vpath.combine(vfs.srcFolder, testCase.projectRoot));
            fs.chdir(vpath.combine(vfs.srcFolder, testCase.projectRoot));
            fs.makeReadonly();
            return fs;
        }
        let fs: vfs.FileSystem | undefined;
        return [
            {
                name: `@module: commonjs`,
                payload: {
                    testCase,
                    moduleKind: ts.ModuleKind.CommonJS,
                    get vfs() {
                        return fs ??= makeFileSystem();
                    },
                },
            },
            {
                name: `@module: amd`,
                payload: {
                    testCase,
                    moduleKind: ts.ModuleKind.AMD,
                    get vfs() {
                        return fs ??= makeFileSystem();
                    },
                },
            },
        ];
    }

    public verifyResolution() {
        const cwd = this.vfs.cwd();
        const ignoreCase = this.vfs.ignoreCase;
        const resolutionInfo: ProjectRunnerTestCaseResolutionInfo & ts.CompilerOptions = JSON.parse(JSON.stringify(this.testCase));
        resolutionInfo.resolvedInputFiles = this.compilerResult.program!.getSourceFiles()
            .map(({ fileName: input }) =>
                vpath.beneath(vfs.builtFolder, input, this.vfs.ignoreCase) || vpath.beneath(vfs.testLibFolder, input, this.vfs.ignoreCase) ? Utils.removeTestPathPrefixes(input) :
                    vpath.isAbsolute(input) ? vpath.relative(cwd, input, ignoreCase) :
                    input
            );

        resolutionInfo.emittedFiles = this.compilerResult.outputFiles!
            .map(output => output.meta.get("fileName") || output.file)
            .map(output => Utils.removeTestPathPrefixes(vpath.isAbsolute(output) ? vpath.relative(cwd, output, ignoreCase) : output));

        const content = JSON.stringify(resolutionInfo, undefined, "    ");
        Harness.Baseline.runBaseline(this.getBaselineFolder(this.compilerResult.moduleKind) + this.testCaseJustName + ".json", content);
    }

    public verifyDiagnostics() {
        if (this.compilerResult.errors.length) {
            Harness.Baseline.runBaseline(this.getBaselineFolder(this.compilerResult.moduleKind) + this.testCaseJustName + ".errors.txt", getErrorsBaseline(this.compilerResult));
        }
    }

    public verifyJavaScriptOutput() {
        if (this.testCase.baselineCheck) {
            const errs: Error[] = [];
            let nonSubfolderDiskFiles = 0;
            for (const output of this.compilerResult.outputFiles!) {
                try {
                    // convert file name to rooted name
                    // if filename is not rooted - concat it with project root and then expand project root relative to current directory
                    const fileName = output.meta.get("fileName") || output.file;
                    const diskFileName = vpath.isAbsolute(fileName) ? fileName : vpath.resolve(this.vfs.cwd(), fileName);

                    // compute file name relative to current directory (expanded project root)
                    let diskRelativeName = vpath.relative(this.vfs.cwd(), diskFileName, this.vfs.ignoreCase);
                    if (vpath.isAbsolute(diskRelativeName) || diskRelativeName.startsWith("../")) {
                        // If the generated output file resides in the parent folder or is rooted path,
                        // we need to instead create files that can live in the project reference folder
                        // but make sure extension of these files matches with the fileName the compiler asked to write
                        diskRelativeName = `diskFile${nonSubfolderDiskFiles}${vpath.extname(fileName, [".js.map", ".js", ".d.ts"], this.vfs.ignoreCase)}`;
                        nonSubfolderDiskFiles++;
                    }

                    const content = Utils.removeTestPathPrefixes(output.text, /*retainTrailingDirectorySeparator*/ true);
                    Harness.Baseline.runBaseline(this.getBaselineFolder(this.compilerResult.moduleKind) + diskRelativeName, content as string | null); // eslint-disable-line no-restricted-syntax
                }
                catch (e) {
                    errs.push(e);
                }
            }

            if (errs.length) {
                throw Error(errs.join("\n     "));
            }
        }
    }

    public verifySourceMapRecord() {
        // NOTE: This check was commented out in previous code. Leaving this here to eventually be restored if needed.
        // if (compilerResult.sourceMapData) {
        //     Harness.Baseline.runBaseline(getBaselineFolder(compilerResult.moduleKind) + testCaseJustName + ".sourcemap.txt", () => {
        //         return Harness.SourceMapRecorder.getSourceMapRecord(compilerResult.sourceMapData, compilerResult.program,
        //             ts.filter(compilerResult.outputFiles, outputFile => Harness.Compiler.isJS(outputFile.emittedFileName)));
        //     });
        // }
    }

    public verifyDeclarations() {
        if (!this.compilerResult.errors.length && this.testCase.declaration) {
            const dTsCompileResult = this.compileDeclarations(this.compilerResult);
            if (dTsCompileResult && dTsCompileResult.errors.length) {
                Harness.Baseline.runBaseline(this.getBaselineFolder(this.compilerResult.moduleKind) + this.testCaseJustName + ".dts.errors.txt", getErrorsBaseline(dTsCompileResult));
            }
        }
    }

    // Project baselines verified go in project/testCaseName/moduleKind/
    private getBaselineFolder(moduleKind: ts.ModuleKind) {
        return "project/" + this.testCaseJustName + "/" + moduleNameToString(moduleKind) + "/";
    }

    private cleanProjectUrl(url: string) {
        let diskProjectPath = ts.normalizeSlashes(Harness.IO.resolvePath(this.testCase.projectRoot)!);
        let projectRootUrl = "file:///" + diskProjectPath;
        const normalizedProjectRoot = ts.normalizeSlashes(this.testCase.projectRoot);
        diskProjectPath = diskProjectPath.substr(0, diskProjectPath.lastIndexOf(normalizedProjectRoot));
        projectRootUrl = projectRootUrl.substr(0, projectRootUrl.lastIndexOf(normalizedProjectRoot));
        if (url && url.length) {
            if (url.indexOf(projectRootUrl) === 0) {
                // replace the disk specific project url path into project root url
                url = "file:///" + url.substr(projectRootUrl.length);
            }
            else if (url.indexOf(diskProjectPath) === 0) {
                // Replace the disk specific path into the project root path
                url = url.substr(diskProjectPath.length);
                if (url.charCodeAt(0) !== ts.CharacterCodes.slash) {
                    url = "/" + url;
                }
            }
        }

        return url;
    }

    private compileProjectFiles(moduleKind: ts.ModuleKind, configFileSourceFiles: readonly ts.SourceFile[], getInputFiles: () => readonly string[], compilerHost: ts.CompilerHost, compilerOptions: ts.CompilerOptions): CompileProjectFilesResult {
        const program = ts.createProgram(getInputFiles(), compilerOptions, compilerHost);
        const errors = ts.getPreEmitDiagnostics(program);

        const { sourceMaps: sourceMapData, diagnostics: emitDiagnostics } = program.emit();

        // Clean up source map data that will be used in baselining
        if (sourceMapData) {
            for (const data of sourceMapData) {
                data.sourceMap = {
                    ...data.sourceMap,
                    sources: data.sourceMap.sources.map(source => this.cleanProjectUrl(source)),
                    sourceRoot: data.sourceMap.sourceRoot && this.cleanProjectUrl(data.sourceMap.sourceRoot),
                };
            }
        }

        return {
            configFileSourceFiles,
            moduleKind,
            program,
            errors: ts.concatenate(errors, emitDiagnostics),
            sourceMapData,
        };
    }

    private compileDeclarations(compilerResult: BatchCompileProjectTestCaseResult) {
        if (!compilerResult.program) {
            return;
        }

        const compilerOptions = compilerResult.program.getCompilerOptions();
        const allInputFiles: documents.TextDocument[] = [];
        const rootFiles: string[] = [];
        ts.forEach(compilerResult.program.getSourceFiles(), sourceFile => {
            if (sourceFile.isDeclarationFile) {
                if (!vpath.isDefaultLibrary(sourceFile.fileName)) {
                    allInputFiles.unshift(new documents.TextDocument(sourceFile.fileName, sourceFile.text));
                }
                rootFiles.unshift(sourceFile.fileName);
            }
            else if (!(compilerOptions.outFile)) {
                let emitOutputFilePathWithoutExtension: string | undefined;
                if (compilerOptions.outDir) {
                    let sourceFilePath = ts.getNormalizedAbsolutePath(sourceFile.fileName, compilerResult.program!.getCurrentDirectory());
                    sourceFilePath = sourceFilePath.replace(compilerResult.program!.getCommonSourceDirectory(), "");
                    emitOutputFilePathWithoutExtension = ts.removeFileExtension(ts.combinePaths(compilerOptions.outDir, sourceFilePath));
                }
                else {
                    emitOutputFilePathWithoutExtension = ts.removeFileExtension(sourceFile.fileName);
                }

                const outputDtsFileName = emitOutputFilePathWithoutExtension + ts.Extension.Dts;
                const file = findOutputDtsFile(outputDtsFileName);
                if (file) {
                    allInputFiles.unshift(file);
                    rootFiles.unshift(file.meta.get("fileName") || file.file);
                }
            }
            else {
                const outputDtsFileName = ts.removeFileExtension(compilerOptions.outFile) + ts.Extension.Dts;
                const outputDtsFile = findOutputDtsFile(outputDtsFileName)!;
                if (!ts.contains(allInputFiles, outputDtsFile)) {
                    allInputFiles.unshift(outputDtsFile);
                    rootFiles.unshift(outputDtsFile.meta.get("fileName") || outputDtsFile.file);
                }
            }
        });

        const _vfs = vfs.createFromFileSystem(Harness.IO, /*ignoreCase*/ false, {
            documents: allInputFiles,
            cwd: vpath.combine(vfs.srcFolder, this.testCase.projectRoot),
        });

        // Dont allow config files since we are compiling existing source options
        const compilerHost = new ProjectCompilerHost(_vfs, compilerResult.compilerOptions!, this.testCaseJustName, this.testCase, compilerResult.moduleKind);
        return this.compileProjectFiles(compilerResult.moduleKind, compilerResult.configFileSourceFiles, () => rootFiles, compilerHost, compilerResult.compilerOptions!);

        function findOutputDtsFile(fileName: string) {
            return ts.forEach(compilerResult.outputFiles, outputFile => outputFile.meta.get("fileName") === fileName ? outputFile : undefined);
        }
    }
}

function moduleNameToString(moduleKind: ts.ModuleKind) {
    return moduleKind === ts.ModuleKind.AMD ? "amd" :
        moduleKind === ts.ModuleKind.CommonJS ? "node" : "none";
}

function getErrorsBaseline(compilerResult: CompileProjectFilesResult) {
    const inputSourceFiles = compilerResult.configFileSourceFiles.slice();
    if (compilerResult.program) {
        for (const sourceFile of compilerResult.program.getSourceFiles()) {
            if (!Harness.isDefaultLibraryFile(sourceFile.fileName)) {
                inputSourceFiles.push(sourceFile);
            }
        }
    }

    const inputFiles = inputSourceFiles.map<Harness.Compiler.TestFile>(sourceFile => ({
        unitName: ts.isRootedDiskPath(sourceFile.fileName) ?
            Harness.RunnerBase.removeFullPaths(sourceFile.fileName) :
            sourceFile.fileName,
        content: sourceFile.text,
    }));

    return Harness.Compiler.getErrorBaseline(inputFiles, compilerResult.errors);
}

function createCompilerOptions(testCase: ProjectRunnerTestCase & ts.CompilerOptions, moduleKind: ts.ModuleKind) {
    // Set the special options that depend on other testcase options
    const compilerOptions: ts.CompilerOptions = {
        noErrorTruncation: false,
        skipDefaultLibCheck: false,
        moduleResolution: ts.ModuleResolutionKind.Classic,
        module: moduleKind,
        newLine: ts.NewLineKind.CarriageReturnLineFeed,
        mapRoot: testCase.resolveMapRoot && testCase.mapRoot
            ? vpath.resolve(vfs.srcFolder, testCase.mapRoot)
            : testCase.mapRoot,

        sourceRoot: testCase.resolveSourceRoot && testCase.sourceRoot
            ? vpath.resolve(vfs.srcFolder, testCase.sourceRoot)
            : testCase.sourceRoot,
    };

    // Set the values specified using json
    const optionNameMap = ts.arrayToMap(ts.optionDeclarations, option => option.name);
    for (const name in testCase) {
        if (name !== "mapRoot" && name !== "sourceRoot") {
            const option = optionNameMap.get(name);
            if (option) {
                const optType = option.type;
                let value = testCase[name] as any;
                if (!ts.isString(optType)) {
                    const key = value.toLowerCase();
                    const optTypeValue = optType.get(key);
                    if (optTypeValue) {
                        value = optTypeValue;
                    }
                }
                compilerOptions[option.name] = value;
            }
        }
    }

    return compilerOptions;
}
