// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.TemplateEngine.Abstractions.PhysicalFileSystem;

namespace Microsoft.TemplateEngine.Utils
{
    /// <summary>
    /// In-memory file system implementation of <see cref="IPhysicalFileSystem"/>.
    /// </summary>
    /// <seealso cref="Abstractions.ITemplateEngineHost"/>
    public class InMemoryFileSystem : IPhysicalFileSystem
    {
        private readonly FileSystemDirectory _root;
        private readonly IPhysicalFileSystem _basis;

        public InMemoryFileSystem(string root, IPhysicalFileSystem basis)
        {
            _basis = basis;
            _root = new FileSystemDirectory(Path.GetFileName(root.TrimEnd('/', '\\')), root);
            _ = IsPathInCone(root, out string newRoot);
            if (root != newRoot)
            {
                _root = new FileSystemDirectory(Path.GetFileName(newRoot.TrimEnd('/', '\\')), newRoot);
            }
        }

        public void CreateDirectory(string path)
        {
            if (!IsPathInCone(path, out string processedPath))
            {
                _basis.CreateDirectory(path);
                return;
            }

            path = processedPath;
            string rel = path.Substring(_root.FullPath.Length).Trim('/', '\\');

            if (string.IsNullOrEmpty(rel))
            {
                return;
            }

            string[] parts = rel.Split('/', '\\');
            FileSystemDirectory currentDir = _root;

            for (int i = 0; i < parts.Length; ++i)
            {
                if (!currentDir.Directories.TryGetValue(parts[i], out FileSystemDirectory dir))
                {
                    dir = new FileSystemDirectory(parts[i], Path.Combine(currentDir.FullPath, parts[i]));
                    currentDir.Directories[parts[i]] = dir;
                }

                currentDir = dir;
            }
        }

        public Stream CreateFile(string path)
        {
            if (!IsPathInCone(path, out string processedPath))
            {
                return _basis.CreateFile(path);
            }

            path = processedPath;
            string rel = path.Substring(_root.FullPath.Length).Trim('/', '\\');
            string[] parts = rel.Split('/', '\\');
            FileSystemDirectory currentDir = _root;

            for (int i = 0; i < parts.Length - 1; ++i)
            {
                if (!currentDir.Directories.TryGetValue(parts[i], out FileSystemDirectory dir))
                {
                    dir = new FileSystemDirectory(parts[i], Path.Combine(currentDir.FullPath, parts[i]));
                    currentDir.Directories[parts[i]] = dir;
                }

                currentDir = dir;
            }

            if (!currentDir.Files.TryGetValue(parts[parts.Length - 1], out FileSystemFile targetFile))
            {
                targetFile = new FileSystemFile(parts[parts.Length - 1], Path.Combine(currentDir.FullPath, parts[parts.Length - 1]));
                currentDir.Files[parts[parts.Length - 1]] = targetFile;
            }

            return targetFile.OpenWrite();
        }

        public void DirectoryDelete(string path, bool recursive)
        {
            if (!IsPathInCone(path, out string processedPath))
            {
                //TODO: handle cases where a portion of what would be deleted is inside our cone
                //  and parts are outside
                _basis.DirectoryDelete(path, recursive);
                return;
            }

            path = processedPath;
            string rel = path.Substring(_root.FullPath.Length).Trim('/', '\\');

            if (string.IsNullOrEmpty(rel))
            {
                return;
            }

            string[] parts = rel.Split('/', '\\');
            FileSystemDirectory currentDir = _root;
            FileSystemDirectory? parent = null;

            for (int i = 0; i < parts.Length; ++i)
            {
                parent = currentDir;
                if (!currentDir.Directories.TryGetValue(parts[i], out FileSystemDirectory dir))
                {
                    return;
                }

                currentDir = dir;
            }

            if (!recursive && (currentDir.Directories.Count > 0 || currentDir.Files.Count > 0))
            {
                throw new IOException("Directory is not empty");
            }

            _ = (parent?.Directories.Remove(currentDir.Name));
        }

