﻿// 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.Collections.Immutable;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
using System.Windows;
using System.Windows.Controls;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Common;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Editor;
using Microsoft.CodeAnalysis.Editor.Shared;
using Microsoft.CodeAnalysis.Editor.Shared.Utilities;
using Microsoft.CodeAnalysis.Host;
using Microsoft.CodeAnalysis.Internal.Log;
using Microsoft.CodeAnalysis.Navigation;
using Microsoft.CodeAnalysis.Options;
using Microsoft.VisualStudio.Imaging.Interop;
using Microsoft.VisualStudio.LanguageServices.Implementation.TaskList;
using Microsoft.VisualStudio.Shell.TableControl;
using Microsoft.VisualStudio.Shell.TableManager;
using Microsoft.VisualStudio.Text;
using Roslyn.Utilities;

namespace Microsoft.VisualStudio.LanguageServices.Implementation.TableDataSource
{
    internal abstract partial class VisualStudioBaseDiagnosticListTable
    {
        /// <summary>
        /// Used by the editor to signify that errors added to the error list
        /// should not be copied to the guest.  Instead they will be published via LSP.
        /// </summary>
        private const string DoNotPropogateToGuestProperty = "DoNotPropagateToGuests";

        /// <summary>
        /// Error list diagnostic source for "Build + Intellisense" setting.
        /// See <see cref="VisualStudioDiagnosticListTableWorkspaceEventListener.VisualStudioDiagnosticListTable.BuildTableDataSource"/>
        /// for error list diagnostic source for "Build only" setting.
        /// </summary>
        protected class LiveTableDataSource : AbstractRoslynTableDataSource<DiagnosticTableItem, DiagnosticsUpdatedArgs>
        {
            private readonly string _identifier;
            private readonly IDiagnosticService _diagnosticService;
            private readonly Workspace _workspace;
            private readonly OpenDocumentTracker<DiagnosticTableItem> _tracker;

            /// <summary>
            /// Flag indicating if a build inside Visual Studio is in progress.
            /// We get build progress updates from <see cref="ExternalErrorDiagnosticUpdateSource.BuildProgressChanged"/>.
            /// Build progress events are guaranteed to be invoked in a serial fashion during build.
            /// </summary>
            private bool _isBuildRunning;

            public LiveTableDataSource(
                Workspace workspace,
                IGlobalOptionService globalOptions,
                IThreadingContext threadingContext,
                IDiagnosticService diagnosticService,
                string identifier,
                ExternalErrorDiagnosticUpdateSource? buildUpdateSource = null)
                : base(workspace, threadingContext)
            {
                _workspace = workspace;
                GlobalOptions = globalOptions;
                _identifier = identifier;

                _tracker = new OpenDocumentTracker<DiagnosticTableItem>(_workspace);

                _diagnosticService = diagnosticService;
                ConnectToDiagnosticService(workspace, diagnosticService);

                ConnectToBuildUpdateSource(buildUpdateSource);
            }

            public IGlobalOptionService GlobalOptions { get; }
            public override string DisplayName => ServicesVSResources.CSharp_VB_Diagnostics_Table_Data_Source;
            public override string SourceTypeIdentifier => StandardTableDataSources.ErrorTableDataSource;
            public override string Identifier => _identifier;
            public override object GetItemKey(DiagnosticsUpdatedArgs data) => data.Id;

            private void ConnectToBuildUpdateSource(ExternalErrorDiagnosticUpdateSource? buildUpdateSource)
            {
                if (buildUpdateSource == null)
                {
                    // it can be null in unit test
                    return;
                }

                OnBuildProgressChanged(buildUpdateSource.IsInProgress);
                buildUpdateSource.BuildProgressChanged +=
                    (_, progress) => OnBuildProgressChanged(running: progress != ExternalErrorDiagnosticUpdateSource.BuildProgress.Done);
            }

            private void OnBuildProgressChanged(bool running)
            {
                _isBuildRunning = running;

                // We mark error list "Build + Intellisense" setting as stable,
                // i.e. shown as "Error List" without the trailing "...", if both the following are true:
                //  1. User invoked build is not running inside Visual Studio AND
                //  2. Solution crawler is not running in background to compute intellisense diagnostics.
                ChangeStableStateIfRequired(newIsStable: !_isBuildRunning && !IsSolutionCrawlerRunning);
            }

