﻿// 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.ComponentModel.Composition;
using System.IO;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Host.Mef;
using Microsoft.VisualStudio.LanguageServices.Implementation.ProjectSystem;
using Microsoft.VisualStudio.LanguageServices.Implementation.TaskList;
using Microsoft.VisualStudio.Shell;
using Microsoft.VisualStudio.Shell.Interop;
using Roslyn.Utilities;

namespace Microsoft.VisualStudio.LanguageServices.Implementation
{
    [Export(typeof(AnalyzerFileWatcherService))]
    internal sealed class AnalyzerFileWatcherService
    {
        private static readonly object s_analyzerChangedErrorId = new();

        private readonly VisualStudioWorkspaceImpl _workspace;
        private readonly HostDiagnosticUpdateSource _updateSource;
        private readonly IVsFileChangeEx _fileChangeService;

        private readonly Dictionary<string, FileChangeTracker> _fileChangeTrackers = new(StringComparer.OrdinalIgnoreCase);

        /// <summary>
        /// Holds a list of assembly modified times that we can use to detect a file change prior to the <see cref="FileChangeTracker"/> being in place.
        /// Once it's in place and subscribed, we'll remove the entry because any further changes will be detected that way.
        /// </summary>
        private readonly Dictionary<string, DateTime> _assemblyUpdatedTimesUtc = new(StringComparer.OrdinalIgnoreCase);

        private readonly object _guard = new();

        private readonly DiagnosticDescriptor _analyzerChangedRule = new(
            id: IDEDiagnosticIds.AnalyzerChangedId,
            title: ServicesVSResources.AnalyzerChangedOnDisk,
            messageFormat: ServicesVSResources.The_analyzer_assembly_0_has_changed_Diagnostics_may_be_incorrect_until_Visual_Studio_is_restarted,
            category: FeaturesResources.Roslyn_HostError,
            defaultSeverity: DiagnosticSeverity.Warning,
            isEnabledByDefault: true);

        [ImportingConstructor]
        [Obsolete(MefConstruction.ImportingConstructorMessage, error: true)]
        public AnalyzerFileWatcherService(
            VisualStudioWorkspaceImpl workspace,
            HostDiagnosticUpdateSource hostDiagnosticUpdateSource,
            SVsServiceProvider serviceProvider)
        {
            _workspace = workspace;
            _updateSource = hostDiagnosticUpdateSource;
            _fileChangeService = (IVsFileChangeEx)serviceProvider.GetService(typeof(SVsFileChangeEx));
        }
        internal void RemoveAnalyzerAlreadyLoadedDiagnostics(ProjectId projectId, string analyzerPath)
            => _updateSource.ClearDiagnosticsForProject(projectId, Tuple.Create(s_analyzerChangedErrorId, analyzerPath));

        private void RaiseAnalyzerChangedWarning(ProjectId projectId, string analyzerPath)
        {
            var messageArguments = new string[] { analyzerPath };

            var project = _workspace.CurrentSolution.GetProject(projectId);
            if (project != null && DiagnosticData.TryCreate(_analyzerChangedRule, messageArguments, project, out var diagnostic))
            {
                _updateSource.UpdateDiagnosticsForProject(projectId, Tuple.Create(s_analyzerChangedErrorId, analyzerPath), SpecializedCollections.SingletonEnumerable(diagnostic));
            }
        }

        private static DateTime? GetLastUpdateTimeUtc(string fullPath)
        {
            try
            {
                var creationTimeUtc = File.GetCreationTimeUtc(fullPath);
                var writeTimeUtc = File.GetLastWriteTimeUtc(fullPath);

                return writeTimeUtc > creationTimeUtc ? writeTimeUtc : creationTimeUtc;
            }
            catch (IOException)
            {
                return null;
            }
            catch (UnauthorizedAccessException)
            {
                return null;
            }
        }

        internal void TrackFilePathAndReportErrorIfChanged(string filePath, ProjectId projectId)
        {
            lock (_guard)
            {
                if (!_fileChangeTrackers.TryGetValue(filePath, out var tracker))
                {
                    tracker = new FileChangeTracker(_fileChangeService, filePath);
                    tracker.UpdatedOnDisk += Tracker_UpdatedOnDisk;
                    _ = tracker.StartFileChangeListeningAsync();

                    _fileChangeTrackers.Add(filePath, tracker);
                }

                if (_assemblyUpdatedTimesUtc.TryGetValue(filePath, out var assemblyUpdatedTime))
                {
                    var currentFileUpdateTime = GetLastUpdateTimeUtc(filePath);

                    if (currentFileUpdateTime != null)
                    {
                        if (currentFileUpdateTime != assemblyUpdatedTime)
                        {
                            RaiseAnalyzerChangedWarning(projectId, filePath);
                        }

                        // If the the tracker is in place, at this point we can stop checking any further for this assembly
                        if (tracker.PreviousCallToStartFileChangeHasAsynchronouslyCompleted)
                        {
                            _assemblyUpdatedTimesUtc.Remove(filePath);
                        }
                    }
                }
                else
                {
                    // We don't have an assembly updated time. This means we either haven't ever checked it, or we have a file watcher in place.
                    // If the file watcher is in place, then nothing further to do. Otherwise we'll add the update time to the map for future checking
                    if (!tracker.PreviousCallToStartFileChangeHasAsynchronouslyCompleted)
                    {
                        var currentFileUpdateTime = GetLastUpdateTimeUtc(filePath);

                        if (currentFileUpdateTime != null)
                        {
                            _assemblyUpdatedTimesUtc[filePath] = currentFileUpdateTime.Value;
                        }
                    }
                }
            }
        }

        private void Tracker_UpdatedOnDisk(object sender, EventArgs e)
        {
            var tracker = (FileChangeTracker)sender;
            var filePath = tracker.FilePath;

            lock (_guard)
            {
                // Once we've created a diagnostic for a given analyzer file, there's
                // no need to keep watching it.
                _fileChangeTrackers.Remove(filePath);
            }

            tracker.Dispose();
            tracker.UpdatedOnDisk -= Tracker_UpdatedOnDisk;

            // Traverse the chain of requesting assemblies to get back to the original analyzer
            // assembly.
            foreach (var project in _workspace.CurrentSolution.Projects)
            {
                var analyzerFileReferences = project.AnalyzerReferences.OfType<AnalyzerFileReference>();

                if (analyzerFileReferences.Any(a => a.FullPath.Equals(filePath, StringComparison.OrdinalIgnoreCase)))
                {
                    RaiseAnalyzerChangedWarning(project.Id, filePath);
                }
            }
        }
    }
}
