﻿// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Host;
using Microsoft.CodeAnalysis.Host.Mef;
using Microsoft.CodeAnalysis.Options;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Newtonsoft.Json;
using Roslyn.Utilities;

namespace Microsoft.CodeAnalysis.LanguageServerIndexFormat.Generator
{
    internal static class CompilerInvocation
    {
        public static async Task<Project> CreateFromJsonAsync(string jsonContents)
        {
            var invocationInfo = JsonConvert.DeserializeObject<CompilerInvocationInfo>(jsonContents);
            Assumes.Present(invocationInfo);
            return await CreateFromInvocationInfoAsync(invocationInfo);
        }

        public static async Task<Project> CreateFromInvocationInfoAsync(CompilerInvocationInfo invocationInfo)
        {
            // We will use a Workspace to simplify the creation of the compilation, but will be careful not to return the Workspace instance from this class.
            // We will still provide the language services which are used by the generator itself, but we don't tie it to a Workspace object so we can
            // run this as an in-proc source generator if one day desired.
            var workspace = new AdhocWorkspace(await Composition.CreateHostServicesAsync());

            var languageName = GetLanguageName(invocationInfo);
            var languageServices = workspace.Services.GetLanguageServices(languageName).LanguageServices;

            var mapPath = GetPathMapper(invocationInfo);

            var splitCommandLine = CommandLineParser.SplitCommandLineIntoArguments(invocationInfo.Arguments, removeHashComments: false).ToList();

            // Unfortunately for us there are a few paths that get directly read by the command line parse which we need to remap,
            // such as /ruleset files. So let's go through and process them now.
            for (var i = 0; i < splitCommandLine.Count; i++)
            {
                const string RuleSetSwitch = "/ruleset:";

                if (splitCommandLine[i].StartsWith(RuleSetSwitch, StringComparison.Ordinal))
                {
                    var rulesetPath = splitCommandLine[i][RuleSetSwitch.Length..];

                    var quoted = rulesetPath is ['"', _, .., '"'];
                    if (quoted)
                    {
                        rulesetPath = rulesetPath[1..^1];
                    }

                    rulesetPath = mapPath(rulesetPath);

                    if (quoted)
                    {
                        rulesetPath = "\"" + rulesetPath + "\"";
                    }

                    splitCommandLine[i] = RuleSetSwitch + rulesetPath;
                }
            }

            var commandLineParserService = languageServices.GetRequiredService<ICommandLineParserService>();
            var parsedCommandLine = commandLineParserService.Parse(splitCommandLine, Path.GetDirectoryName(invocationInfo.ProjectFilePath), isInteractive: false, sdkDirectory: null);

            var analyzerLoader = new DefaultAnalyzerAssemblyLoader();

            var projectId = ProjectId.CreateNewId(invocationInfo.ProjectFilePath);

            var projectInfo = ProjectInfo.Create(
                new ProjectInfo.ProjectAttributes(
                    id: projectId,
                    version: VersionStamp.Default,
                    name: Path.GetFileNameWithoutExtension(invocationInfo.ProjectFilePath),
                    assemblyName: parsedCommandLine.CompilationName!,
                    language: languageName,
                    compilationOutputFilePaths: default,
                    checksumAlgorithm: parsedCommandLine.ChecksumAlgorithm,
                    filePath: invocationInfo.ProjectFilePath,
                    outputFilePath: parsedCommandLine.OutputFileName),
                parsedCommandLine.CompilationOptions,
                parsedCommandLine.ParseOptions,
                parsedCommandLine.SourceFiles.Select(s => CreateDocumentInfo(unmappedPath: s.Path)),
                projectReferences: null,
                metadataReferences: parsedCommandLine.MetadataReferences.Select(r => MetadataReference.CreateFromFile(mapPath(r.Reference), r.Properties)),
                additionalDocuments: parsedCommandLine.AdditionalFiles.Select(f => CreateDocumentInfo(unmappedPath: f.Path)),
                analyzerReferences: parsedCommandLine.AnalyzerReferences.Select(r => new AnalyzerFileReference(r.FilePath, analyzerLoader)),
                analyzerConfigDocuments: parsedCommandLine.AnalyzerConfigPaths.Select(CreateDocumentInfo),
                hostObjectType: null);

            var solution = workspace.CurrentSolution.AddProject(projectInfo);
            return solution.GetRequiredProject(projectId);

            // Local methods:
            DocumentInfo CreateDocumentInfo(string unmappedPath)
            {
                var mappedPath = mapPath(unmappedPath);
                return DocumentInfo.Create(
                    DocumentId.CreateNewId(projectId, mappedPath),
                    name: mappedPath,
                    filePath: mappedPath,
                    loader: new WorkspaceFileTextLoader(languageServices.SolutionServices, mappedPath, parsedCommandLine.Encoding));
            }
        }

