using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Host;
using Microsoft.CodeAnalysis.MSBuild;
using Microsoft.CodeAnalysis.PooledObjects;
using Roslyn.Utilities;

namespace SharpIDE.Application.Features.Analysis.ProjectLoader;

public partial class CustomMsBuildProjectLoader
{
    private sealed partial class CustomWorker
    {
        private readonly struct ResolvedReferences
        {
            public ImmutableHashSet<ProjectReference> ProjectReferences { get; }
            public ImmutableArray<MetadataReference> MetadataReferences { get; }

            public ResolvedReferences(ImmutableHashSet<ProjectReference> projectReferences, ImmutableArray<MetadataReference> metadataReferences)
            {
                ProjectReferences = projectReferences;
                MetadataReferences = metadataReferences;
            }
        }

        /// <summary>
        /// This type helps produces lists of metadata and project references. Initially, it contains a list of metadata references.
        /// As project references are added, the metadata references that match those project references are removed.
        /// </summary>
        private sealed class ResolvedReferencesBuilder
        {
            /// <summary>
            /// The full list of <see cref="MetadataReference"/>s.
            /// </summary>
            private readonly ImmutableArray<MetadataReference> _metadataReferences;

            /// <summary>
            /// A map of every metadata reference file paths to a set of indices whether than file path
            /// exists in the list. It is expected that there may be multiple metadata references for the
            /// same file path in the case where multiple extern aliases are provided.
            /// </summary>
            private readonly ImmutableDictionary<string, HashSet<int>> _pathToIndicesMap;

            /// <summary>
            /// A set of indices into <see cref="_metadataReferences"/> that are to be removed.
            /// </summary>
            private readonly HashSet<int> _indicesToRemove;

            private readonly ImmutableHashSet<ProjectReference>.Builder _projectReferences;

            public ResolvedReferencesBuilder(IEnumerable<MetadataReference> metadataReferences)
            {
                _metadataReferences = [.. metadataReferences];
                _pathToIndicesMap = CreatePathToIndexMap(_metadataReferences);
                _indicesToRemove = [];
                _projectReferences = ImmutableHashSet.CreateBuilder<ProjectReference>();
            }

            private static ImmutableDictionary<string, HashSet<int>> CreatePathToIndexMap(ImmutableArray<MetadataReference> metadataReferences)
            {
                var builder = ImmutableDictionary.CreateBuilder<string, HashSet<int>>(PathUtilities.Comparer);

                for (var index = 0; index < metadataReferences.Length; index++)
                {
                    var filePath = GetFilePath(metadataReferences[index]);
                    if (filePath != null)
                    {
                        builder.MultiAdd(filePath, index);
                    }
                }

                return builder.ToImmutable();
            }

            private static string? GetFilePath(MetadataReference metadataReference)
            {
                return metadataReference switch
                {
                    PortableExecutableReference portableExecutableReference => portableExecutableReference.FilePath,
                    UnresolvedMetadataReference unresolvedMetadataReference => unresolvedMetadataReference.Reference,
                    _ => null,
                };
            }

            public void AddProjectReference(ProjectReference projectReference)
            {
                _projectReferences.Add(projectReference);
            }

            public void SwapMetadataReferenceForProjectReference(ProjectReference projectReference, params string?[] possibleMetadataReferencePaths)
            {
                foreach (var path in possibleMetadataReferencePaths)
                {
                    if (path != null)
                    {
                        Remove(path);
                    }
                }

                AddProjectReference(projectReference);
            }

            /// <summary>
            /// Returns true if a metadata reference with the given file path is contained within this list.
            /// </summary>
            public bool Contains(string? filePath)
                => filePath != null
                && _pathToIndicesMap.ContainsKey(filePath);

            /// <summary>
            /// Removes the metadata reference with the given file path from this list.
            /// </summary>
            public void Remove(string filePath)
            {
                if (filePath != null && _pathToIndicesMap.TryGetValue(filePath, out var indices))
                {
                    _indicesToRemove.AddRange(indices);
                }
            }

