// 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.

#nullable enable

using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;

namespace SharpGenTools.Sdk.Internal.Roslyn;

// Contains path parsing utilities.
// We need our own because System.IO.Path is insufficient for our purposes
// For example we need to be able to work with invalid paths or paths containing wildcards
internal static class PathUtilities
{
    // We consider '/' a directory separator on Unix like systems. 
    // On Windows both / and \ are equally accepted.
    internal static readonly char DirectorySeparatorChar = PlatformInformation.IsUnix ? '/' : '\\';
    internal const char AltDirectorySeparatorChar = '/';
    internal const string ParentRelativeDirectory = "..";
    internal const string ThisDirectory = ".";
    internal static readonly string DirectorySeparatorStr = new(DirectorySeparatorChar, 1);
    internal const char VolumeSeparatorChar = ':';
    internal static bool IsUnixLikePlatform => PlatformInformation.IsUnix;

    /// <summary>
    /// True if the character is the platform directory separator character or the alternate directory separator.
    /// </summary>
    public static bool IsDirectorySeparator(char c) => c == DirectorySeparatorChar || c == AltDirectorySeparatorChar;

    /// <summary>
    /// True if the character is any recognized directory separator character.
    /// </summary>
    public static bool IsAnyDirectorySeparator(char c) => c == '\\' || c == '/';

    /// <summary>
    /// Removes trailing directory separator characters
    /// </summary>
    /// <remarks>
    /// This will trim the root directory separator:
    /// "C:\" maps to "C:", and "/" maps to ""
    /// </remarks>
    public static string TrimTrailingSeparators(string s)
    {
        int lastSeparator = s.Length;
        while (lastSeparator > 0 && IsDirectorySeparator(s[lastSeparator - 1]))
        {
            lastSeparator -= 1;
        }

        if (lastSeparator != s.Length)
        {
            s = s.Substring(0, lastSeparator);
        }

        return s;
    }

    /// <summary>
    /// Ensures a trailing directory separator character
    /// </summary>
    public static string EnsureTrailingSeparator(string s)
    {
        if (s.Length == 0 || IsAnyDirectorySeparator(s[s.Length - 1]))
        {
            return s;
        }

        // Use the existing slashes in the path, if they're consistent
        bool hasSlash = s.IndexOf('/') >= 0;
        bool hasBackslash = s.IndexOf('\\') >= 0;
        if (hasSlash && !hasBackslash)
        {
            return s + '/';
        }
        else if (!hasSlash && hasBackslash)
        {
            return s + '\\';
        }
        else
        {
            // If there are no slashes or they are inconsistent, use the current platform's slash.
            return s + DirectorySeparatorChar;
        }
    }

    public static string GetExtension(string path)
    {
        return FileNameUtilities.GetExtension(path);
    }

    public static string ChangeExtension(string path, string? extension)
    {
        return FileNameUtilities.ChangeExtension(path, extension);
    }

    public static string RemoveExtension(string path)
    {
        return FileNameUtilities.ChangeExtension(path, extension: null);
    }

    [return: NotNullIfNotNull(parameterName: "path")]
    public static string? GetFileName(string? path, bool includeExtension = true)
    {
        return FileNameUtilities.GetFileName(path, includeExtension);
    }

    /// <summary>
    /// Get directory name from path.
    /// </summary>
    /// <remarks>
    /// Unlike <see cref="System.IO.Path.GetDirectoryName(string)"/> it doesn't check for invalid path characters
    /// </remarks>
    /// <returns>Prefix of path that represents a directory</returns>
    public static string? GetDirectoryName(string? path)
    {
        return GetDirectoryName(path, IsUnixLikePlatform);
    }

    internal static string? GetDirectoryName(string? path, bool isUnixLike)
    {
        if (path != null)
        {
            var rootLength = GetPathRoot(path, isUnixLike).Length;
            if (path.Length > rootLength)
            {
                var i = path.Length;
                while (i > rootLength)
                {
                    i--;
                    if (IsDirectorySeparator(path[i]))
                    {
                        if (i > 0 && IsDirectorySeparator(path[i - 1]))
                        {
                            continue;
                        }

                        break;
                    }
                }

                return path.Substring(0, i);
            }
        }

        return null;
    }

