// <copyright file="RemoteSessionSettings.cs" company="WebDriver 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;
using System.Collections.Generic;
using System.Globalization;
using System.Text.Json;

namespace OpenQA.Selenium
{
    /// <summary>
    /// Base class for managing options specific to a browser driver.
    /// </summary>
    public class RemoteSessionSettings : ICapabilities
    {
        private const string FirstMatchCapabilityName = "firstMatch";
        private const string AlwaysMatchCapabilityName = "alwaysMatch";

        private readonly List<string> reservedSettingNames = new List<string>() { FirstMatchCapabilityName, AlwaysMatchCapabilityName };
        private DriverOptions mustMatchDriverOptions;
        private List<DriverOptions> firstMatchOptions = new List<DriverOptions>();
        private Dictionary<string, object> remoteMetadataSettings = new Dictionary<string, object>();

        /// <summary>
        /// Creates a new instance of the <see cref="RemoteSessionSettings"/> class.
        /// </summary>
        public RemoteSessionSettings()
        {
        }

        /// <summary>
        /// Creates a new instance of the <see cref="RemoteSessionSettings"/> class,
        /// containing the specified <see cref="DriverOptions"/> to use in the remote
        /// session.
        /// </summary>
        /// <param name="mustMatchDriverOptions">
        /// A <see cref="DriverOptions"/> object that contains values that must be matched
        /// by the remote end to create the remote session.
        /// </param>
        /// <param name="firstMatchDriverOptions">
        /// A list of <see cref="DriverOptions"/> objects that contain values that may be matched
        /// by the remote end to create the remote session.
        /// </param>
        public RemoteSessionSettings(DriverOptions mustMatchDriverOptions, params DriverOptions[] firstMatchDriverOptions)
        {
            this.mustMatchDriverOptions = mustMatchDriverOptions;
            foreach (DriverOptions firstMatchOption in firstMatchDriverOptions)
            {
                this.AddFirstMatchDriverOption(firstMatchOption);
            }
        }

        /// <summary>
        /// Gets a value indicating the options that must be matched by the remote end to create a session.
        /// </summary>
        internal DriverOptions MustMatchDriverOptions
        {
            get { return this.mustMatchDriverOptions; }
        }

        /// <summary>
        /// Gets a value indicating the number of options that may be matched by the remote end to create a session.
        /// </summary>
        internal int FirstMatchOptionsCount
        {
            get { return this.firstMatchOptions.Count; }
        }

        /// <summary>
        /// Gets the capability value with the specified name.
        /// </summary>
        /// <param name="capabilityName">The name of the capability to get.</param>
        /// <returns>The value of the capability.</returns>
        /// <exception cref="ArgumentException">
        /// The specified capability name is not in the set of capabilities.
        /// </exception>
        public object this[string capabilityName]
        {
            get
            {
                if (capabilityName == AlwaysMatchCapabilityName)
                {
                    return this.GetAlwaysMatchOptionsAsSerializableDictionary();
                }

                if (capabilityName == FirstMatchCapabilityName)
                {
                    return this.GetFirstMatchOptionsAsSerializableList();
                }

                if (!this.remoteMetadataSettings.ContainsKey(capabilityName))
                {
                    throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, "The capability {0} is not present in this set of capabilities", capabilityName));
                }