            public ProjectInfo? SelectProjectInfoByOutput(IEnumerable<ProjectInfo> projectInfos)
            {
                foreach (var projectInfo in projectInfos)
                {
                    var outputFilePath = projectInfo.OutputFilePath;
                    var outputRefFilePath = projectInfo.OutputRefFilePath;
                    if (outputFilePath != null &&
                        outputRefFilePath != null &&
                        (Contains(outputFilePath) || Contains(outputRefFilePath)))
                    {
                        return projectInfo;
                    }
                }

                return null;
            }

            public ImmutableArray<UnresolvedMetadataReference> GetUnresolvedMetadataReferences()
            {
                var builder = ImmutableArray.CreateBuilder<UnresolvedMetadataReference>();

                foreach (var metadataReference in GetMetadataReferences())
                {
                    if (metadataReference is UnresolvedMetadataReference unresolvedMetadataReference)
                    {
                        builder.Add(unresolvedMetadataReference);
                    }
                }

                return builder.ToImmutableAndClear();
            }

            private ImmutableArray<MetadataReference> GetMetadataReferences()
            {
                var builder = ImmutableArray.CreateBuilder<MetadataReference>();

                // used to eliminate duplicates
                var _ = PooledHashSet<MetadataReference>.GetInstance(out var set);

                for (var index = 0; index < _metadataReferences.Length; index++)
                {
                    var reference = _metadataReferences[index];
                    if (!_indicesToRemove.Contains(index) && set.Add(reference))
                    {
                        builder.Add(reference);
                    }
                }

                return builder.ToImmutableAndClear();
            }

            private ImmutableHashSet<ProjectReference> GetProjectReferences()
                => _projectReferences.ToImmutable();

            public ResolvedReferences ToResolvedReferences()
                => new(GetProjectReferences(), GetMetadataReferences());
        }

        private async Task<ResolvedReferences> ResolveReferencesAsync(ProjectId id, ProjectFileInfo projectFileInfo, CommandLineArguments commandLineArgs, CancellationToken cancellationToken)
        {
            // First, gather all of the metadata references from the command-line arguments.
            var resolvedMetadataReferences = commandLineArgs.ResolveMetadataReferences(
                new WorkspaceMetadataFileReferenceResolver(
                    metadataService: _solutionServices.GetRequiredService<IMetadataService>(),
                    pathResolver: new RelativePathResolver(commandLineArgs.ReferencePaths, commandLineArgs.BaseDirectory)));

            var builder = new ResolvedReferencesBuilder(resolvedMetadataReferences);

            var projectDirectory = Path.GetDirectoryName(projectFileInfo.FilePath);
            RoslynDebug.AssertNotNull(projectDirectory);

            // Next, iterate through all project references in the file and create project references.
            foreach (var projectFileReference in projectFileInfo.ProjectReferences)
            {
                var aliases = projectFileReference.Aliases;

                if (_pathResolver.TryGetAbsoluteProjectPath(projectFileReference.Path, baseDirectory: projectDirectory, _discoveredProjectOptions.OnPathFailure, out var projectReferencePath))
                {
                    // The easiest case is to add a reference to a project we already know about.
                    if (TryAddReferenceToKnownProject(id, projectReferencePath, aliases, builder))
                    {
                        continue;
                    }

                    if (projectFileReference.ReferenceOutputAssembly)
                    {
                        // If we don't know how to load a project (that is, it's not a language we support), we can still
                        // attempt to verify that its output exists on disk and is included in our set of metadata references.
                        // If it is, we'll just leave it in place.
                        if (!IsProjectLoadable(projectReferencePath) &&
                            await VerifyUnloadableProjectOutputExistsAsync(projectReferencePath, builder, cancellationToken).ConfigureAwait(false))
                        {
                            continue;
                        }

                        // If metadata is preferred, see if the project reference's output exists on disk and is included
                        // in our metadata references. If it is, don't create a project reference; we'll just use the metadata.
                        if (_preferMetadataForReferencesOfDiscoveredProjects &&
                            await VerifyProjectOutputExistsAsync(projectReferencePath, builder, cancellationToken).ConfigureAwait(false))
                        {
                            continue;
                        }

                        // Finally, we'll try to load and reference the project.
                        if (await TryLoadAndAddReferenceAsync(id, projectReferencePath, aliases, builder, cancellationToken).ConfigureAwait(false))
                        {
                            continue;
                        }
                    }
                    else
                    {
                        // Load the project but do not add a reference:
                        _ = await LoadProjectInfosFromPathAsync(projectReferencePath, _discoveredProjectOptions, cancellationToken).ConfigureAwait(false);
                        continue;
                    }
                }

                // We weren't able to handle this project reference, so add it without further processing.
                var unknownProjectId = _projectMap.GetOrCreateProjectId(projectFileReference.Path);
                var newProjectReference = CreateProjectReference(from: id, to: unknownProjectId, aliases);
                builder.AddProjectReference(newProjectReference);
            }

            // Are there still any unresolved metadata references? If so, remove them and report diagnostics.
            foreach (var unresolvedMetadataReference in builder.GetUnresolvedMetadataReferences())
            {
                var filePath = unresolvedMetadataReference.Reference;

                builder.Remove(filePath);

                _diagnosticReporter.Report(new ProjectDiagnostic(
                    WorkspaceDiagnosticKind.Warning,
                    string.Format(WorkspaceMSBuildResources.Unresolved_metadata_reference_removed_from_project_0, filePath),
                    id));
            }

            return builder.ToResolvedReferences();
        }