        private static string GetLanguageName(CompilerInvocationInfo invocationInfo)
        {
            return invocationInfo.Tool switch
            {
                "csc" => LanguageNames.CSharp,
                "vbc" => LanguageNames.VisualBasic,
                _ => throw new NotSupportedException($"Tool '{invocationInfo.Tool}' is not supported."),
            };
        }

        /// <summary>
        /// Given the JSON description, returns a function that will map paths from the original paths to the current paths.
        /// </summary>
        /// <remarks>
        /// The compiler invocation JSON input is allowed to specify a map of file paths. The scenario here is to allow us to
        /// do LSIF indexing on another machine than an original build might have been done on. For example, say the main build process
        /// for a repository has the source synchronized to the S:\source1, but we want to do analysis on a different machine which has
        /// the source in a folder S:\source2. If we have the original compilation command line when it was built under S:\source1, and
        /// know that any time we see S:\source1 we should actually read the file out of S:\source2, then we analyze on a separate machine.
        /// 
        /// This is used to enable some internal-to-Microsoft build environments which have a mechanism to run "analysis" passes like
        /// the LSIF tool independent from the main build machines, and can restore source and build artifacts to provide the environment
        /// that is close enough to match the original.
        /// </remarks>
        private static Func<string, string> GetPathMapper(CompilerInvocationInfo invocationInfo)
        {
            return unmappedPath =>
            {
                foreach (var potentialPathMapping in invocationInfo.PathMappings)
                {
                    // If it's just a file name being mapped, just a direct map
                    if (unmappedPath.Equals(potentialPathMapping.From, StringComparison.OrdinalIgnoreCase))
                    {
                        return potentialPathMapping.To;
                    }

                    // Map arbitrary contents under subdirectories
                    var fromWithDirectorySuffix = AddDirectorySuffixIfMissing(potentialPathMapping.From);

                    if (unmappedPath.StartsWith(fromWithDirectorySuffix, StringComparison.OrdinalIgnoreCase))
                    {
                        // Trim off any leading \, which would happen if you have a path like C:\Directory\\File.cs with a double slash, and happen to be
                        // mapping C:\Directory somewhere.
                        var relativePath = unmappedPath[fromWithDirectorySuffix.Length..].TrimStart('\\');

                        return Path.Combine(AddDirectorySuffixIfMissing(potentialPathMapping.To), relativePath);
                    }
                }

                return unmappedPath;
            };

            static string AddDirectorySuffixIfMissing(string path)
            {
                return path.EndsWith("\\", StringComparison.OrdinalIgnoreCase) ? path : path + "\\";
            }
        }

        /// <summary>
        /// A simple data class that represents the schema for JSON serialization.
        /// </summary>
        public sealed class CompilerInvocationInfo
        {
#nullable disable // this class is used for deserialization by Newtonsoft.Json, so we don't really need warnings about this class itself

            public string Tool { get; set; }

            public string Arguments { get; set; }

            public string ProjectFilePath { get; set; }

            public List<PathMapping> PathMappings { get; set; } = new List<PathMapping>();

            public sealed class PathMapping
            {
                public string From { get; set; }
                public string To { get; set; }
            }

#nullable disable
        }
    }
}