    internal static bool IsSameDirectoryOrChildOf(string child, string parent)
    {
        parent = RemoveTrailingDirectorySeparator(parent);
        string? currentChild = child;
        while (currentChild != null)
        {
            currentChild = RemoveTrailingDirectorySeparator(currentChild);

            if (currentChild.Equals(parent, StringComparison.OrdinalIgnoreCase))
            {
                return true;
            }

            currentChild = GetDirectoryName(currentChild);
        }

        return false;
    }

    /// <summary>
    /// Gets the root part of the path.
    /// </summary>
    [return: NotNullIfNotNull(parameterName: "path")]
    public static string? GetPathRoot(string? path)
    {
        return GetPathRoot(path, IsUnixLikePlatform);
    }

    [return: NotNullIfNotNull(parameterName: "path")]
    private static string? GetPathRoot(string? path, bool isUnixLike)
    {
        if (path == null)
        {
            return null;
        }

        if (isUnixLike)
        {
            return GetUnixRoot(path);
        }
        else
        {
            return GetWindowsRoot(path);
        }
    }

    private static string GetWindowsRoot(string path)
    {
        // Windows
        int length = path.Length;
        if (length >= 1 && IsDirectorySeparator(path[0]))
        {
            if (length < 2 || !IsDirectorySeparator(path[1]))
            {
                //  It was of the form:
                //          \     
                //          \f
                // in this case, just return \ as the root.
                return path.Substring(0, 1);
            }

            // First consume all directory separators.
            int i = 2;
            i = ConsumeDirectorySeparators(path, length, i);

            // We've got \\ so far.  If we have a path of the form \\x\y\z
            // then we want to return "\\x\y" as the root portion.
            bool hitSeparator = false;
            while (true)
            {
                if (i == length)
                {
                    // We reached the end of the path. The entire path is
                    // considered the root.
                    return path;
                }

                if (!IsDirectorySeparator(path[i]))
                {
                    // We got a non separator character.  Just keep consuming.
                    i++;
                    continue;
                }

                if (!hitSeparator)
                {
                    // This is the first separator group we've hit after some server path.  
                    // Consume them and keep going.
                    hitSeparator = true;
                    i = ConsumeDirectorySeparators(path, length, i);
                    continue;
                }

                // We hit the second separator.  The root is the path up to this point.
                return path.Substring(0, i);
            }
        }
        else if (length >= 2 && path[1] == VolumeSeparatorChar)
        {
            // handles c: and c:\
            return length >= 3 && IsDirectorySeparator(path[2])
                       ? path.Substring(0, 3)
                       : path.Substring(0, 2);
        }
        else
        {
            // No path root.
            return "";
        }
    }

    private static int ConsumeDirectorySeparators(string path, int length, int i)
    {
        while (i < length && IsDirectorySeparator(path[i]))
        {
            i++;
        }

        return i;
    }

    private static string GetUnixRoot(string path)
    {
        // either it starts with "/" and thus has "/" as the root.  Or it has no root.
        return path.Length > 0 && IsDirectorySeparator(path[0])
                   ? path.Substring(0, 1)
                   : "";
    }

    /// <summary>
    /// Gets the specific kind of relative or absolute path.
    /// </summary>
    public static PathKind GetPathKind(string? path)
    {
        if (RoslynString.IsNullOrWhiteSpace(path))
        {
            return PathKind.Empty;
        }

        // "C:\"
        // "\\machine" (UNC)
        // "/etc"      (Unix)
        if (IsAbsolute(path))
        {
            return PathKind.Absolute;
        }

        // "."
        // ".."
        // ".\"
        // "..\"
        if (path.Length > 0 && path[0] == '.')
        {
            if (path.Length == 1 || IsDirectorySeparator(path[1]))
            {
                return PathKind.RelativeToCurrentDirectory;
            }

            if (path[1] == '.')
            {
                if (path.Length == 2 || IsDirectorySeparator(path[2]))
                {
                    return PathKind.RelativeToCurrentParent;
                }
            }
        }

        if (!IsUnixLikePlatform)
        {
            // "\"
            // "\goo"
            if (path.Length >= 1 && IsDirectorySeparator(path[0]))
            {
                return PathKind.RelativeToCurrentRoot;
            }

            // "C:goo"

            if (path.Length >= 2 && path[1] == VolumeSeparatorChar && (path.Length <= 2 || !IsDirectorySeparator(path[2])))
            {
                return PathKind.RelativeToDriveDirectory;
            }
        }

        // "goo.dll"
        return PathKind.Relative;
    }