        private async Task<bool> TryLoadAndAddReferenceAsync(ProjectId id, string projectReferencePath, ImmutableArray<string> aliases, ResolvedReferencesBuilder builder, CancellationToken cancellationToken)
        {
            var projectReferenceInfos = await LoadProjectInfosFromPathAsync(projectReferencePath, _discoveredProjectOptions, cancellationToken).ConfigureAwait(false);

            if (projectReferenceInfos.IsEmpty)
            {
                return false;
            }

            // Find the project reference info whose output we have a metadata reference for.
            ProjectInfo? projectReferenceInfo = null;
            foreach (var info in projectReferenceInfos)
            {
                var outputFilePath = info.OutputFilePath;
                var outputRefFilePath = info.OutputRefFilePath;
                if (outputFilePath != null &&
                    outputRefFilePath != null &&
                    (builder.Contains(outputFilePath) || builder.Contains(outputRefFilePath)))
                {
                    projectReferenceInfo = info;
                    break;
                }
            }

            if (projectReferenceInfo is null)
            {
                // We didn't find the project reference info that matches any of our metadata references.
                // In this case, we'll go ahead and use the first project reference info that was found,
                // but report a warning because this likely means that either a metadata reference path
                // or a project output path is incorrect.

                projectReferenceInfo = projectReferenceInfos[0];

                _diagnosticReporter.Report(new ProjectDiagnostic(
                    WorkspaceDiagnosticKind.Warning,
                    string.Format(WorkspaceMSBuildResources.Found_project_reference_without_a_matching_metadata_reference_0, projectReferencePath),
                    id));
            }

            if (!ProjectReferenceExists(to: id, from: projectReferenceInfo))
            {
                var newProjectReference = CreateProjectReference(from: id, to: projectReferenceInfo.Id, aliases);
                builder.SwapMetadataReferenceForProjectReference(newProjectReference, projectReferenceInfo.OutputRefFilePath, projectReferenceInfo.OutputFilePath);
            }
            else
            {
                // This project already has a reference on us. Don't introduce a circularity by referencing it.
                // However, if the project's output doesn't exist on disk, we need to remove from our list of
                // metadata references to avoid failures later. Essentially, the concern here is that the metadata
                // reference is an UnresolvedMetadataReference, which will throw when we try to create a
                // Compilation with it.

                var outputRefFilePath = projectReferenceInfo.OutputRefFilePath;
                if (outputRefFilePath != null && !File.Exists(outputRefFilePath))
                {
                    builder.Remove(outputRefFilePath);
                }

                var outputFilePath = projectReferenceInfo.OutputFilePath;
                if (outputFilePath != null && !File.Exists(outputFilePath))
                {
                    builder.Remove(outputFilePath);
                }
            }

            // Note that we return true even if we don't actually add a reference due to a circularity because,
            // in that case, we've still handled everything.
            return true;
        }

