// <copyright file="FirefoxDriver.cs" company="Selenium Committers">
// Licensed to the Software Freedom Conservancy (SFC) under one
// or more contributor license agreements.  See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership.  The SFC licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License.  You may obtain a copy of the License at
//
//   http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied.  See the License for the
// specific language governing permissions and limitations
// under the License.
// </copyright>

using OpenQA.Selenium.Remote;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.IO;
using System.IO.Compression;

namespace OpenQA.Selenium.Firefox;

/// <summary>
/// Provides a way to access Firefox to run tests.
/// </summary>
/// <remarks>
/// When the FirefoxDriver object has been instantiated the browser will load. The test can then navigate to the URL under test and
/// start your test.
/// <para>
/// In the case of the FirefoxDriver, you can specify a named profile to be used, or you can let the
/// driver create a temporary, anonymous profile. A custom extension allowing the driver to communicate
/// to the browser will be installed into the profile.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// [TestFixture]
/// public class Testing
/// {
///     private IWebDriver driver;
///     <para></para>
///     [SetUp]
///     public void SetUp()
///     {
///         driver = new FirefoxDriver();
///     }
///     <para></para>
///     [Test]
///     public void TestGoogle()
///     {
///         driver.Navigate().GoToUrl("http://www.google.co.uk");
///         /*
///         *   Rest of the test
///         */
///     }
///     <para></para>
///     [TearDown]
///     public void TearDown()
///     {
///         driver.Quit();
///     }
/// }
/// </code>
/// </example>
public class FirefoxDriver : WebDriver
{
    /// <summary>
    /// Command for setting the command context of a Firefox driver.
    /// </summary>
    public static readonly string SetContextCommand = "setContext";

    /// <summary>
    /// Command for getting the command context of a Firefox driver.
    /// </summary>
    public static readonly string GetContextCommand = "getContext";

    /// <summary>
    /// Command for installing an addon to a Firefox driver.
    /// </summary>
    public static readonly string InstallAddOnCommand = "installAddOn";

    /// <summary>
    /// Command for uninstalling an addon from a Firefox driver.
    /// </summary>
    public static readonly string UninstallAddOnCommand = "uninstallAddOn";

    /// <summary>
    /// Command for getting aa full page screenshot from a Firefox driver.
    /// </summary>
    public static readonly string GetFullPageScreenshotCommand = "fullPageScreenshot";

    private static readonly Dictionary<string, CommandInfo> firefoxCustomCommands = new Dictionary<string, CommandInfo>()
    {
        { SetContextCommand, new HttpCommandInfo(HttpCommandInfo.PostCommand, "/session/{sessionId}/moz/context") },
        { GetContextCommand, new HttpCommandInfo(HttpCommandInfo.GetCommand, "/session/{sessionId}/moz/context") },
        { InstallAddOnCommand, new HttpCommandInfo(HttpCommandInfo.PostCommand, "/session/{sessionId}/moz/addon/install") },
        { UninstallAddOnCommand, new HttpCommandInfo(HttpCommandInfo.PostCommand, "/session/{sessionId}/moz/addon/uninstall") },
        { GetFullPageScreenshotCommand, new HttpCommandInfo(HttpCommandInfo.GetCommand, "/session/{sessionId}/moz/screenshot/full") }
    };

    /// <summary>
    /// Initializes a new instance of the <see cref="FirefoxDriver"/> class.
    /// </summary>
    public FirefoxDriver()
        : this(new FirefoxOptions())
    {
    }

    /// <summary>
    /// Initializes a new instance of the <see cref="FirefoxDriver"/> class using the specified options. Uses the Mozilla-provided Marionette driver implementation.
    /// </summary>
    /// <param name="options">The <see cref="FirefoxOptions"/> to be used with the Firefox driver.</param>
    /// <exception cref="ArgumentNullException">If <paramref name="options"/> is <see langword="null"/>.</exception>
    public FirefoxDriver(FirefoxOptions options)
        : this(FirefoxDriverService.CreateDefaultService(), options, RemoteWebDriver.DefaultCommandTimeout)
    {
    }

    /// <summary>
    /// Initializes a new instance of the <see cref="FirefoxDriver"/> class using the specified driver service. Uses the Mozilla-provided Marionette driver implementation.
    /// </summary>
    /// <param name="service">The <see cref="FirefoxDriverService"/> used to initialize the driver.</param>
    /// <exception cref="ArgumentNullException">If <paramref name="service"/> is <see langword="null"/>.</exception>
    public FirefoxDriver(FirefoxDriverService service)
        : this(service, new FirefoxOptions(), RemoteWebDriver.DefaultCommandTimeout)
    {
    }