    /// <summary>
    /// True if the path is an absolute path (rooted to drive or network share)
    /// </summary>
    public static bool IsAbsolute([NotNullWhen(true)] string? path)
    {
        if (RoslynString.IsNullOrEmpty(path))
        {
            return false;
        }

        if (IsUnixLikePlatform)
        {
            return path[0] == DirectorySeparatorChar;
        }

        // "C:\"
        if (IsDriveRootedAbsolutePath(path))
        {
            // Including invalid paths (e.g. "*:\")
            return true;
        }

        // "\\machine\share"
        // Including invalid/incomplete UNC paths (e.g. "\\goo")
        return path.Length >= 2 &&
               IsDirectorySeparator(path[0]) &&
               IsDirectorySeparator(path[1]);
    }

    /// <summary>
    /// Returns true if given path is absolute and starts with a drive specification ("C:\").
    /// </summary>
    private static bool IsDriveRootedAbsolutePath(string path)
    {
        Debug.Assert(!IsUnixLikePlatform);
        return path.Length >= 3 && path[1] == VolumeSeparatorChar && IsDirectorySeparator(path[2]);
    }

    /// <summary>
    /// Combines an absolute path with a relative.
    /// </summary>
    /// <param name="root">Absolute root path.</param>
    /// <param name="relativePath">Relative path.</param>
    /// <returns>
    /// An absolute combined path, or null if <paramref name="relativePath"/> is 
    /// absolute (e.g. "C:\abc", "\\machine\share\abc"), 
    /// relative to the current root (e.g. "\abc"), 
    /// or relative to a drive directory (e.g. "C:abc\def").
    /// </returns>
    /// <seealso cref="CombinePossiblyRelativeAndRelativePaths"/>
    public static string? CombineAbsoluteAndRelativePaths(string root, string relativePath)
    {
        Debug.Assert(IsAbsolute(root));

        return CombinePossiblyRelativeAndRelativePaths(root, relativePath);
    }

    /// <summary>
    /// Combine two paths, the first of which may be absolute.
    /// </summary>
    /// <param name="root">First path: absolute, relative, or null.</param>
    /// <param name="relativePath">Second path: relative and non-null.</param>
    /// <returns>null, if <paramref name="root"/> is null; a combined path, otherwise.</returns>
    /// <seealso cref="CombineAbsoluteAndRelativePaths"/>
    public static string? CombinePossiblyRelativeAndRelativePaths(string? root, string? relativePath)
    {
        if (RoslynString.IsNullOrEmpty(root))
        {
            return null;
        }

        switch (GetPathKind(relativePath))
        {
            case PathKind.Empty:
                return root;

            case PathKind.Absolute:
            case PathKind.RelativeToCurrentRoot:
            case PathKind.RelativeToDriveDirectory:
                return null;
        }

        return CombinePathsUnchecked(root, relativePath);
    }

    public static string CombinePathsUnchecked(string root, string? relativePath)
    {
        RoslynDebug.Assert(!RoslynString.IsNullOrEmpty(root));

        char c = root[root.Length - 1];
        if (!IsDirectorySeparator(c) && c != VolumeSeparatorChar)
        {
            return root + DirectorySeparatorStr + relativePath;
        }

        return root + relativePath;
    }

    private static string RemoveTrailingDirectorySeparator(string path)
    {
        if (path.Length > 0 && IsDirectorySeparator(path[path.Length - 1]))
        {
            return path.Substring(0, path.Length - 1);
        }
        else
        {
            return path;
        }
    }

    /// <summary>
    /// Determines whether an assembly reference is considered an assembly file path or an assembly name.
    /// used, for example, on values of /r and #r.
    /// </summary>
    public static bool IsFilePath(string assemblyDisplayNameOrPath)
    {
        RoslynDebug.Assert(assemblyDisplayNameOrPath != null);

        string? extension = FileNameUtilities.GetExtension(assemblyDisplayNameOrPath);
        return string.Equals(extension, ".dll", StringComparison.OrdinalIgnoreCase)
            || string.Equals(extension, ".exe", StringComparison.OrdinalIgnoreCase)
            || assemblyDisplayNameOrPath.IndexOf(DirectorySeparatorChar) != -1
            || assemblyDisplayNameOrPath.IndexOf(AltDirectorySeparatorChar) != -1;
    }

