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

using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Classification;
using Microsoft.CodeAnalysis.Editor.Shared.Extensions;
using Microsoft.CodeAnalysis.Host;
using Microsoft.CodeAnalysis.PooledObjects;
using Microsoft.CodeAnalysis.Text;
using Microsoft.CodeAnalysis.Text.Shared.Extensions;
using Microsoft.VisualStudio.Text;
using Microsoft.VisualStudio.Text.Projection;
using Roslyn.Utilities;

namespace Microsoft.VisualStudio.LanguageServices.Implementation.Venus;

internal sealed partial class ContainedDocument
{
    // this is to support old venus/razor case before dev16. 
    // all new razor (asp.NET core after dev16) should use their own implementation not ours
    public sealed class DocumentServiceProvider : IDocumentServiceProvider
    {
        private readonly SpanMapper _spanMapper;
        private readonly DocumentExcerpter _excerpter;

        public DocumentServiceProvider(ITextBuffer primaryBuffer)
        {
            _spanMapper = new SpanMapper(primaryBuffer);
            _excerpter = new DocumentExcerpter(primaryBuffer);
        }

        public TService GetService<TService>() where TService : class, IDocumentService
        {
            if (_spanMapper is TService spanMapper)
            {
                return spanMapper;
            }

            if (_excerpter is TService excerpter)
            {
                return excerpter;
            }

            // ask the default document service provider
            return DefaultTextDocumentServiceProvider.Instance.GetService<TService>();
        }

        private static ITextSnapshot GetRoslynSnapshot(SourceText sourceText)
            => sourceText.FindCorrespondingEditorTextSnapshot();

        private sealed class SpanMapper : AbstractSpanMappingService
        {
            private readonly ITextBuffer _primaryBuffer;

            public SpanMapper(ITextBuffer primaryBuffer)
                => _primaryBuffer = primaryBuffer;

            /// <summary>
            /// Legacy venus does not support us adding import directives and them mapping them to their own concepts.
            /// </summary>
            public override bool SupportsMappingImportDirectives => false;

            public override async Task<ImmutableArray<(string mappedFilePath, TextChange mappedTextChange)>> GetMappedTextChangesAsync(
                Document oldDocument,
                Document newDocument,
                CancellationToken cancellationToken)
            {
                var textChanges = (await newDocument.GetTextChangesAsync(oldDocument, cancellationToken).ConfigureAwait(false)).ToImmutableArray();
                var mappedSpanResults = await MapSpansAsync(oldDocument, textChanges.Select(tc => tc.Span), CancellationToken.None).ConfigureAwait(false);
                var mappedTextChanges = MatchMappedSpansToTextChanges(textChanges, mappedSpanResults);
                return mappedTextChanges;
            }

            public override async Task<ImmutableArray<MappedSpanResult>> MapSpansAsync(
                Document document,
                IEnumerable<TextSpan> spans,
                CancellationToken cancellationToken)
            {
                // REVIEW: for now, we keep document here due to open file case, otherwise, we need to create new SpanMappingService for every char user types.
                var sourceText = await document.GetValueTextAsync(cancellationToken).ConfigureAwait(false);

                // _primary buffer (in this case razor html files) is not in roslyn snapshot, so mapping from roslyn snapshot to razor document
                // always just map to current snapshot which have potential to have a race since content could have changed while we are doing this.
                // but for ones that uses this implementation, it always had that possiblity. so this doesn't change that aspect due to this.
                var primarySnapshot = (IProjectionSnapshot)_primaryBuffer.CurrentSnapshot;
                var roslynSnapshot = GetRoslynSnapshot(sourceText);
                if (roslynSnapshot == null)
                {
                    return default;
                }

                var builder = ArrayBuilder<MappedSpanResult>.GetInstance();
                foreach (var span in spans)
                {
                    var result = (MappedSpanResult?)null;
                    foreach (var primarySpan in primarySnapshot.MapFromSourceSnapshot(span.ToSnapshotSpan(roslynSnapshot)))
                    {
                        // this is from http://index/?query=MapSecondaryToPrimarySpan&rightProject=Microsoft.VisualStudio.Editor.Implementation&file=VsTextBufferCoordinatorAdapter.cs&line=177
                        // make sure we only consider one that's not split
                        if (primarySpan.Length != span.Length)
                        {
                            continue;
                        }

                        // take the first one.
                        // contained document file path points cshtml this secondary buffer belong to
                        var primarySnapshotSpan = new SnapshotSpan(primarySnapshot, primarySpan);
                        result = new MappedSpanResult(document.FilePath, primarySnapshotSpan.ToLinePositionSpan(), primarySpan.ToTextSpan());
                        break;
                    }

                    builder.Add(result ?? default);
                }

                return builder.ToImmutableAndFree();
            }
        }