            public override AbstractTableEntriesSnapshot<DiagnosticTableItem> CreateSnapshot(
                AbstractTableEntriesSource<DiagnosticTableItem> source,
                int version,
                ImmutableArray<DiagnosticTableItem> items,
                ImmutableArray<ITrackingPoint> trackingPoints)
            {
                var diagnosticSource = (DiagnosticTableEntriesSource)source;
                var snapshot = new TableEntriesSnapshot(ThreadingContext, diagnosticSource, version, items, trackingPoints);

                if (diagnosticSource.SupportSpanTracking && !trackingPoints.IsDefaultOrEmpty)
                {
                    // track the open document so that we can throw away tracking points on document close properly
                    _tracker.TrackOpenDocument(diagnosticSource.TrackingDocumentId, diagnosticSource.Key, snapshot);
                }

                return snapshot;
            }

            protected override object GetOrUpdateAggregationKey(DiagnosticsUpdatedArgs data)
            {
                var key = TryGetAggregateKey(data);
                if (key == null)
                {
                    key = CreateAggregationKey(data);
                    AddAggregateKey(data, key);
                    return key;
                }

                if (!CheckAggregateKey(key as AggregatedKey, data))
                {
                    RemoveStaledData(data);

                    key = CreateAggregationKey(data);
                    AddAggregateKey(data, key);
                }

                return key;
            }

            private bool CheckAggregateKey(AggregatedKey? key, DiagnosticsUpdatedArgs? args)
            {
                if (key == null)
                {
                    return true;
                }

                if (args?.DocumentId == null || args?.Solution == null)
                {
                    return true;
                }

                var documents = GetDocumentsWithSameFilePath(args.Solution, args.DocumentId);
                return key.DocumentIds == documents;
            }

            private object CreateAggregationKey(DiagnosticsUpdatedArgs data)
            {
                if (data.DocumentId == null || data.Solution == null)
                    return GetItemKey(data);

                if (data.Id is not LiveDiagnosticUpdateArgsId liveArgsId)
                    return GetItemKey(data);

                var documents = GetDocumentsWithSameFilePath(data.Solution, data.DocumentId);
                return new AggregatedKey(documents, liveArgsId.Analyzer, liveArgsId.Kind);
            }

            private void PopulateInitialData(Workspace workspace, IDiagnosticService diagnosticService)
            {
                var diagnosticMode = GlobalOptions.GetDiagnosticMode();

                // If we're not in Solution-Crawler mode, then don't add any diagnostics to the table.  Instead, the LSP
                // client will be handling everything.
                var diagnostics = diagnosticMode != DiagnosticMode.SolutionCrawlerPush
                    ? ImmutableArray<DiagnosticBucket>.Empty
                    : diagnosticService.GetDiagnosticBuckets(
                        workspace, projectId: null, documentId: null, cancellationToken: CancellationToken.None);

                foreach (var bucket in diagnostics)
                {
                    // We only need to issue an event to VS that these docs have diagnostics associated with them.  So
                    // we create a dummy notification for this.  It doesn't matter that it is 'DiagnosticsRemoved' as
                    // this doesn't actually change any data.  All that will happen now is that VS will call back into
                    // us for these IDs and we'll fetch the diagnostics at that point.
                    OnDataAddedOrChanged(DiagnosticsUpdatedArgs.DiagnosticsRemoved(
                        bucket.Id, bucket.Workspace, solution: null, bucket.ProjectId, bucket.DocumentId));
                }
            }

            private void OnDiagnosticsUpdated(object sender, DiagnosticsUpdatedArgs e)
            {
                using (Logger.LogBlock(FunctionId.LiveTableDataSource_OnDiagnosticsUpdated, static e => GetDiagnosticUpdatedMessage(e), e, CancellationToken.None))
                {
                    if (_workspace != e.Workspace)
                    {
                        return;
                    }

                    // if we're in lsp mode we never respond to any diagnostics we hear about, lsp client is fully
                    // responsible for populating the error list.
                    var diagnostics = GlobalOptions.IsLspPullDiagnostics()
                        ? ImmutableArray<DiagnosticData>.Empty
                        : e.Diagnostics;

                    if (diagnostics.Length == 0)
                    {
                        OnDataRemoved(e);
                        return;
                    }

                    var count = diagnostics.Where(ShouldInclude).Count();
                    if (count <= 0)
                    {
                        OnDataRemoved(e);
                        return;
                    }

                    OnDataAddedOrChanged(e);
                }
            }

