﻿// 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.Diagnostics;
using System.IO;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;

namespace Microsoft.CodeAnalysis.BuildTasks
{
    /// <summary>
    /// Given a list of SourceRoot items produces a list of the same items with added <c>MappedPath</c> metadata that
    /// contains calculated deterministic source path for each SourceRoot.
    /// </summary>
    /// <remarks>
    /// Does not perform any path validation.
    /// 
    /// The <c>MappedPath</c> is either the path (ItemSpec) itself, when <see cref="Deterministic"/> is false, 
    /// or a calculated deterministic source path (starting with prefix '/_/', '/_1/', etc.), otherwise.
    /// </remarks>
    public sealed class MapSourceRoots : Task
    {
        public MapSourceRoots()
        {
            TaskResources = ErrorString.ResourceManager;

            // These required properties will all be assigned by MSBuild. Suppress warnings about leaving them with
            // their default values.
            SourceRoots = null!;
        }

        /// <summary>
        /// SourceRoot items with the following optional well-known metadata:
        /// <list type="bullet">
        ///   <term>SourceControl</term><description>Indicates name of the source control system the source root is tracked by (e.g. Git, TFVC, etc.), if any.</description>
        ///   <term>NestedRoot</term><description>If a value is specified the source root is nested (e.g. git submodule). The value is a path to this root relative to the containing root.</description>
        ///   <term>ContainingRoot</term><description>Identifies another source root item that this source root is nested under.</description>
        /// </list>
        /// </summary>
        [Required]
        public ITaskItem[] SourceRoots { get; set; }

        /// <summary>
        /// True if the mapped paths should be deterministic.
        /// </summary>
        public bool Deterministic { get; set; }

        /// <summary>
        /// SourceRoot items with <term>MappedPath</term> metadata set.
        /// Items listed in <see cref="SourceRoots"/> that have the same ItemSpec will be merged into a single item in this list.
        /// </summary>
        [Output]
        public ITaskItem[]? MappedSourceRoots { get; private set; }

        private static class Names
        {
            public const string SourceRoot = nameof(SourceRoot);
            public const string DeterministicSourcePaths = nameof(DeterministicSourcePaths);

            // Names of well-known SourceRoot metadata items:
            public const string SourceControl = nameof(SourceControl);
            public const string RevisionId = nameof(RevisionId);
            public const string NestedRoot = nameof(NestedRoot);
            public const string ContainingRoot = nameof(ContainingRoot);
            public const string MappedPath = nameof(MappedPath);
            public const string SourceLinkUrl = nameof(SourceLinkUrl);

            public static readonly string[] SourceRootMetadataNames = new[] { SourceControl, RevisionId, NestedRoot, ContainingRoot, MappedPath, SourceLinkUrl };
        }

        private static string EnsureEndsWithSlash(string path)
            => (path[path.Length - 1] == '/') ? path : path + '/';

        private static bool EndsWithDirectorySeparator(string path)
        {
            if (path.Length == 0)
            {
                return false;
            }

            char c = path[path.Length - 1];
            return c == Path.DirectorySeparatorChar || c == Path.AltDirectorySeparatorChar;
        }