        public bool DirectoryExists(string directory)
        {
            if (!IsPathInCone(directory, out string processedPath))
            {
                return _basis.DirectoryExists(directory);
            }

            directory = processedPath;
            string rel = directory.Substring(_root.FullPath.Length).Trim('/', '\\');

            if (string.IsNullOrEmpty(rel))
            {
                return true;
            }

            string[] parts = rel.Split('/', '\\');
            FileSystemDirectory currentDir = _root;

            for (int i = 0; i < parts.Length; ++i)
            {
                if (!currentDir.Directories.TryGetValue(parts[i], out FileSystemDirectory dir))
                {
                    return false;
                }

                currentDir = dir;
            }

            return true;
        }

        public IEnumerable<string> EnumerateDirectories(string path, string pattern, SearchOption searchOption)
        {
            if (!IsPathInCone(path, out string processedPath))
            {
                //TODO: Handle cases where part of the directory set is inside our cone and part is not
                foreach (string s in _basis.EnumerateDirectories(path, pattern, searchOption))
                {
                    yield return s;
                }

                yield break;
            }

            path = processedPath;
            string rel = path.Substring(_root.FullPath.Length).Trim('/', '\\');
            FileSystemDirectory currentDir = _root;

            if (!string.IsNullOrEmpty(rel))
            {
                string[] parts = rel.Split('/', '\\');
                for (int i = 0; i < parts.Length; ++i)
                {
                    if (!currentDir.Directories.TryGetValue(parts[i], out FileSystemDirectory dir))
                    {
                        yield break;
                    }

                    currentDir = dir;
                }
            }

            Regex rx = new(Regex.Escape(pattern).Replace("\\*", ".*").Replace("\\?", "."));

            if (searchOption == SearchOption.TopDirectoryOnly)
            {
                foreach (KeyValuePair<string, FileSystemDirectory> entry in currentDir.Directories)
                {
                    if (rx.IsMatch(entry.Key))
                    {
                        yield return entry.Value.FullPath;
                    }
                }

                yield break;
            }

            Stack<IEnumerator<KeyValuePair<string, FileSystemDirectory>>> directories = new();
            IEnumerator<KeyValuePair<string, FileSystemDirectory>> current = currentDir.Directories.GetEnumerator();
            bool moveNext;

            while ((moveNext = current.MoveNext()) || directories.Count > 0)
            {
                while (!moveNext)
                {
                    current.Dispose();

                    if (directories.Count == 0)
                    {
                        break;
                    }

                    current = directories.Pop();
                    moveNext = current.MoveNext();
                }

                if (!moveNext)
                {
                    break;
                }

                if (rx.IsMatch(current.Current.Key))
                {
                    yield return current.Current.Value.FullPath;
                }

                directories.Push(current);
                current = current.Current.Value.Directories.GetEnumerator();
            }
        }

        public IEnumerable<string> EnumerateFiles(string path, string pattern, SearchOption searchOption)
        {
            if (!IsPathInCone(path, out string processedPath))
            {
                //TODO: Handle cases where part of the directory set is inside our cone and part is not
                foreach (string s in _basis.EnumerateFiles(path, pattern, searchOption))
                {
                    yield return s;
                }

                yield break;
            }

            path = processedPath;
            string rel = path.Substring(_root.FullPath.Length).Trim('/', '\\');
            FileSystemDirectory currentDir = _root;

            if (!string.IsNullOrEmpty(rel))
            {
                string[] parts = rel.Split('/', '\\');
                for (int i = 0; i < parts.Length; ++i)
                {
                    if (!currentDir.Directories.TryGetValue(parts[i], out FileSystemDirectory dir))
                    {
                        yield break;
                    }

                    currentDir = dir;
                }
            }

            Regex rx = new(Regex.Escape(pattern).Replace("\\*", ".*").Replace("\\?", "."));
            foreach (KeyValuePair<string, FileSystemFile> entry in currentDir.Files)
            {
                if (rx.IsMatch(entry.Key))
                {
                    yield return entry.Value.FullPath;
                }
            }

            if (searchOption == SearchOption.TopDirectoryOnly)
            {
                yield break;
            }

            Stack<IEnumerator<KeyValuePair<string, FileSystemDirectory>>> directories = new();
            IEnumerator<KeyValuePair<string, FileSystemDirectory>> current = currentDir.Directories.GetEnumerator();
            bool moveNext;

            while ((moveNext = current.MoveNext()) || directories.Count > 0)
            {
                while (!moveNext)
                {
                    current.Dispose();

                    if (directories.Count == 0)
                    {
                        break;
                    }

                    current = directories.Pop();
                    moveNext = current.MoveNext();
                }

                if (!moveNext)
                {
                    break;
                }

                foreach (KeyValuePair<string, FileSystemFile> entry in current.Current.Value.Files)
                {
                    if (rx.IsMatch(entry.Key))
                    {
                        yield return entry.Value.FullPath;
                    }
                }

                directories.Push(current);
                current = current.Current.Value.Directories.GetEnumerator();
            }
        }