            public override AbstractTableEntriesSource<DiagnosticTableItem> CreateTableEntriesSource(object data)
            {
                var item = (UpdatedEventArgs)data;
                return new TableEntriesSource(this, item.Workspace, GlobalOptions, item.ProjectId, item.DocumentId, item.Id);
            }

            private void ConnectToDiagnosticService(Workspace workspace, IDiagnosticService diagnosticService)
            {
                if (diagnosticService == null)
                {
                    // it can be null in unit test
                    return;
                }

                _diagnosticService.DiagnosticsUpdated += OnDiagnosticsUpdated;

                PopulateInitialData(workspace, diagnosticService);
            }

            private static bool ShouldInclude(DiagnosticData diagnostic)
            {
                if (diagnostic == null)
                {
                    // guard us from wrong provider that gives null diagnostic
                    Debug.Assert(false, "Let's see who does this");
                    return false;
                }

                // If this diagnostic is for LSP only, then we won't show it here
                if (diagnostic.Properties.ContainsKey(nameof(DocumentPropertiesService.DiagnosticsLspClientName)))
                {
                    return false;
                }

                switch (diagnostic.Severity)
                {
                    case DiagnosticSeverity.Info:
                    case DiagnosticSeverity.Warning:
                    case DiagnosticSeverity.Error:
                        return true;
                    case DiagnosticSeverity.Hidden:
                    default:
                        return false;
                }
            }

            public override IEqualityComparer<DiagnosticTableItem> GroupingComparer
                => DiagnosticTableItem.GroupingComparer.Instance;

            public override IEnumerable<DiagnosticTableItem> Order(IEnumerable<DiagnosticTableItem> groupedItems)
            {
                // Deterministically order the items.
                //
                // TODO: unclear why we are comparing OriginalFileSpan and not the final normalized span.  This may
                // indicate a bug. If it is correct behavior, this should be documented as to why this is the right span
                // to be considering.
                return groupedItems.OrderBy(d => d.Data.DataLocation.UnmappedFileSpan.StartLinePosition)
                                   .ThenBy(d => d.Data.Id)
                                   .ThenBy(d => d.Data.Message)
                                   .ThenBy(d => d.Data.DataLocation.UnmappedFileSpan.EndLinePosition);
            }

            private class TableEntriesSource : DiagnosticTableEntriesSource
            {
                private readonly LiveTableDataSource _source;
                private readonly Workspace _workspace;
                private readonly IGlobalOptionService _globalOptions;
                private readonly ProjectId? _projectId;
                private readonly DocumentId? _documentId;
                private readonly object _id;
                private readonly string _buildTool;

                public TableEntriesSource(LiveTableDataSource source, Workspace workspace, IGlobalOptionService globalOptions, ProjectId? projectId, DocumentId? documentId, object id)
                {
                    _source = source;
                    _workspace = workspace;
                    _globalOptions = globalOptions;
                    _projectId = projectId;
                    _documentId = documentId;
                    _id = id;
                    _buildTool = (id as BuildToolId)?.BuildTool ?? string.Empty;
                }

                public override object Key => _id;
                public override string BuildTool => _buildTool;
                public override bool SupportSpanTracking => _documentId != null;
                public override DocumentId? TrackingDocumentId => _documentId;

                public override ImmutableArray<DiagnosticTableItem> GetItems()
                {
                    // If we're in lsp pull mode then lsp client owns the error list.
                    var diagnosticMode = _globalOptions.GetDiagnosticMode();
                    if (diagnosticMode == DiagnosticMode.LspPull)
                        return ImmutableArray<DiagnosticTableItem>.Empty;

                    var provider = _source._diagnosticService;
                    var items = provider.GetDiagnosticsAsync(_workspace, _projectId, _documentId, _id, includeSuppressedDiagnostics: true, CancellationToken.None)
                        .AsTask()
                        .WaitAndGetResult_CanCallOnBackground(CancellationToken.None)
                                        .Where(ShouldInclude)
                                        .Select(data => DiagnosticTableItem.Create(_workspace, data));

                    return items.ToImmutableArray();
                }