        private bool IsProjectLoadable(string projectPath)
            => _projectFileExtensionRegistry.TryGetLanguageNameFromProjectPath(projectPath, DiagnosticReportingMode.Ignore, out _);

        private async Task<bool> VerifyUnloadableProjectOutputExistsAsync(string projectPath, ResolvedReferencesBuilder builder, CancellationToken cancellationToken)
        {
            var buildHost = await _buildHostProcessManager.GetBuildHostWithFallbackAsync(projectPath, cancellationToken).ConfigureAwait(false);
            var outputFilePath = await buildHost.TryGetProjectOutputPathAsync(projectPath, cancellationToken).ConfigureAwait(false);
            return outputFilePath != null
                && builder.Contains(outputFilePath)
                && File.Exists(outputFilePath);
        }

        private async Task<bool> VerifyProjectOutputExistsAsync(string projectPath, ResolvedReferencesBuilder builder, CancellationToken cancellationToken)
        {
            // Note: Load the project, but don't report failures.
            var projectFileInfos = await LoadProjectFileInfosAsync(projectPath, DiagnosticReportingOptions.IgnoreAll, cancellationToken).ConfigureAwait(false);

            foreach (var projectFileInfo in projectFileInfos)
            {
                var outputFilePath = projectFileInfo.OutputFilePath;
                var outputRefFilePath = projectFileInfo.OutputRefFilePath;

                if ((builder.Contains(outputFilePath) && File.Exists(outputFilePath)) ||
                    (builder.Contains(outputRefFilePath) && File.Exists(outputRefFilePath)))
                {
                    return true;
                }
            }

            return false;
        }

        private ProjectReference CreateProjectReference(ProjectId from, ProjectId to, ImmutableArray<string> aliases)
        {
            var newReference = new ProjectReference(to, aliases);
            _projectIdToProjectReferencesMap.MultiAdd(from, newReference);
            return newReference;
        }

        private bool ProjectReferenceExists(ProjectId to, ProjectId from)
	        => _projectIdToProjectReferencesMap.TryGetValue(from, out var references)
	           && references.Any(pr => pr.ProjectId == to);

        private static bool ProjectReferenceExists(ProjectId to, ProjectInfo from)
            => from.ProjectReferences.Any(pr => pr.ProjectId == to);

        private bool TryAddReferenceToKnownProject(
            ProjectId id,
            string projectReferencePath,
            ImmutableArray<string> aliases,
            ResolvedReferencesBuilder builder)
        {
            if (_projectMap.TryGetIdsByProjectPath(projectReferencePath, out var projectReferenceIds))
            {
                foreach (var projectReferenceId in projectReferenceIds)
                {
                    // Don't add a reference if the project already has a reference on us. Otherwise, it will cause a circularity.
                    if (ProjectReferenceExists(to: id, from: projectReferenceId))
                    {
                        return false;
                    }

                    var outputRefFilePath = _projectMap.GetOutputRefFilePathById(projectReferenceId);
                    var outputFilePath = _projectMap.GetOutputFilePathById(projectReferenceId);

                    if (builder.Contains(outputRefFilePath) ||
                        builder.Contains(outputFilePath))
                    {
                        var newProjectReference = CreateProjectReference(from: id, to: projectReferenceId, aliases);
                        builder.SwapMetadataReferenceForProjectReference(newProjectReference, outputRefFilePath, outputFilePath);
                        return true;
                    }
                }
            }

            return false;
        }
    }
}