        public IEnumerable<string> EnumerateFileSystemEntries(string directoryName, string pattern, SearchOption searchOption)
        {
            if (!IsPathInCone(directoryName, out string processedPath))
            {
                //TODO: Handle cases where part of the directory set is inside our cone and part is not
                foreach (string s in _basis.EnumerateFileSystemEntries(directoryName, pattern, searchOption))
                {
                    yield return s;
                }

                yield break;
            }

            directoryName = processedPath;
            string rel = directoryName.Substring(_root.FullPath.Length).Trim('/', '\\');
            FileSystemDirectory currentDir = _root;

            if (!string.IsNullOrEmpty(rel))
            {
                string[] parts = rel.Split('/', '\\');
                for (int i = 0; i < parts.Length; ++i)
                {
                    if (!currentDir.Directories.TryGetValue(parts[i], out FileSystemDirectory dir))
                    {
                        yield break;
                    }

                    currentDir = dir;
                }
            }

            Regex rx = new("^" + Regex.Escape(pattern).Replace("\\*", ".*").Replace("\\?", ".") + "$");

            foreach (KeyValuePair<string, FileSystemFile> entry in currentDir.Files)
            {
                if (rx.IsMatch(entry.Key))
                {
                    yield return entry.Value.FullPath;
                }
            }

            if (searchOption == SearchOption.TopDirectoryOnly)
            {
                foreach (KeyValuePair<string, FileSystemDirectory> entry in currentDir.Directories)
                {
                    if (rx.IsMatch(entry.Key))
                    {
                        yield return entry.Value.FullPath;
                    }
                }
                yield break;
            }

            Stack<IEnumerator<KeyValuePair<string, FileSystemDirectory>>> directories = new();
            IEnumerator<KeyValuePair<string, FileSystemDirectory>> current = currentDir.Directories.GetEnumerator();
            bool moveNext;

            while ((moveNext = current.MoveNext()) || directories.Count > 0)
            {
                while (!moveNext)
                {
                    current.Dispose();

                    if (directories.Count == 0)
                    {
                        break;
                    }

                    current = directories.Pop();
                    moveNext = current.MoveNext();
                }

                if (!moveNext)
                {
                    break;
                }

                if (rx.IsMatch(current.Current.Key))
                {
                    yield return current.Current.Value.FullPath;
                }

                foreach (KeyValuePair<string, FileSystemFile> entry in current.Current.Value.Files)
                {
                    if (rx.IsMatch(entry.Key))
                    {
                        yield return entry.Value.FullPath;
                    }
                }

                directories.Push(current);
                current = current.Current.Value.Directories.GetEnumerator();
            }
        }

        public void FileCopy(string sourcePath, string targetPath, bool overwrite)
        {
            if (!overwrite && FileExists(targetPath))
            {
                throw new IOException($"File already exists {targetPath}");
            }

            using (Stream s = OpenRead(sourcePath))
            using (Stream t = CreateFile(targetPath))
            {
                s.CopyTo(t);
            }
        }

        public void FileDelete(string path)
        {
            if (!IsPathInCone(path, out string processedPath))
            {
                _basis.FileDelete(path);
                return;
            }

            path = processedPath;
            string rel = path.Substring(_root.FullPath.Length).Trim('/', '\\');
            string[] parts = rel.Split('/', '\\');
            FileSystemDirectory currentDir = _root;

            for (int i = 0; i < parts.Length - 1; ++i)
            {
                if (!currentDir.Directories.TryGetValue(parts[i], out FileSystemDirectory dir))
                {
                    dir = new FileSystemDirectory(parts[i], Path.Combine(currentDir.FullPath, parts[i]));
                    currentDir.Directories[parts[i]] = dir;
                }

                currentDir = dir;
            }

            _ = currentDir.Files.Remove(parts[parts.Length - 1]);
        }