        private sealed class DocumentExcerpter : IDocumentExcerptService
        {
            private readonly ITextBuffer _primaryBuffer;

            public DocumentExcerpter(ITextBuffer primaryBuffer)
                => _primaryBuffer = primaryBuffer;

            public async Task<ExcerptResult?> TryExcerptAsync(Document document, TextSpan span, ExcerptMode mode, ClassificationOptions classificationOptions, CancellationToken cancellationToken)
            {
                // REVIEW: for now, we keep document here due to open file case, otherwise, we need to create new DocumentExcerpter for every char user types.
                var sourceText = await document.GetValueTextAsync(cancellationToken).ConfigureAwait(false);

                var primarySnapshot = (IProjectionSnapshot)_primaryBuffer.CurrentSnapshot;

                var roslynSnapshot = GetRoslynSnapshot(sourceText);
                if (roslynSnapshot == null)
                {
                    return null;
                }

                var spanOnPrimarySnapshot = MapRoslynSpanToPrimarySpan(primarySnapshot, roslynSnapshot, span);
                if (spanOnPrimarySnapshot == null)
                {
                    return null;
                }

                var contentSpanOnPrimarySnapshot = GetContentSpanFromPrimarySpan(mode, spanOnPrimarySnapshot.Value);
                if (contentSpanOnPrimarySnapshot == null)
                {
                    // can't figure out span to extract content from
                    return null;
                }

                var (content, spanOnContent) = GetContentAndMappedSpan(mode, spanOnPrimarySnapshot.Value, contentSpanOnPrimarySnapshot.Value);
                if (content == null)
                {
                    return null;
                }

                var classifiedSpansOnContent = await GetClassifiedSpansOnContentAsync(document, roslynSnapshot, contentSpanOnPrimarySnapshot.Value, classificationOptions, cancellationToken).ConfigureAwait(false);

                // the default implementation has no idea how to classify the primary snapshot
                return new ExcerptResult(content, spanOnContent, classifiedSpansOnContent, document, span);
            }

            private static async Task<ImmutableArray<ClassifiedSpan>> GetClassifiedSpansOnContentAsync(
                Document document, ITextSnapshot roslynSnapshot, SnapshotSpan contentSpanOnPrimarySnapshot, ClassificationOptions options, CancellationToken cancellationToken)
            {
                var primarySnapshot = (IProjectionSnapshot)contentSpanOnPrimarySnapshot.Snapshot;

                // map content span on the primary buffer to second buffer and for ones that can be mapped,
                // get classification for those portion on secondary buffer and convert span on those to
                // span on the content and create ClassifiedSpan
                var contentSpan = contentSpanOnPrimarySnapshot.Span.ToTextSpan();

                // anything based on content is starting from 0
                var startPositionOnContentSpan = GetNonWhitespaceStartPositionOnContent(contentSpanOnPrimarySnapshot);

                using var _1 = Classifier.GetPooledList(out var list);

                foreach (var roslynSpan in primarySnapshot.MapToSourceSnapshots(contentSpanOnPrimarySnapshot.Span))
                {
                    if (roslynSnapshot.TextBuffer != roslynSpan.Snapshot.TextBuffer)
                    {
                        // not mapped to right buffer. ignore
                        continue;
                    }

                    // we don't have guarantee that primary snapshot is from same snapshot as roslyn snapshot. make
                    // sure we map it to right snapshot
                    var fixedUpSpan = roslynSpan.TranslateTo(roslynSnapshot, SpanTrackingMode.EdgeExclusive);
                    var classifiedSpans = await ClassifierHelper.GetClassifiedSpansAsync(
                        document, fixedUpSpan.Span.ToTextSpan(), options, includeAdditiveSpans: false, cancellationToken).ConfigureAwait(false);
                    if (classifiedSpans.IsDefault)
                    {
                        continue;
                    }

                    foreach (var classifiedSpan in classifiedSpans)
                    {
                        var mappedSpan = MapRoslynSpanToPrimarySpan(primarySnapshot, roslynSnapshot, classifiedSpan.TextSpan);
                        if (mappedSpan == null)
                        {
                            continue;
                        }

                        var spanOnContentSpan = GetSpanOnContent(mappedSpan.Value.Span.ToTextSpan(), contentSpan);
                        if (spanOnContentSpan.Start < startPositionOnContentSpan)
                        {
                            // skip span before start position.
                            continue;
                        }

                        list.Add(new ClassifiedSpan(spanOnContentSpan, classifiedSpan.ClassificationType));
                    }
                }

                // classifier expects there is no gap between classification spans. any empty space
                // from the above classification call will be filled with "text"
                //
                // the EditorClassifier call above fills all the gaps for the span it is called with, but we are combining
                // multiple spans with html code, so we need to fill those gaps
                using var _2 = Classifier.GetPooledList(out var builder);
                ClassifierHelper.FillInClassifiedSpanGaps(startPositionOnContentSpan, list, builder);

                // add html after roslyn content if there is any
                if (builder.Count == 0)
                {
                    // no roslyn code. add all as html code
                    builder.Add(new ClassifiedSpan(new TextSpan(0, contentSpan.Length), ClassificationTypeNames.Text));
                }
                else
                {
                    var lastSpan = builder[^1].TextSpan;
                    if (lastSpan.End < contentSpan.Length)
                    {
                        builder.Add(new ClassifiedSpan(new TextSpan(lastSpan.End, contentSpan.Length - lastSpan.End), ClassificationTypeNames.Text));
                    }
                }

                return [.. builder];
            }