    /// <summary>
    /// Determines if "path" contains 'component' within itself.
    /// i.e. asking if the path "c:\goo\bar\baz" has component "bar" would return 'true'.
    /// On the other hand, if you had "c:\goo\bar1\baz" then it would not have "bar" as a
    /// component.
    /// 
    /// A path contains a component if any file name or directory name in the path
    /// matches 'component'.  As such, if you had something like "\\goo" then that would
    /// not have "goo" as a component. That's because here "goo" is the server name portion
    /// of the UNC path, and not an actual directory or file name.
    /// </summary>
    public static bool ContainsPathComponent(string? path, string component, bool ignoreCase)
    {
        var comparison = ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
        if (path?.IndexOf(component, comparison) >= 0)
        {
            var comparer = ignoreCase ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;

            int count = 0;
            string? currentPath = path;
            while (currentPath != null)
            {
                var currentName = GetFileName(currentPath);
                if (comparer.Equals(currentName, component))
                {
                    return true;
                }

                currentPath = GetDirectoryName(currentPath);
                count++;
            }
        }

        return false;
    }

    /// <summary>
    /// Gets a path relative to a directory.
    /// </summary>
    public static string GetRelativePath(string directory, string fullPath)
    {
        string relativePath = string.Empty;

        if (IsChildPath(directory, fullPath))
        {
            return GetRelativeChildPath(directory, fullPath);
        }

        var directoryPathParts = GetPathParts(directory);
        var fullPathParts = GetPathParts(fullPath);

        if (directoryPathParts.Length == 0 || fullPathParts.Length == 0)
        {
            return fullPath;
        }

        int index = 0;

        // find index where full path diverges from base path
        for (; index < directoryPathParts.Length; index++)
        {
            if (!PathsEqual(directoryPathParts[index], fullPathParts[index]))
            {
                break;
            }
        }

        // if the first part doesn't match, they don't even have the same volume
        // so there can be no relative path.
        if (index == 0)
        {
            return fullPath;
        }

        // add backup notation for remaining base path levels beyond the index
        var remainingParts = directoryPathParts.Length - index;
        if (remainingParts > 0)
        {
            for (int i = 0; i < remainingParts; i++)
            {
                relativePath = relativePath + ParentRelativeDirectory + DirectorySeparatorStr;
            }
        }

        // add the rest of the full path parts
        for (int i = index; i < fullPathParts.Length; i++)
        {
            relativePath = CombinePathsUnchecked(relativePath, fullPathParts[i]);
        }

        return relativePath;
    }

    /// <summary>
    /// True if the child path is a child of the parent path.
    /// </summary>
    public static bool IsChildPath(string parentPath, string childPath)
    {
        return parentPath.Length > 0
            && childPath.Length > parentPath.Length
            && PathsEqual(childPath, parentPath, parentPath.Length)
            && (IsDirectorySeparator(parentPath[parentPath.Length - 1]) || IsDirectorySeparator(childPath[parentPath.Length]));
    }

    private static string GetRelativeChildPath(string parentPath, string childPath)
    {
        var relativePath = childPath.Substring(parentPath.Length);

        // trim any leading separators left over after removing leading directory
        int start = ConsumeDirectorySeparators(relativePath, relativePath.Length, 0);
        if (start > 0)
        {
            relativePath = relativePath.Substring(start);
        }

        return relativePath;
    }

    private static readonly char[] s_pathChars = new char[] { VolumeSeparatorChar, DirectorySeparatorChar, AltDirectorySeparatorChar };

    private static string[] GetPathParts(string path)
    {
        var pathParts = path.Split(s_pathChars);

        // remove references to self directories ('.')
        if (pathParts.Contains(ThisDirectory))
        {
            pathParts = pathParts.Where(s => s != ThisDirectory).ToArray();
        }

        return pathParts;
    }

    /// <summary>
    /// True if the two paths are the same.
    /// </summary>
    public static bool PathsEqual(string path1, string path2)
    {
        return PathsEqual(path1, path2, Math.Max(path1.Length, path2.Length));
    }

    /// <summary>
    /// True if the two paths are the same.  (but only up to the specified length)
    /// </summary>
    private static bool PathsEqual(string path1, string path2, int length)
    {
        if (path1.Length < length || path2.Length < length)
        {
            return false;
        }

        for (int i = 0; i < length; i++)
        {
            if (!PathCharEqual(path1[i], path2[i]))
            {
                return false;
            }
        }

        return true;
    }

    private static bool PathCharEqual(char x, char y)
    {
        if (IsDirectorySeparator(x) && IsDirectorySeparator(y))
        {
            return true;
        }

        return IsUnixLikePlatform
                   ? x == y
                   : char.ToUpperInvariant(x) == char.ToUpperInvariant(y);
    }