        public bool FileExists(string file)
        {
            if (!IsPathInCone(file, out string processedPath))
            {
                return _basis.FileExists(file);
            }

            file = processedPath;
            string rel = file.Substring(_root.FullPath.Length).Trim('/', '\\');
            string[] parts = rel.Split('/', '\\');
            FileSystemDirectory currentDir = _root;

            for (int i = 0; i < parts.Length - 1; ++i)
            {
                if (!currentDir.Directories.TryGetValue(parts[i], out FileSystemDirectory dir))
                {
                    return false;
                }

                currentDir = dir;
            }

            return currentDir.Files.ContainsKey(parts[parts.Length - 1]);
        }

        public string GetCurrentDirectory()
        {
            return Directory.GetCurrentDirectory();
        }

        public Stream OpenRead(string path)
        {
            if (!IsPathInCone(path, out string processedPath))
            {
                return _basis.OpenRead(path);
            }

            path = processedPath;
            string rel = path.Substring(_root.FullPath.Length).Trim('/', '\\');
            string[] parts = rel.Split('/', '\\');
            FileSystemDirectory currentDir = _root;

            for (int i = 0; i < parts.Length - 1; ++i)
            {
                if (!currentDir.Directories.TryGetValue(parts[i], out FileSystemDirectory dir))
                {
                    dir = new FileSystemDirectory(parts[i], Path.Combine(currentDir.FullPath, parts[i]));
                    currentDir.Directories[parts[i]] = dir;
                }

                currentDir = dir;
            }

            if (!currentDir.Files.TryGetValue(parts[parts.Length - 1], out FileSystemFile targetFile))
            {
                throw new FileNotFoundException("File not found", path);
            }

            return targetFile.OpenRead();
        }

        public string ReadAllText(string path)
        {
            using (Stream s = OpenRead(path))
            using (StreamReader r = new(s, Encoding.UTF8, true, 8192, true))
            {
                return r.ReadToEnd();
            }
        }

        public byte[] ReadAllBytes(string path)
        {
            //file can be either memory or physical file here
            using Stream s = OpenRead(path);
            if (s is not MemoryStream ms)
            {
                using (MemoryStream stream = new())
                {
                    s.CopyTo(stream);
                    return stream.ToArray();
                }
            }
            return ms.ToArray();
        }

        public void WriteAllText(string path, string value)
        {
            using (Stream s = CreateFile(path))
            using (StreamWriter r = new(s, Encoding.UTF8, 8192, true))
            {
                r.Write(value);
            }
        }

        public FileAttributes GetFileAttributes(string file)
        {
            if (!IsPathInCone(file, out string processedPath))
            {
                return _basis.GetFileAttributes(file);
            }

            file = processedPath;
            string rel = file.Substring(_root.FullPath.Length).Trim('/', '\\');
            string[] parts = rel.Split('/', '\\');
            FileSystemDirectory currentDir = _root;

            for (int i = 0; i < parts.Length - 1; ++i)
            {
                if (!currentDir.Directories.TryGetValue(parts[i], out FileSystemDirectory dir))
                {
                    dir = new FileSystemDirectory(parts[i], Path.Combine(currentDir.FullPath, parts[i]));
                    currentDir.Directories[parts[i]] = dir;
                }

                currentDir = dir;
            }

            if (!currentDir.Files.TryGetValue(parts[parts.Length - 1], out FileSystemFile targetFile))
            {
                throw new FileNotFoundException("File not found", file);
            }

            return targetFile.Attributes;
        }