    /// <summary>
    /// Initializes a new instance of the <see cref="FirefoxDriver"/> class using the specified path
    /// to the directory containing <c>geckodriver.exe</c>.
    /// </summary>
    /// <param name="geckoDriverDirectory">The full path to the directory containing <c>geckodriver.exe</c>.</param>
    public FirefoxDriver(string geckoDriverDirectory)
        : this(geckoDriverDirectory, new FirefoxOptions())
    {
    }

    /// <summary>
    /// Initializes a new instance of the <see cref="FirefoxDriver"/> class using the specified path
    /// to the directory containing <c>geckodriver.exe</c> and options.
    /// </summary>
    /// <param name="geckoDriverDirectory">The full path to the directory containing <c>geckodriver.exe</c>.</param>
    /// <param name="options">The <see cref="FirefoxOptions"/> to be used with the Firefox driver.</param>
    /// <exception cref="ArgumentNullException">If <paramref name="options"/> is <see langword="null"/>.</exception>
    public FirefoxDriver(string geckoDriverDirectory, FirefoxOptions options)
        : this(geckoDriverDirectory, options, RemoteWebDriver.DefaultCommandTimeout)
    {
    }

    /// <summary>
    /// Initializes a new instance of the <see cref="FirefoxDriver"/> class using the specified path
    /// to the directory containing <c>geckodriver.exe</c>, options, and command timeout.
    /// </summary>
    /// <param name="geckoDriverDirectory">The full path to the directory containing <c>geckodriver.exe</c>.</param>
    /// <param name="options">The <see cref="FirefoxOptions"/> to be used with the Firefox driver.</param>
    /// <param name="commandTimeout">The maximum amount of time to wait for each command.</param>
    /// <exception cref="ArgumentNullException">If <paramref name="options"/> is <see langword="null"/>.</exception>
    public FirefoxDriver(string geckoDriverDirectory, FirefoxOptions options, TimeSpan commandTimeout)
        : this(FirefoxDriverService.CreateDefaultService(geckoDriverDirectory), options, commandTimeout)
    {
    }

    /// <summary>
    /// Initializes a new instance of the <see cref="FirefoxDriver"/> class using the specified options, driver service, and timeout. Uses the Mozilla-provided Marionette driver implementation.
    /// </summary>
    /// <param name="service">The <see cref="FirefoxDriverService"/> to use.</param>
    /// <param name="options">The <see cref="FirefoxOptions"/> to be used with the Firefox driver.</param>
    /// <exception cref="ArgumentNullException">If <paramref name="service"/> or <paramref name="options"/> are <see langword="null"/>.</exception>
    public FirefoxDriver(FirefoxDriverService service, FirefoxOptions options)
        : this(service, options, RemoteWebDriver.DefaultCommandTimeout)
    {
    }

    /// <summary>
    /// Initializes a new instance of the <see cref="FirefoxDriver"/> class using the specified options, driver service, and timeout. Uses the Mozilla-provided Marionette driver implementation.
    /// </summary>
    /// <param name="service">The <see cref="FirefoxDriverService"/> to use.</param>
    /// <param name="options">The <see cref="FirefoxOptions"/> to be used with the Firefox driver.</param>
    /// <param name="commandTimeout">The maximum amount of time to wait for each command.</param>
    /// <exception cref="ArgumentNullException">If <paramref name="service"/> or <paramref name="options"/> are <see langword="null"/>.</exception>
    public FirefoxDriver(FirefoxDriverService service, FirefoxOptions options, TimeSpan commandTimeout)
        : base(GenerateDriverServiceCommandExecutor(service, options, commandTimeout), ConvertOptionsToCapabilities(options))
    {
        // Add the custom commands unique to Firefox
        this.AddCustomFirefoxCommands();
    }

    /// <summary>
    /// Uses DriverFinder to set Service attributes if necessary when creating the command executor
    /// </summary>
    /// <param name="service"></param>
    /// <param name="commandTimeout"></param>
    /// <param name="options"></param>
    /// <returns></returns>
    /// <exception cref="ArgumentNullException">If <paramref name="options"/> is <see langword="null"/>.</exception>
    private static ICommandExecutor GenerateDriverServiceCommandExecutor(DriverService service, DriverOptions options, TimeSpan commandTimeout)
    {
        if (options is null)
        {
            throw new ArgumentNullException(nameof(options));
        }

        if (service is null)
        {
            throw new ArgumentNullException(nameof(service));
        }

        if (service.DriverServicePath == null)
        {
            DriverFinder finder = new DriverFinder(options);
            string fullServicePath = finder.GetDriverPath();
            service.DriverServicePath = Path.GetDirectoryName(fullServicePath);
            service.DriverServiceExecutableName = Path.GetFileName(fullServicePath);
            if (finder.TryGetBrowserPath(out string? browserPath))
            {
                options.BinaryLocation = browserPath;
                options.BrowserVersion = null;
            }
        }
        return new DriverServiceCommandExecutor(service, commandTimeout);
    }

