// 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.Concurrent;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using OpenQA.Selenium;
using OpenQA.Selenium.Chrome;
using OpenQA.Selenium.Edge;
using OpenQA.Selenium.IE;
using OpenQA.Selenium.Remote;
using OpenQA.Selenium.Safari;
using Xunit;
using Xunit.Abstractions;

namespace Microsoft.AspNetCore.E2ETesting
{
    public class BrowserFixture : IAsyncLifetime
    {
        private readonly ConcurrentDictionary<string, Task<(IWebDriver browser, ILogs log)>> _browsers = new ConcurrentDictionary<string, Task<(IWebDriver, ILogs)>>();

        public BrowserFixture(IMessageSink diagnosticsMessageSink)
        {
            DiagnosticsMessageSink = diagnosticsMessageSink;
        }

        public ILogs Logs { get; private set; }

        public IMessageSink DiagnosticsMessageSink { get; }

        public string UserProfileDir { get; private set; }

        public bool EnsureNotHeadless { get; set; }

        public static void EnforceSupportedConfigurations()
        {
            // Do not change the current platform support without explicit approval.
            Assert.False(
                RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && RuntimeInformation.ProcessArchitecture == Architecture.X64,
                "Selenium tests should be running in this platform.");
        }

        public static bool IsHostAutomationSupported()
        {
            // We emit an assemblymetadata attribute that reflects the value of SeleniumE2ETestsSupported at build
            // time and we use that to conditionally skip Selenium tests parts.
            var attribute = typeof(BrowserFixture).Assembly.GetCustomAttributes<AssemblyMetadataAttribute>()
                .SingleOrDefault(a => a.Key == "Microsoft.AspNetCore.Testing.Selenium.Supported");
            var attributeValue = attribute != null ? bool.Parse(attribute.Value) : false;

            // The environment variable below can be set up before running the tests so as to override the default
            // value provided in the attribute.
            var environmentOverride = Environment
                .GetEnvironmentVariable("MICROSOFT_ASPNETCORE_TESTING_SELENIUM_SUPPORTED");
            var environmentOverrideValue = !string.IsNullOrWhiteSpace(environmentOverride) ? bool.Parse(attribute.Value) : false;

            if (environmentOverride != null)
            {
                return environmentOverrideValue;
            }
            else
            {
                return attributeValue;
            }
        }

        public async Task DisposeAsync()
        {
            var browsers = await Task.WhenAll(_browsers.Values);
            foreach (var (browser, log) in browsers)
            {
                browser?.Quit();
                browser?.Dispose();
            }

            await DeleteBrowserUserProfileDirectoriesAsync();
        }

        private async Task DeleteBrowserUserProfileDirectoriesAsync()
        {
            foreach (var context in _browsers.Keys)
            {
                var userProfileDirectory = UserProfileDirectory(context);
                if (!string.IsNullOrEmpty(userProfileDirectory) && Directory.Exists(userProfileDirectory))
                {
                    var attemptCount = 0;
                    while (true)
                    {
                        try
                        {
                            Directory.Delete(userProfileDirectory, recursive: true);
                            break;
                        }
                        catch (UnauthorizedAccessException ex)
                        {
                            attemptCount++;
                            if (attemptCount < 5)
                            {
                                Console.WriteLine($"Failed to delete browser profile directory '{userProfileDirectory}': '{ex}'. Will retry.");
                                await Task.Delay(2000);
                            }
                            else
                            {
                                throw;
                            }
                        }
                    }
                }
            }
        }

        public Task<(IWebDriver, ILogs)> GetOrCreateBrowserAsync(ITestOutputHelper output, string isolationContext = "")
        {
            Func<string, ITestOutputHelper, Task<(IWebDriver, ILogs)>> createBrowserFunc;
            if (E2ETestOptions.Instance.SauceTest)
            {
                createBrowserFunc = CreateSauceBrowserAsync;
            }
            else
            {
                if (!IsHostAutomationSupported())
                {
                    output.WriteLine($"{nameof(BrowserFixture)}: Host does not support browser automation.");
                    return Task.FromResult<(IWebDriver, ILogs)>(default);
                }

                createBrowserFunc = CreateBrowserAsync;
            }


            return _browsers.GetOrAdd(isolationContext, createBrowserFunc, output);
        }