                public override ImmutableArray<ITrackingPoint> GetTrackingPoints(ImmutableArray<DiagnosticTableItem> items)
                    => _workspace.CreateTrackingPoints(_documentId, items);
            }

            private class TableEntriesSnapshot : AbstractTableEntriesSnapshot<DiagnosticTableItem>, IWpfTableEntriesSnapshot
            {
                private readonly DiagnosticTableEntriesSource _source;

                public TableEntriesSnapshot(
                    IThreadingContext threadingContext,
                    DiagnosticTableEntriesSource source,
                    int version,
                    ImmutableArray<DiagnosticTableItem> items,
                    ImmutableArray<ITrackingPoint> trackingPoints)
                    : base(threadingContext, version, items, trackingPoints)
                {
                    _source = source;
                }

                public override bool TryGetValue(int index, string columnName, [NotNullWhen(returnValue: true)] out object? content)
                {
                    // REVIEW: this method is too-chatty to make async, but otherwise, how one can implement it async?
                    //         also, what is cancellation mechanism?
                    var item = GetItem(index);
                    if (item == null)
                    {
                        content = null;
                        return false;
                    }

                    var data = item.Data;
                    switch (columnName)
                    {
                        case StandardTableKeyNames.ErrorRank:
                            content = ValueTypeCache.GetOrCreate(GetErrorRank(data));
                            return content != null;
                        case StandardTableKeyNames.ErrorSeverity:
                            content = ValueTypeCache.GetOrCreate(GetErrorCategory(data.Severity));
                            return content != null;
                        case StandardTableKeyNames.ErrorCode:
                            content = data.Id;
                            return content != null;
                        case StandardTableKeyNames.ErrorCodeToolTip:
                            content = (data.GetValidHelpLinkUri() != null) ? string.Format(EditorFeaturesResources.Get_help_for_0, data.Id) : null;
                            return content != null;
                        case StandardTableKeyNames.HelpKeyword:
                            content = data.GetHelpKeyword();
                            return content != null;
                        case StandardTableKeyNames.HelpLink:
                            content = data.GetValidHelpLinkUri()?.AbsoluteUri;
                            return content != null;
                        case StandardTableKeyNames.ErrorCategory:
                            content = data.Category;
                            return content != null;
                        case StandardTableKeyNames.ErrorSource:
                            content = ValueTypeCache.GetOrCreate(GetErrorSource(_source.BuildTool));
                            return content != null;
                        case StandardTableKeyNames.BuildTool:
                            content = GetBuildTool(_source.BuildTool);
                            return content != null;
                        case StandardTableKeyNames.Text:
                            content = data.Message;
                            return content != null;
                        case StandardTableKeyNames.DocumentName:
                            content = data.DataLocation.MappedFileSpan.Path;
                            return content != null;
                        case StandardTableKeyNames.Line:
                            content = data.DataLocation.MappedFileSpan.StartLinePosition.Line;
                            return true;
                        case StandardTableKeyNames.Column:
                            content = data.DataLocation.MappedFileSpan.StartLinePosition.Character;
                            return true;
                        case StandardTableKeyNames.ProjectName:
                            content = item.ProjectName;
                            return content != null;
                        case ProjectNames:
                            var names = item.ProjectNames;
                            content = names;
                            return names.Length > 0;
                        case StandardTableKeyNames.ProjectGuid:
                            content = ValueTypeCache.GetOrCreate(item.ProjectGuid);
                            return (Guid)content != Guid.Empty;
                        case ProjectGuids:
                            var guids = item.ProjectGuids;
                            content = guids;
                            return guids.Length > 0;
                        case StandardTableKeyNames.SuppressionState:
                            content = data.IsSuppressed ? SuppressionState.Suppressed : SuppressionState.Active;
                            return true;
                        case DoNotPropogateToGuestProperty:
                            content = true;
                            return true;
                        default:
                            content = null;
                            return false;
                    }
                }

                private string GetBuildTool(string buildTool)
                {
                    // for build tool, regardless where error is coming from ("build" or "live"), 
                    // we show "compiler" to users.
                    if (buildTool == PredefinedBuildTools.Live)
                    {
                        return PredefinedBuildTools.Build;
                    }

                    return _source.BuildTool;
                }