    private static int PathHashCode(string? path)
    {
        int hc = 0;

        if (path != null)
        {
            foreach (var ch in path)
            {
                if (!IsDirectorySeparator(ch))
                {
                    hc = Hash.Combine((int)char.ToUpperInvariant(ch), hc);
                }
            }
        }

        return hc;
    }

    public static string NormalizePathPrefix(string filePath, ImmutableArray<KeyValuePair<string, string>> pathMap)
    {
        if (pathMap.IsDefaultOrEmpty)
        {
            return filePath;
        }

        // find the first key in the path map that matches a prefix of the normalized path.
        // Note that we expect the client to use consistent capitalization; we use ordinal (case-sensitive) comparisons.
        foreach (var kv in pathMap)
        {
            var oldPrefix = kv.Key;
            if (!(oldPrefix?.Length > 0)) continue;

            // oldPrefix always ends with a path separator, so there's no need to check if it was a partial match
            // e.g. for the map /goo=/bar and filename /goooo
            if (filePath.StartsWith(oldPrefix, StringComparison.Ordinal))
            {
                var replacementPrefix = kv.Value;

                // Replace that prefix.
                var replacement = replacementPrefix + filePath.Substring(oldPrefix.Length);

                // Normalize the path separators if used uniformly in the replacement
                bool hasSlash = replacementPrefix.IndexOf('/') >= 0;
                bool hasBackslash = replacementPrefix.IndexOf('\\') >= 0;
                return
                    (hasSlash && !hasBackslash) ? replacement.Replace('\\', '/') :
                    (hasBackslash && !hasSlash) ? replacement.Replace('/', '\\') :
                    replacement;
            }
        }

        return filePath;
    }

    /// <summary>
    /// Unfortunately, we cannot depend on Path.GetInvalidPathChars() or Path.GetInvalidFileNameChars()
    /// From MSDN: The array returned from this method is not guaranteed to contain the complete set of characters
    /// that are invalid in file and directory names. The full set of invalid characters can vary by file system.
    /// https://msdn.microsoft.com/en-us/library/system.io.path.getinvalidfilenamechars.aspx
    /// 
    /// Additionally, Path.GetInvalidPathChars() doesn't include "?" or "*" which are invalid characters,
    /// and Path.GetInvalidFileNameChars() includes ":" and "\" which are valid characters.
    /// 
    /// The more accurate way is to let the framework parse the path and throw on any errors.
    /// </summary>
    public static bool IsValidFilePath([NotNullWhen(true)] string? fullPath)
    {
        try
        {
            if (RoslynString.IsNullOrEmpty(fullPath))
            {
                return false;
            }

            // Uncomment when this is fixed: https://github.com/dotnet/roslyn/issues/19592
            // Debug.Assert(IsAbsolute(fullPath));

            var fileInfo = new FileInfo(fullPath);
            return !string.IsNullOrEmpty(fileInfo.Name);
        }
        catch (Exception ex) when (
            ex is ArgumentException ||          // The file name is empty, contains only white spaces, or contains invalid characters.
            ex is PathTooLongException ||       // The specified path, file name, or both exceed the system-defined maximum length.
            ex is NotSupportedException)        // fileName contains a colon (:) in the middle of the string.
        {
            return false;
        }
    }

    /// <summary>
    /// If the current environment uses the '\' directory separator, replaces all uses of '\'
    /// in the given string with '/'. Otherwise, returns the string.
    /// </summary>
    /// <remarks>
    /// This method is equivalent to Microsoft.CodeAnalysis.BuildTasks.GenerateMSBuildEditorConfig.NormalizeWithForwardSlash
    /// Both methods should be kept in sync.
    /// </remarks>
    public static string NormalizeWithForwardSlash(string p)
        => DirectorySeparatorChar == '/' ? p : p.Replace(DirectorySeparatorChar, '/');

    public static readonly IEqualityComparer<string> Comparer = new PathComparer();

    private class PathComparer : IEqualityComparer<string?>
    {
        public bool Equals(string? x, string? y)
        {
            if (x == null && y == null)
            {
                return true;
            }

            if (x == null || y == null)
            {
                return false;
            }

            return PathsEqual(x, y);
        }

        public int GetHashCode(string? s)
        {
            return PathHashCode(s);
        }
    }

    internal static class TestAccessor
    {
        internal static string? GetDirectoryName(string path, bool isUnixLike)
            => PathUtilities.GetDirectoryName(path, isUnixLike);
    }
}