﻿// 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.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.Editor.Shared.Utilities;
using Microsoft.CodeAnalysis.Shared.TestHooks;
using Microsoft.VisualStudio.Text;
using Microsoft.VisualStudio.Text.Editor;
using Microsoft.VisualStudio.Threading;

namespace Microsoft.CodeAnalysis.Workspaces
{
    /// <summary>
    /// All methods must be called on UI thread.
    /// </summary>
    internal interface ITextBufferVisibilityTracker
    {
        /// <summary>
        /// Whether or not this text buffer is in an actively visible <see cref="ITextView"/>.
        /// </summary>
        bool IsVisible(ITextBuffer subjectBuffer);

        /// <summary>
        /// Registers to hear about visibility changes for this particular buffer.  Note: registration will not trigger
        /// a call to <paramref name="callback"/>.  If clients need that information, they should check the <see
        /// cref="IsVisible"/> state of the <paramref name="subjectBuffer"/> themselves.
        /// </summary>
        void RegisterForVisibilityChanges(ITextBuffer subjectBuffer, Action callback);

        /// <summary>
        /// Unregister equivalent of <see cref="RegisterForVisibilityChanges"/>.
        /// </summary>
        void UnregisterForVisibilityChanges(ITextBuffer subjectBuffer, Action callback);
    }

    internal static class ITextBufferVisibilityTrackerExtensions
    {
        /// <summary>
        /// Waits the specified amount of time while the specified <paramref name="subjectBuffer"/> is not visible.  If
        /// any document visibility changes happen, the delay will cancel.
        /// </summary>
        public static Task DelayWhileNonVisibleAsync(
            this ITextBufferVisibilityTracker? service,
            IThreadingContext threadingContext,
            IAsynchronousOperationListener listener,
            ITextBuffer subjectBuffer,
            TimeSpan timeSpan,
            CancellationToken cancellationToken)
        {
            // Only add a delay if we have access to a service that will tell us when the buffer become visible or not.
            if (service is null)
                return Task.CompletedTask;

            // Because cancellation is both expensive, and a super common thing to occur while we're delaying the caller
            // until visibility, we special case the implementation here and transition to the canceled state
            // explicitly, rather than throwing a cancellation exception.

            var delayTask = DelayWhileNonVisibleWorkerAsync();

            // it's very reasonable for the delay-task to complete synchronously (we've already been canceled, or the
            // buffer is already visible.  So fast path that out.
            if (delayTask.IsCompleted)
                return delayTask;

            var taskOfTask = delayTask.ContinueWith(
                // Convert a successfully completed task when we were canceled to a canceled task.  Otherwise, return
                // the faulted or non-canceled task as is.
                task => task.Status == TaskStatus.RanToCompletion && cancellationToken.IsCancellationRequested ? Task.FromCanceled(cancellationToken) : task,
                CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
            return taskOfTask.Unwrap();

            // Normal delay logic, except that this does not throw in the event of cancellation, but instead returns
            // gracefully.  The above task continuation logic then ensures we return a canceled task without needing
            // exceptions.
            async Task DelayWhileNonVisibleWorkerAsync()
            {
                if (cancellationToken.IsCancellationRequested)
                    return;

                await threadingContext.JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken);
                if (service.IsVisible(subjectBuffer))
                    return;

                // ensure we listen for visibility changes before checking.  That way we don't have a race where we check
                // something see it is not visible, but then do not hear about its visibility change because we've hooked up
                // our event after that happens.
                var visibilityChangedTaskSource = new TaskCompletionSource<bool>();
                var callback = void () => visibilityChangedTaskSource.TrySetResult(true);
                service.RegisterForVisibilityChanges(subjectBuffer, callback);

                try
                {
                    // Listen to when the active document changed so that we startup work on a document once it becomes visible.
                    var delayTask = listener.Delay(timeSpan, cancellationToken);
                    await Task.WhenAny(delayTask, visibilityChangedTaskSource.Task).NoThrowAwaitable(captureContext: true);
                }
                finally
                {
                    service.UnregisterForVisibilityChanges(subjectBuffer, callback);
                }
            }
        }
    }
}