                private static ErrorSource GetErrorSource(string buildTool)
                {
                    if (buildTool == PredefinedBuildTools.Build)
                    {
                        return ErrorSource.Build;
                    }

                    return ErrorSource.Other;
                }

                private static ErrorRank GetErrorRank(DiagnosticData item)
                {
                    if (!item.Properties.TryGetValue(WellKnownDiagnosticPropertyNames.Origin, out var value))
                    {
                        return ErrorRank.Other;
                    }

                    switch (value)
                    {
                        case WellKnownDiagnosticTags.Build:
                            // any error from build gets lowest priority
                            // see https://github.com/dotnet/roslyn/issues/28807
                            //
                            // this is only used when intellisense (live) errors are involved.
                            // with "build only" filter on, we use order of errors came in from build for ordering
                            // and doesn't use ErrorRank for ordering (by giving same rank for all errors)
                            //
                            // when live errors are involved, by default, error list will use the following to sort errors
                            // error rank > project rank > project name > file name > line > column
                            // which will basically make syntax errors show up before declaration error and method body semantic errors
                            // among same type of errors, leaf project's error will show up first and then projects that depends on the leaf projects
                            //
                            // any build errors mixed with live errors will show up at the end. when live errors are on, some of errors
                            // still left as build errors such as errors produced after CompilationStages.Compile or ones listed here
                            // http://source.roslyn.io/#Microsoft.CodeAnalysis.CSharp/Compilation/CSharpCompilerDiagnosticAnalyzer.cs,23 or similar ones for VB
                            // and etc.
                            return ErrorRank.PostBuild;
                        case nameof(ErrorRank.Lexical):
                            return ErrorRank.Lexical;
                        case nameof(ErrorRank.Syntactic):
                            return ErrorRank.Syntactic;
                        case nameof(ErrorRank.Declaration):
                            return ErrorRank.Declaration;
                        case nameof(ErrorRank.Semantic):
                            return ErrorRank.Semantic;
                        case nameof(ErrorRank.Emit):
                            return ErrorRank.Emit;
                        case nameof(ErrorRank.PostBuild):
                            return ErrorRank.PostBuild;
                        default:
                            return ErrorRank.Other;
                    }
                }

                public override bool TryNavigateTo(int index, NavigationOptions options, CancellationToken cancellationToken)
                    => TryNavigateToItem(index, options, cancellationToken);

                #region IWpfTableEntriesSnapshot

                public bool CanCreateDetailsContent(int index)
                    => CanCreateDetailsContent(index, GetItem);

                public bool TryCreateDetailsContent(int index, [NotNullWhen(returnValue: true)] out FrameworkElement? expandedContent)
                    => TryCreateDetailsContent(index, GetItem, out expandedContent);

                public bool TryCreateDetailsStringContent(int index, [NotNullWhen(returnValue: true)] out string? content)
                    => TryCreateDetailsStringContent(index, GetItem, out content);

                // unused ones                    
                public bool TryCreateColumnContent(int index, string columnName, bool singleColumnView, [NotNullWhen(returnValue: true)] out FrameworkElement? content)
                {
                    content = null;
                    return false;
                }

                public bool TryCreateImageContent(int index, string columnName, bool singleColumnView, out ImageMoniker content)
                {
                    content = default;
                    return false;
                }

                public bool TryCreateStringContent(int index, string columnName, bool truncatedText, bool singleColumnView, [NotNullWhen(returnValue: true)] out string? content)
                {
                    content = null;
                    return false;
                }

                public bool TryCreateToolTip(int index, string columnName, [NotNullWhen(returnValue: true)] out object? toolTip)
                {
                    toolTip = null;
                    return false;
                }

                #endregion
            }

            private static string GetDiagnosticUpdatedMessage(DiagnosticsUpdatedArgs e)
            {
                var id = e.Id.ToString();
                if (e.Id is LiveDiagnosticUpdateArgsId live)
                {
                    id = $"{live.Analyzer}/{live.Kind}";
                }
                else if (e.Id is AnalyzerUpdateArgsId analyzer)
                {
                    id = analyzer.Analyzer.ToString();
                }

                var diagnostics = e.Diagnostics;
                return $"Kind:{e.Workspace.Kind}, Analyzer:{id}, Update:{e.Kind}, {(object?)e.DocumentId ?? e.ProjectId}, ({string.Join(Environment.NewLine, diagnostics)})";
            }
        }
    }
}