        public Task InitializeAsync() => Task.CompletedTask;

        private async Task<(IWebDriver browser, ILogs log)> CreateBrowserAsync(string context, ITestOutputHelper output)
        {
            var opts = new ChromeOptions();

            // Force language to english for tests
            opts.AddUserProfilePreference("intl.accept_languages", "en");

            if (!EnsureNotHeadless &&
                !Debugger.IsAttached &&
                !string.Equals(Environment.GetEnvironmentVariable("E2E_TEST_VISIBLE"), "true", StringComparison.OrdinalIgnoreCase))
            {
                opts.AddArgument("--headless=new");
            }

            opts.AddArgument("--no-sandbox");
            opts.AddArgument("--ignore-certificate-errors");

            // Log errors
            opts.SetLoggingPreference(LogType.Browser, LogLevel.All);
            opts.SetLoggingPreference(LogType.Driver, LogLevel.All);

            // On Windows/Linux, we don't need to set opts.BinaryLocation
            // But for Travis Mac builds we do
            var binaryLocation = Environment.GetEnvironmentVariable("TEST_CHROME_BINARY");
            if (!string.IsNullOrEmpty(binaryLocation))
            {
                opts.BinaryLocation = binaryLocation;
                output.WriteLine($"Set {nameof(ChromeOptions)}.{nameof(opts.BinaryLocation)} to {binaryLocation}");
            }

            var userProfileDirectory = UserProfileDirectory(context);
            UserProfileDir = userProfileDirectory;
            if (!string.IsNullOrEmpty(userProfileDirectory))
            {
                Directory.CreateDirectory(userProfileDirectory);
                opts.AddArgument($"--user-data-dir={userProfileDirectory}");
                opts.AddUserProfilePreference("download.default_directory", Path.Combine(userProfileDirectory, "Downloads"));
            }

            var instance = await SeleniumStandaloneServer.GetInstanceAsync(output);

            var attempt = 0;
            const int maxAttempts = 3;
            Exception innerException;
            do
            {
                try
                {
                    // The driver opens the browser window and tries to connect to it on the constructor.
                    // Under heavy load, this can cause issues
                    // To prevent this we let the client attempt several times to connect to the server, increasing
                    // the max allowed timeout for a command on each attempt linearly.
                    // This can also be caused if many tests are running concurrently, we might want to manage
                    // chrome and chromedriver instances more aggressively if we have to.
                    // Additionally, if we think the selenium server has become irresponsive, we could spin up
                    // replace the current selenium server instance and let a new instance take over for the
                    // remaining tests.
                    var driver = new RemoteWebDriverWithLogs(
                        instance.Uri,
                        opts.ToCapabilities(),
                        TimeSpan.FromSeconds(60).Add(TimeSpan.FromSeconds(attempt * 60)));

                    driver.Manage().Timeouts().ImplicitWait = TimeSpan.FromSeconds(1);
                    var logs = driver.Manage().Logs;

                    return (driver, logs);
                }
                catch (Exception ex)
                {
                    output.WriteLine($"Error initializing RemoteWebDriver: {ex.Message}");
                    innerException = ex;
                }

                attempt++;

            } while (attempt < maxAttempts);

            throw new InvalidOperationException("Couldn't create a Selenium remote driver client. The server is irresponsive", innerException);
        }

        private string UserProfileDirectory(string context)
        {
            if (string.IsNullOrEmpty(context))
            {
                return null;
            }

            return Path.Combine(Path.GetTempPath(), "BrowserFixtureUserProfiles", context);
        }