    /// <summary>
    /// Gets a read-only dictionary of the custom WebDriver commands defined for FirefoxDriver.
    /// The keys of the dictionary are the names assigned to the command; the values are the
    /// <see cref="CommandInfo"/> objects describing the command behavior.
    /// </summary>
    public static IReadOnlyDictionary<string, CommandInfo> CustomCommandDefinitions => new ReadOnlyDictionary<string, CommandInfo>(firefoxCustomCommands);

    /// <summary>
    /// Gets or sets the <see cref="IFileDetector"/> responsible for detecting
    /// sequences of keystrokes representing file paths and names.
    /// </summary>
    /// <remarks>The Firefox driver does not allow a file detector to be set,
    /// as the server component of the Firefox driver only allows uploads from
    /// the local computer environment. Attempting to set this property has no
    /// effect, but does not throw an exception. If you  are attempting to run
    /// the Firefox driver remotely, use <see cref="RemoteWebDriver"/> in
    /// conjunction with a standalone WebDriver server.</remarks>
    public override IFileDetector FileDetector
    {
        get => base.FileDetector;
        set { }
    }

    /// <summary>
    /// Sets the command context used when issuing commands to <c>geckodriver</c>.
    /// </summary>
    /// <exception cref="WebDriverException">If response is not recognized</exception>
    /// <returns>The context of commands.</returns>
    public FirefoxCommandContext GetContext()
    {
        Response commandResponse = this.Execute(GetContextCommand, null);

        if (commandResponse.Value is not string response
            || !Enum.TryParse(response, ignoreCase: true, out FirefoxCommandContext output))
        {
            throw new WebDriverException(string.Format(CultureInfo.InvariantCulture, "Do not recognize response: {0}; expected Context or Chrome", commandResponse.Value));
        }

        return output;
    }

    /// <summary>
    /// Sets the command context used when issuing commands to <c>geckodriver</c>.
    /// </summary>
    /// <param name="context">The <see cref="FirefoxCommandContext"/> value to which to set the context.</param>
    public void SetContext(FirefoxCommandContext context)
    {
        string contextValue = context.ToString().ToLowerInvariant();
        Dictionary<string, object> parameters = new Dictionary<string, object>();
        parameters["context"] = contextValue;
        this.Execute(SetContextCommand, parameters);
    }

    /// <summary>
    /// Installs a Firefox add-on from a directory.
    /// </summary>
    /// <param name="addOnDirectoryToInstall">Full path of the directory of the add-on to install.</param>
    /// <param name="temporary">Whether the add-on is temporary; required for unsigned add-ons.</param>
    /// <returns>The unique identifier of the installed add-on.</returns>
    /// <exception cref="ArgumentNullException">If <paramref name="addOnDirectoryToInstall"/> is null or empty.</exception>
    /// <exception cref="ArgumentException">If the directory at <paramref name="addOnDirectoryToInstall"/> does not exist.</exception>
    public string InstallAddOnFromDirectory(string addOnDirectoryToInstall, bool temporary = false)
    {
        if (string.IsNullOrEmpty(addOnDirectoryToInstall))
        {
            throw new ArgumentNullException(nameof(addOnDirectoryToInstall), "Add-on file name must not be null or the empty string");
        }

        if (!Directory.Exists(addOnDirectoryToInstall))
        {
            throw new ArgumentException("Directory " + addOnDirectoryToInstall + " does not exist", nameof(addOnDirectoryToInstall));
        }

        string addOnFileToInstall = Path.Combine(Path.GetTempPath(), "addon" + new Random().Next() + ".zip");
        ZipFile.CreateFromDirectory(addOnDirectoryToInstall, addOnFileToInstall);

        return this.InstallAddOnFromFile(addOnFileToInstall, temporary);
    }