        public void SetFileAttributes(string file, FileAttributes attributes)
        {
            if (!IsPathInCone(file, out string processedPath))
            {
                _basis.SetFileAttributes(file, attributes);
                return;
            }

            file = processedPath;
            string rel = file.Substring(_root.FullPath.Length).Trim('/', '\\');
            string[] parts = rel.Split('/', '\\');
            FileSystemDirectory currentDir = _root;

            for (int i = 0; i < parts.Length - 1; ++i)
            {
                if (!currentDir.Directories.TryGetValue(parts[i], out FileSystemDirectory dir))
                {
                    dir = new FileSystemDirectory(parts[i], Path.Combine(currentDir.FullPath, parts[i]));
                    currentDir.Directories[parts[i]] = dir;
                }

                currentDir = dir;
            }

            if (!currentDir.Files.TryGetValue(parts[parts.Length - 1], out FileSystemFile targetFile))
            {
                throw new FileNotFoundException("File not found", file);
            }

            targetFile.Attributes = attributes;
        }

        public DateTime GetLastWriteTimeUtc(string file)
        {
            if (!IsPathInCone(file, out string processedPath))
            {
                return _basis.GetLastWriteTimeUtc(file);
            }

            file = processedPath;
            string rel = file.Substring(_root.FullPath.Length).Trim('/', '\\');
            string[] parts = rel.Split('/', '\\');
            FileSystemDirectory currentDir = _root;

            for (int i = 0; i < parts.Length - 1; ++i)
            {
                if (!currentDir.Directories.TryGetValue(parts[i], out FileSystemDirectory dir))
                {
                    dir = new FileSystemDirectory(parts[i], Path.Combine(currentDir.FullPath, parts[i]));
                    currentDir.Directories[parts[i]] = dir;
                }

                currentDir = dir;
            }

            if (!currentDir.Files.TryGetValue(parts[parts.Length - 1], out FileSystemFile targetFile))
            {
                throw new FileNotFoundException("File not found", file);
            }

            return targetFile.LastWriteTimeUtc;
        }

        public void SetLastWriteTimeUtc(string file, DateTime lastWriteTimeUtc)
        {
            if (!IsPathInCone(file, out string processedPath))
            {
                _basis.SetLastWriteTimeUtc(file, lastWriteTimeUtc);
            }

            file = processedPath;
            string rel = file.Substring(_root.FullPath.Length).Trim('/', '\\');
            string[] parts = rel.Split('/', '\\');
            FileSystemDirectory currentDir = _root;

            for (int i = 0; i < parts.Length - 1; ++i)
            {
                if (!currentDir.Directories.TryGetValue(parts[i], out FileSystemDirectory dir))
                {
                    dir = new FileSystemDirectory(parts[i], Path.Combine(currentDir.FullPath, parts[i]));
                    currentDir.Directories[parts[i]] = dir;
                }

                currentDir = dir;
            }

            if (!currentDir.Files.TryGetValue(parts[parts.Length - 1], out FileSystemFile targetFile))
            {
                throw new FileNotFoundException("File not found", file);
            }

            targetFile.LastWriteTimeUtc = lastWriteTimeUtc;
        }

        /// <inheritdoc/>
        public string PathRelativeTo(string target, string relativeTo)
        {
            if (
                !IsPathInCone(target, out string targetProcessed)
                ||
                !IsPathInCone(relativeTo, out string relativeToProcessed))
            {
                return _basis.PathRelativeTo(target, relativeTo);
            }

            return PhysicalFileSystem.PathRelativeToInternal(targetProcessed, relativeToProcessed);
        }

        /// <summary>
        /// Currently not implemented in <see cref="InMemoryFileSystem"/>.
        /// Just returns <see cref="IDisposable"/> object, but never calls callback.
        /// </summary>
        public IDisposable WatchFileChanges(string filepath, FileSystemEventHandler fileChanged)
        {
            return new MemoryStream(); //Just some disposable dummy
        }