        private async Task<(IWebDriver browser, ILogs log)> CreateSauceBrowserAsync(string context, ITestOutputHelper output)
        {
            var sauce = E2ETestOptions.Instance.Sauce;

            if (sauce == null ||
                string.IsNullOrEmpty(sauce.TestName) ||
                string.IsNullOrEmpty(sauce.Username) ||
                string.IsNullOrEmpty(sauce.AccessKey) ||
                string.IsNullOrEmpty(sauce.TunnelIdentifier) ||
                string.IsNullOrEmpty(sauce.PlatformName) ||
                string.IsNullOrEmpty(sauce.BrowserName))
            {
                throw new InvalidOperationException("Required SauceLabs environment variables not set.");
            }

            var name = sauce.TestName;
            if (!string.IsNullOrEmpty(context))
            {
                name = $"{name} - {context}";
            }

            DriverOptions options;

            switch (sauce.BrowserName.ToLowerInvariant())
            {
                case "chrome":
                    options = new ChromeOptions();
                    break;
                case "safari":
                    options = new SafariOptions();
                    break;
                case "internet explorer":
                    options = new InternetExplorerOptions();
                    break;
                case "microsoftedge":
                    options = new EdgeOptions();
                    break;
                default:
                    throw new InvalidOperationException($"Browser name {sauce.BrowserName} not recognized");
            }

            // Required config
            options.AddAdditionalOption("username", sauce.Username);
            options.AddAdditionalOption("accessKey", sauce.AccessKey);
            options.AddAdditionalOption("tunnelIdentifier", sauce.TunnelIdentifier);
            options.AddAdditionalOption("name", name);

            if (!string.IsNullOrEmpty(sauce.BrowserName))
            {
                options.AddAdditionalOption("browserName", sauce.BrowserName);
            }

            if (!string.IsNullOrEmpty(sauce.PlatformVersion))
            {
                options.PlatformName = sauce.PlatformName;
                options.AddAdditionalOption("platformVersion", sauce.PlatformVersion);
            }
            else
            {
                // In some cases (like macOS), SauceLabs expects us to set "platform" instead of "platformName".
                options.AddAdditionalOption("platform", sauce.PlatformName);
            }

            if (!string.IsNullOrEmpty(sauce.BrowserVersion))
            {
                options.BrowserVersion = sauce.BrowserVersion;
            }

            if (!string.IsNullOrEmpty(sauce.DeviceName))
            {
                options.AddAdditionalOption("deviceName", sauce.DeviceName);
            }

            if (!string.IsNullOrEmpty(sauce.DeviceOrientation))
            {
                options.AddAdditionalOption("deviceOrientation", sauce.DeviceOrientation);
            }

            if (!string.IsNullOrEmpty(sauce.AppiumVersion))
            {
                options.AddAdditionalOption("appiumVersion", sauce.AppiumVersion);
            }

            if (!string.IsNullOrEmpty(sauce.SeleniumVersion))
            {
                options.AddAdditionalOption("seleniumVersion", sauce.SeleniumVersion);
            }

            var capabilities = options.ToCapabilities();

            //await SauceConnectServer.StartAsync(output);
            await Task.Yield();

            var attempt = 0;
            const int maxAttempts = 3;
            do
            {
                try
                {
                    // Attempt to create a new browser in SauceLabs.
                    var driver = new RemoteWebDriver(
                        new Uri("http://localhost:4445/wd/hub"),
                        capabilities,
                        TimeSpan.FromSeconds(60).Add(TimeSpan.FromSeconds(attempt * 60)));

                    // Make sure implicit waits are disabled as they don't mix well with explicit waiting
                    // see https://www.selenium.dev/documentation/en/webdriver/waits/#implicit-wait
                    driver.Manage().Timeouts().ImplicitWait = TimeSpan.Zero;
                    var logs = driver.Manage().Logs;

                    return (driver, logs);
                }
                catch (Exception ex)
                {
                    output.WriteLine($"Error initializing RemoteWebDriver: {ex.Message}");
                }

                attempt++;

            } while (attempt < maxAttempts);

            throw new InvalidOperationException("Couldn't create a SauceLabs remote driver client.");
        }

        // This is a workaround for https://github.com/SeleniumHQ/selenium/issues/8229
        private class RemoteWebDriverWithLogs : RemoteWebDriver, ISupportsLogs
        {
            public RemoteWebDriverWithLogs(Uri remoteAddress, ICapabilities desiredCapabilities, TimeSpan commandTimeout)
                : base(remoteAddress, desiredCapabilities, commandTimeout)
            {
            }
        }
    }
}