            private static int GetNonWhitespaceStartPositionOnContent(SnapshotSpan spanOnPrimarySnapshot)
            {
                for (var i = spanOnPrimarySnapshot.Start.Position; i < spanOnPrimarySnapshot.End.Position; i++)
                {
                    if (!char.IsWhiteSpace(spanOnPrimarySnapshot.Snapshot[i]))
                    {
                        return i - spanOnPrimarySnapshot.Start.Position;
                    }
                }

                return spanOnPrimarySnapshot.Length;
            }

            private static SnapshotSpan? MapRoslynSpanToPrimarySpan(IProjectionSnapshot primarySnapshot, ITextSnapshot roslynSnapshot, TextSpan span)
            {
                var primarySpans = primarySnapshot.MapFromSourceSnapshot(span.ToSnapshotSpan(roslynSnapshot));
                if (primarySpans.Count != 1)
                {
                    // default version doesn't support where span mapped multiple primary buffer spans
                    return null;
                }

                return new SnapshotSpan(primarySnapshot, primarySpans[0]);
            }

            private static (SourceText, TextSpan) GetContentAndMappedSpan(ExcerptMode mode, SnapshotSpan primarySpan, SnapshotSpan contentSpan)
            {
                var line = primarySpan.Start.GetContainingLine();

                if (mode == ExcerptMode.SingleLine)
                {
                    return (line.Snapshot.AsText().GetSubText(contentSpan.Span.ToTextSpan()), GetSpanOnContent(primarySpan.Span.ToTextSpan(), contentSpan.Span.ToTextSpan()));
                }

                if (mode == ExcerptMode.Tooltip)
                {
                    return (line.Snapshot.AsText().GetSubText(contentSpan.Span.ToTextSpan()), GetSpanOnContent(primarySpan.Span.ToTextSpan(), contentSpan.Span.ToTextSpan()));
                }

                return (null, default);
            }

            private static SnapshotSpan? GetContentSpanFromPrimarySpan(ExcerptMode mode, SnapshotSpan primarySpan)
            {
                var line = primarySpan.Start.GetContainingLine();

                if (mode == ExcerptMode.SingleLine)
                {
                    // the line where primary span is on
                    return line.Extent;
                }

                if (mode == ExcerptMode.Tooltip)
                {
                    // +-3 line of the line where primary span is on
                    const int AdditionalLineCountPerSide = 3;

                    var startLine = line.Snapshot.GetLineFromLineNumber(Math.Max(0, line.LineNumber - AdditionalLineCountPerSide));
                    var endLine = line.Snapshot.GetLineFromLineNumber(Math.Min(line.Snapshot.LineCount - 1, line.LineNumber + AdditionalLineCountPerSide));

                    return new SnapshotSpan(line.Snapshot, Span.FromBounds(startLine.Extent.Start.Position, endLine.Extent.End.Position));
                }

                return null;
            }

            private static TextSpan GetSpanOnContent(TextSpan targetSpan, TextSpan excerptSpan)
                => new(targetSpan.Start - excerptSpan.Start, targetSpan.Length);
        }
    }
}