    /// <summary>
    /// Installs a Firefox add-on from a file, typically a .xpi file.
    /// </summary>
    /// <param name="addOnFileToInstall">Full path and file name of the add-on to install.</param>
    /// <param name="temporary">Whether the add-on is temporary; required for unsigned add-ons.</param>
    /// <returns>The unique identifier of the installed add-on.</returns>
    /// <exception cref="ArgumentNullException">
    /// <para>If <paramref name="addOnFileToInstall"/> is null or empty.</para>
    /// or
    /// <para>If the file at <paramref name="addOnFileToInstall"/> does not exist.</para>
    /// </exception>
    public string InstallAddOnFromFile(string addOnFileToInstall, bool temporary = false)
    {
        if (string.IsNullOrEmpty(addOnFileToInstall))
        {
            throw new ArgumentNullException(nameof(addOnFileToInstall), "Add-on file name must not be null or the empty string");
        }

        byte[] addOnBytes;
        try
        {
            addOnBytes = File.ReadAllBytes(addOnFileToInstall);
        }
        catch (Exception ex)
        {
            throw new ArgumentException($"Failed to read from file {addOnFileToInstall}", nameof(addOnFileToInstall), ex);
        }

        string base64EncodedAddOn = Convert.ToBase64String(addOnBytes);

        return this.InstallAddOn(base64EncodedAddOn, temporary);
    }

    /// <summary>
    /// Installs a Firefox add-on.
    /// </summary>
    /// <param name="base64EncodedAddOn">The base64-encoded string representation of the add-on binary.</param>
    /// <param name="temporary">Whether the add-on is temporary; required for unsigned add-ons.</param>
    /// <returns>The unique identifier of the installed add-on.</returns>
    /// <exception cref="ArgumentNullException">If <paramref name="base64EncodedAddOn"/> is null or empty.</exception>
    public string InstallAddOn(string base64EncodedAddOn, bool temporary = false)
    {
        if (string.IsNullOrEmpty(base64EncodedAddOn))
        {
            throw new ArgumentNullException(nameof(base64EncodedAddOn), "Base64 encoded add-on must not be null or the empty string");
        }

        Dictionary<string, object> parameters = new Dictionary<string, object>
        {
            ["addon"] = base64EncodedAddOn,
            ["temporary"] = temporary
        };
        Response response = this.Execute(InstallAddOnCommand, parameters);

        return (string)response.Value!;
    }

    /// <summary>
    /// Uninstalls a Firefox add-on.
    /// </summary>
    /// <param name="addOnId">The ID of the add-on to uninstall.</param>
    /// <exception cref="ArgumentNullException">If <paramref name="addOnId"/> is null or empty.</exception>
    public void UninstallAddOn(string addOnId)
    {
        if (string.IsNullOrEmpty(addOnId))
        {
            throw new ArgumentNullException(nameof(addOnId), "Base64 encoded add-on must not be null or the empty string");
        }

        Dictionary<string, object> parameters = new Dictionary<string, object>();
        parameters["id"] = addOnId;
        this.Execute(UninstallAddOnCommand, parameters);
    }

    /// <summary>
    /// Gets a <see cref="Screenshot"/> object representing the image of the full page on the screen.
    /// </summary>
    /// <returns>A <see cref="Screenshot"/> object containing the image.</returns>
    public Screenshot GetFullPageScreenshot()
    {
        Response screenshotResponse = this.Execute(GetFullPageScreenshotCommand, null);

        screenshotResponse.EnsureValueIsNotNull();
        string base64 = screenshotResponse.Value.ToString()!;
        return new Screenshot(base64);
    }

    /// <summary>
    /// In derived classes, the <see cref="PrepareEnvironment"/> method prepares the environment for test execution.
    /// </summary>
    protected virtual void PrepareEnvironment()
    {
        // Does nothing, but provides a hook for subclasses to do "stuff"
    }

    /// <summary>
    /// Disposes of the FirefoxDriver and frees all resources.
    /// </summary>
    /// <param name="disposing">A value indicating whether the user initiated the
    /// disposal of the object. Pass <see langword="true"/> if the user is actively
    /// disposing the object; otherwise <see langword="false"/>.</param>
    protected override void Dispose(bool disposing)
    {
        base.Dispose(disposing);
    }

    private static ICapabilities ConvertOptionsToCapabilities(FirefoxOptions options)
    {
        if (options == null)
        {
            throw new ArgumentNullException(nameof(options), "options must not be null");
        }

        return options.ToCapabilities();
    }

    private void AddCustomFirefoxCommands()
    {
        foreach (KeyValuePair<string, CommandInfo> entry in CustomCommandDefinitions)
        {
            this.RegisterInternalDriverCommand(entry.Key, entry.Value);
        }
    }
}