        public override bool Execute()
        {
            // Merge metadata of SourceRoot items with the same identity.
            var mappedSourceRoots = new List<ITaskItem>();
            var rootByItemSpec = new Dictionary<string, ITaskItem>();
            foreach (var sourceRoot in SourceRoots)
            {
                // The SourceRoot is required to have a trailing directory separator.
                // We do not append one implicitly as we do not know which separator to append on Windows.
                // The usage of SourceRoot might be sensitive to what kind of separator is used (e.g. in SourceLink where it needs
                // to match the corresponding separators used in paths given to the compiler).
                if (!EndsWithDirectorySeparator(sourceRoot.ItemSpec))
                {
                    Log.LogErrorFromResources("MapSourceRoots.PathMustEndWithSlashOrBackslash", Names.SourceRoot, sourceRoot.ItemSpec);
                }

                if (rootByItemSpec.TryGetValue(sourceRoot.ItemSpec, out var existingRoot))
                {
                    ReportConflictingWellKnownMetadata(existingRoot, sourceRoot);
                    sourceRoot.CopyMetadataTo(existingRoot);
                }
                else
                {
                    rootByItemSpec.Add(sourceRoot.ItemSpec, sourceRoot);
                    mappedSourceRoots.Add(sourceRoot);
                }
            }

            if (Log.HasLoggedErrors)
            {
                return false;
            }

            if (Deterministic)
            {
                var topLevelMappedPaths = new Dictionary<string, string>();
                void setTopLevelMappedPaths(bool sourceControlled)
                {
                    foreach (var root in mappedSourceRoots)
                    {
                        if (!string.IsNullOrEmpty(root.GetMetadata(Names.SourceControl)) == sourceControlled)
                        {
                            string localPath = root.ItemSpec;
                            string nestedRoot = root.GetMetadata(Names.NestedRoot);
                            if (string.IsNullOrEmpty(nestedRoot))
                            {
                                // root isn't nested

                                if (topLevelMappedPaths.ContainsKey(localPath))
                                {
                                    Log.LogErrorFromResources("MapSourceRoots.ContainsDuplicate", Names.SourceRoot, localPath);
                                }
                                else
                                {
                                    int index = topLevelMappedPaths.Count;
                                    var mappedPath = "/_" + (index == 0 ? "" : index.ToString()) + "/";
                                    topLevelMappedPaths.Add(localPath, mappedPath);
                                    root.SetMetadata(Names.MappedPath, mappedPath);
                                }
                            }
                        }
                    }
                }

                // assign mapped paths to process source-controlled top-level roots first:
                setTopLevelMappedPaths(sourceControlled: true);

                // then assign mapped paths to other source-controlled top-level roots:
                setTopLevelMappedPaths(sourceControlled: false);

                if (topLevelMappedPaths.Count == 0)
                {
                    Log.LogErrorFromResources("MapSourceRoots.NoTopLevelSourceRoot", Names.SourceRoot, Names.DeterministicSourcePaths);
                    return false;
                }

                // finally, calculate mapped paths of nested roots:
                foreach (var root in mappedSourceRoots)
                {
                    string nestedRoot = root.GetMetadata(Names.NestedRoot);
                    if (!string.IsNullOrEmpty(nestedRoot))
                    {
                        string containingRoot = root.GetMetadata(Names.ContainingRoot);

                        // The value of ContainingRoot metadata is a file path that is compared with ItemSpec values of SourceRoot items.
                        // Since the paths in ItemSpec have backslashes replaced with slashes on non-Windows platforms we need to do the same for ContainingRoot.
                        if (containingRoot != null && topLevelMappedPaths.TryGetValue(Utilities.FixFilePath(containingRoot), out var mappedTopLevelPath))
                        {
                            Debug.Assert(mappedTopLevelPath.EndsWith("/", StringComparison.Ordinal));
                            root.SetMetadata(Names.MappedPath, mappedTopLevelPath + EnsureEndsWithSlash(nestedRoot.Replace('\\', '/')));
                        }
                        else
                        {
                            Log.LogErrorFromResources("MapSourceRoots.NoSuchTopLevelSourceRoot", Names.SourceRoot + "." + Names.ContainingRoot, Names.SourceRoot, containingRoot);
                        }
                    }
                }
            }
            else
            {
                foreach (var root in mappedSourceRoots)
                {
                    root.SetMetadata(Names.MappedPath, root.ItemSpec);
                }
            }

            if (!Log.HasLoggedErrors)
            {
                MappedSourceRoots = mappedSourceRoots.ToArray();
            }

            return !Log.HasLoggedErrors;
        }

        /// <summary>
        /// Checks that when merging metadata of two SourceRoot items we don't have any conflicting well-known metadata values.
        /// </summary>
        private void ReportConflictingWellKnownMetadata(ITaskItem left, ITaskItem right)
        {
            foreach (var metadataName in Names.SourceRootMetadataNames)
            {
                var leftValue = left.GetMetadata(metadataName);
                var rightValue = right.GetMetadata(metadataName);

                if (!string.IsNullOrEmpty(leftValue) && !string.IsNullOrEmpty(rightValue) && leftValue != rightValue)
                {
                    Log.LogWarningFromResources("MapSourceRoots.ContainsDuplicate", Names.SourceRoot, left.ItemSpec, metadataName, leftValue, rightValue);
                }
            }
        }
    }
}