        private bool IsPathInCone(string path, out string processedPath)
        {
            if (!Path.IsPathRooted(path))
            {
                path = Path.Combine(GetCurrentDirectory(), path);
            }

            path = path.Replace('\\', '/');

            bool leadSlash = path[0] == '/';

            if (leadSlash)
            {
                path = path.Substring(1);
            }

            string[] parts = path.Split('/');

            List<string> realParts = new();

            for (int i = 0; i < parts.Length; ++i)
            {
                if (string.IsNullOrEmpty(parts[i]))
                {
                    continue;
                }

                switch (parts[i])
                {
                    case ".":
                        continue;
                    case "..":
                        realParts.RemoveAt(realParts.Count - 1);
                        break;
                    default:
                        realParts.Add(parts[i]);
                        break;
                }
            }

            if (leadSlash)
            {
                realParts.Insert(0, string.Empty);
            }

            processedPath = string.Join(Path.DirectorySeparatorChar + string.Empty, realParts);
            if (processedPath.Equals(_root.FullPath) || processedPath.StartsWith(_root.FullPath.TrimEnd('/', '\\') + Path.DirectorySeparatorChar))
            {
                return true;
            }
            else
            {
                return false;
            }
        }

        private class FileSystemDirectory
        {
            public FileSystemDirectory(string name, string fullPath)
            {
                Name = name;
                FullPath = fullPath;
                Directories = new Dictionary<string, FileSystemDirectory>();
                Files = new Dictionary<string, FileSystemFile>();
            }

            public string Name { get; }

            public string FullPath { get; }

            public Dictionary<string, FileSystemDirectory> Directories { get; }

            public Dictionary<string, FileSystemFile> Files { get; }
        }

        private class FileSystemFile
        {
            private readonly object _sync = new();
            private byte[] _data;
            private int _currentReaders;
            private int _currentWriters;

            public FileSystemFile(string name, string fullPath)
            {
                Name = name;
                FullPath = fullPath;
                _data = Array.Empty<byte>();
            }

            public string Name { get; }

            public string FullPath { get; }

            public FileAttributes Attributes { get; set; }

            public DateTime LastWriteTimeUtc { get; set; }

            public Stream OpenRead()
            {
                if (_currentWriters > 0)
                {
                    throw new IOException("File is currently locked for writing");
                }

                lock (_sync)
                {
                    if (_currentWriters > 0)
                    {
                        throw new IOException("File is currently locked for writing");
                    }

                    ++_currentReaders;
                    return new DisposingStream(new MemoryStream(_data, false), () => { lock (_sync) { --_currentReaders; } });
                }
            }

            public Stream OpenWrite()
            {
                if (_currentReaders > 0)
                {
                    throw new IOException("File is currently locked for reading");
                }

                if (_currentWriters > 0)
                {
                    throw new IOException("File is currently locked for writing");
                }

                lock (_sync)
                {
                    if (_currentReaders > 0)
                    {
                        throw new IOException("File is currently locked for reading");
                    }

                    if (_currentWriters > 0)
                    {
                        throw new IOException("File is currently locked for writing");
                    }

                    ++_currentWriters;
                    MemoryStream target = new();
                    return new DisposingStream(target, () =>
                    {
                        lock (_sync)
                        {
                            --_currentWriters;
                            _data = new byte[target.Length];
                            target.Position = 0;
                            _ = target.Read(_data, 0, _data.Length);
                            LastWriteTimeUtc = DateTime.UtcNow;
                        }
                    });
                }
            }
        }

        private class DisposingStream : Stream
        {
            private readonly Stream _basis;
            private readonly Action _onDispose;
            private bool _isDisposed;

            public DisposingStream(Stream basis, Action onDispose)
            {
                _onDispose = onDispose;
                _basis = basis;
            }

            public override bool CanRead => _basis.CanRead;

            public override bool CanSeek => _basis.CanSeek;

            public override bool CanWrite => _basis.CanWrite;

            public override long Length => _basis.Length;

            public override long Position { get => _basis.Position; set => _basis.Position = value; }

            public override void Flush() => _basis.Flush();

            public override int Read(byte[] buffer, int offset, int count) => _basis.Read(buffer, offset, count);

            public override long Seek(long offset, SeekOrigin origin) => _basis.Seek(offset, origin);

            public override void SetLength(long value) => _basis.SetLength(value);

            public override void Write(byte[] buffer, int offset, int count) => _basis.Write(buffer, offset, count);

            protected override void Dispose(bool disposing)
            {
                if (!_isDisposed)
                {
                    _isDisposed = true;
                    _onDispose();
                }

                base.Dispose(disposing);
            }
        }
    }
}