                return this.remoteMetadataSettings[capabilityName];
            }

        }

        /// <summary>
        /// Add a metadata setting to this set of remote session settings.
        /// </summary>
        /// <param name="settingName">The name of the setting to set.</param>
        /// <param name="settingValue">The value of the setting.</param>
        /// <remarks>
        /// The value to be set must be serializable to JSON for transmission
        /// across the wire to the remote end. To be JSON-serializable, the value
        /// must be a string, a numeric value, a boolean value, an object that
        /// implmeents <see cref="IEnumerable"/> that contains JSON-serializable
        /// objects, or a <see cref="Dictionary{TKey, TValue}"/> where the keys
        /// are strings and the values are JSON-serializable.
        /// </remarks>
        /// <exception cref="ArgumentException">
        /// Thrown if the setting name is null, the empty string, or one of the
        /// reserved names of metadata settings; or if the setting value is not
        /// JSON serializable.
        /// </exception>
        public void AddMetadataSetting(string settingName, object settingValue)
        {
            if (string.IsNullOrEmpty(settingName))
            {
                throw new ArgumentException("Metadata setting name cannot be null or empty", nameof(settingName));
            }

            if (this.reservedSettingNames.Contains(settingName))
            {
                throw new ArgumentException(string.Format("'{0}' is a reserved name for a metadata setting, and cannot be used as a name.", settingName), nameof(settingName));
            }

            if (!this.IsJsonSerializable(settingValue))
            {
                throw new ArgumentException("Metadata setting value must be JSON serializable.", nameof(settingValue));
            }

            this.remoteMetadataSettings[settingName] = settingValue;
        }

        /// <summary>
        /// Adds a <see cref="DriverOptions"/> object to the list of options containing values to be
        /// "first matched" by the remote end.
        /// </summary>
        /// <param name="options">The <see cref="DriverOptions"/> to add to the list of "first matched" options.</param>
        public void AddFirstMatchDriverOption(DriverOptions options)
        {
            if (mustMatchDriverOptions != null)
            {
                DriverOptionsMergeResult mergeResult = mustMatchDriverOptions.GetMergeResult(options);
                if (mergeResult.IsMergeConflict)
                {
                    string msg = string.Format(CultureInfo.InvariantCulture, "You cannot request the same capability in both must-match and first-match capabilities. You are attempting to add a first-match driver options object that defines a capability, '{0}', that is already defined in the must-match driver options.", mergeResult.MergeConflictOptionName);
                    throw new ArgumentException(msg, nameof(options));
                }
            }

            firstMatchOptions.Add(options);
        }

        /// <summary>
        /// Adds a <see cref="DriverOptions"/> object containing values that must be matched
        /// by the remote end to successfully create a session.
        /// </summary>
        /// <param name="options">The <see cref="DriverOptions"/> that must be matched by
        /// the remote end to successfully create a session.</param>
        public void SetMustMatchDriverOptions(DriverOptions options)
        {
            if (this.firstMatchOptions.Count > 0)
            {
                int driverOptionIndex = 0;
                foreach (DriverOptions firstMatchOption in this.firstMatchOptions)
                {
                    DriverOptionsMergeResult mergeResult = firstMatchOption.GetMergeResult(options);
                    if (mergeResult.IsMergeConflict)
                    {
                        string msg = string.Format(CultureInfo.InvariantCulture, "You cannot request the same capability in both must-match and first-match capabilities. You are attempting to add a must-match driver options object that defines a capability, '{0}', that is already defined in the first-match driver options with index {1}.", mergeResult.MergeConflictOptionName, driverOptionIndex);
                        throw new ArgumentException(msg, nameof(options));
                    }

                    driverOptionIndex++;
                }
            }

            this.mustMatchDriverOptions = options;
        }

        /// <summary>
        /// Gets a value indicating whether the browser has a given capability.
        /// </summary>
        /// <param name="capability">The capability to get.</param>
        /// <returns>Returns <see langword="true"/> if this set of capabilities has the capability;
        /// otherwise, <see langword="false"/>.</returns>
        public bool HasCapability(string capability)
        {
            if (capability == AlwaysMatchCapabilityName || capability == FirstMatchCapabilityName)
            {
                return true;
            }

            return this.remoteMetadataSettings.ContainsKey(capability);
        }

        /// <summary>
        /// Gets a capability of the browser.
        /// </summary>
        /// <param name="capability">The capability to get.</param>
        /// <returns>An object associated with the capability, or <see langword="null"/>
        /// if the capability is not set in this set of capabilities.</returns>
        public object GetCapability(string capability)
        {
            if (capability == AlwaysMatchCapabilityName)
            {
                return this.GetAlwaysMatchOptionsAsSerializableDictionary();
            }

            if (capability == FirstMatchCapabilityName)
            {
                return this.GetFirstMatchOptionsAsSerializableList();
            }

            if (this.remoteMetadataSettings.ContainsKey(capability))
            {
                return this.remoteMetadataSettings[capability];
            }

            return null;
        }

        /// <summary>
        /// Return a dictionary representation of this <see cref="RemoteSessionSettings"/>.
        /// </summary>
        /// <returns>A <see cref="Dictionary{TKey, TValue}"/> representation of this <see cref="RemoteSessionSettings"/>.</returns>
        public Dictionary<string, object> ToDictionary()
        {
            Dictionary<string, object> capabilitiesDictionary = new Dictionary<string, object>();

            foreach (KeyValuePair<string, object> remoteMetadataSetting in this.remoteMetadataSettings)
            {
                capabilitiesDictionary[remoteMetadataSetting.Key] = remoteMetadataSetting.Value;
            }

            if (this.mustMatchDriverOptions != null)
            {
                capabilitiesDictionary["alwaysMatch"] = GetAlwaysMatchOptionsAsSerializableDictionary();
            }

            if (this.firstMatchOptions.Count > 0)
            {
                List<object> optionsMatches = GetFirstMatchOptionsAsSerializableList();

                capabilitiesDictionary["firstMatch"] = optionsMatches;
            }

            return capabilitiesDictionary;
        }

        /// <summary>
        /// Return a string representation of the remote session settings to be sent.
        /// </summary>
        /// <returns>String representation of the remote session settings to be sent.</returns>
        public override string ToString()
        {
            return JsonSerializer.Serialize(this.ToDictionary(), new JsonSerializerOptions { WriteIndented = true });
        }

        internal DriverOptions GetFirstMatchDriverOptions(int firstMatchIndex)
        {
            if (firstMatchIndex < 0 || firstMatchIndex >= this.firstMatchOptions.Count)
            {
                throw new ArgumentException("Requested index must be greater than zero and less than the count of firstMatch options added.");
            }

            return this.firstMatchOptions[firstMatchIndex];
        }

        private IDictionary<string, object> GetAlwaysMatchOptionsAsSerializableDictionary()
        {
            return this.mustMatchDriverOptions.ToDictionary();
        }

        private List<object> GetFirstMatchOptionsAsSerializableList()
        {
            List<object> optionsMatches = new List<object>();
            foreach (DriverOptions options in this.firstMatchOptions)
            {
                optionsMatches.Add(options.ToDictionary());
            }

            return optionsMatches;
        }

        private bool IsJsonSerializable(object arg)
        {
            IEnumerable argAsEnumerable = arg as IEnumerable;
            IDictionary argAsDictionary = arg as IDictionary;

            if (arg is string || arg is float || arg is double || arg is int || arg is long || arg is bool || arg == null)
            {
                return true;
            }
            else if (argAsDictionary != null)
            {
                foreach (object key in argAsDictionary.Keys)
                {
                    if (!(key is string))
                    {
                        return false;
                    }
                }

                foreach (object value in argAsDictionary.Values)
                {
                    if (!IsJsonSerializable(value))
                    {
                        return false;
                    }
                }
            }
            else if (argAsEnumerable != null)
            {
                foreach (object item in argAsEnumerable)
                {
                    if (!IsJsonSerializable(item))
                    {
                        return false;
                    }
                }
            }
            else
            {
                return false;
            }

            return true;
        }
    }
}
