// <copyright file="SelectElement.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 System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Text;

namespace OpenQA.Selenium.Support.UI;

/// <summary>
/// Provides a convenience method for manipulating selections of options in an HTML select element.
/// </summary>
public class SelectElement : IWrapsElement
{
    /// <summary>
    /// Initializes a new instance of the <see cref="SelectElement"/> class.
    /// </summary>
    /// <param name="element">The element to be wrapped</param>
    /// <exception cref="ArgumentNullException">Thrown when the <see cref="IWebElement"/> object is <see langword="null"/></exception>
    /// <exception cref="UnexpectedTagNameException">Thrown when the element wrapped is not a &lt;select&gt; element.</exception>
    public SelectElement(IWebElement element)
    {
        if (element is null)
        {
            throw new ArgumentNullException(nameof(element), "element cannot be null");
        }

        string tagName = element.TagName;

        if (string.IsNullOrEmpty(tagName) || !string.Equals(tagName, "select", StringComparison.OrdinalIgnoreCase))
        {
            throw new UnexpectedTagNameException("select", tagName);
        }

        this.WrappedElement = element;

        // let check if it's a multiple
        string? attribute = element.GetAttribute("multiple");
        this.IsMultiple = attribute != null && !attribute.Equals("false", StringComparison.OrdinalIgnoreCase);
    }

    /// <summary>
    /// Gets the <see cref="IWebElement"/> wrapped by this object.
    /// </summary>
    public IWebElement WrappedElement { get; }

    /// <summary>
    /// Gets a value indicating whether the parent element supports multiple selections.
    /// </summary>
    public bool IsMultiple { get; }

    /// <summary>
    /// Gets the list of options for the select element.
    /// </summary>
    public IList<IWebElement> Options => this.WrappedElement.FindElements(By.TagName("option"));

    /// <summary>
    /// Gets the selected item within the select element.
    /// </summary>
    /// <remarks>If more than one item is selected this will return the first item.</remarks>
    /// <exception cref="NoSuchElementException">Thrown if no option is selected.</exception>
    public IWebElement SelectedOption
    {
        get
        {
            foreach (IWebElement option in this.Options)
            {
                if (option.Selected)
                {
                    return option;
                }
            }

            throw new NoSuchElementException("No option is selected");
        }
    }

    /// <summary>
    /// Gets all of the selected options within the select element.
    /// </summary>
    public IList<IWebElement> AllSelectedOptions
    {
        get
        {
            List<IWebElement> returnValue = new List<IWebElement>();
            foreach (IWebElement option in this.Options)
            {
                if (option.Selected)
                {
                    returnValue.Add(option);
                }
            }

            return returnValue;
        }
    }

    /// <summary>
    /// Select all options by the text displayed.
    /// </summary>
    /// <param name="text">The text of the option to be selected.</param>
    /// <param name="partialMatch">Default value is false. If true a partial match on the Options list will be performed, otherwise exact match.</param>
    /// <remarks>When given "Bar" this method would select an option like:
    /// <para>
    /// &lt;option value="foo"&gt;Bar&lt;/option&gt;
    /// </para>
    /// </remarks>
    /// <exception cref="ArgumentNullException">If <paramref name="text"/> is <see langword="null"/>.</exception>
    /// <exception cref="NoSuchElementException">Thrown if there is no element with the given text present.</exception>
    public void SelectByText(string text, bool partialMatch = false)
    {
        if (text is null)
        {
            throw new ArgumentNullException(nameof(text), "text must not be null");
        }

        bool matched = false;
        ReadOnlyCollection<IWebElement> options;

        if (!partialMatch)
        {
            // try to find the option via XPATH ...
            options = this.WrappedElement.FindElements(By.XPath(".//option[normalize-space(.) = " + EscapeQuotes(text) + "]"));
        }
        else
        {
            options = this.WrappedElement.FindElements(By.XPath(".//option[contains(normalize-space(.),  " + EscapeQuotes(text) + ")]"));
        }

        foreach (IWebElement option in options)
        {
            SetSelected(option, true);
            if (!this.IsMultiple)
            {
                return;
            }

            matched = true;
        }

        if (options.Count == 0 && text.Contains(" "))
        {
            string substringWithoutSpace = GetLongestSubstringWithoutSpace(text);
            IList<IWebElement> candidates;
            if (string.IsNullOrEmpty(substringWithoutSpace))
            {
                // hmm, text is either empty or contains only spaces - get all options ...
                candidates = this.WrappedElement.FindElements(By.TagName("option"));
            }
            else
            {
                // get candidates via XPATH ...
                candidates = this.WrappedElement.FindElements(By.XPath(".//option[contains(., " + EscapeQuotes(substringWithoutSpace) + ")]"));
            }

            foreach (IWebElement option in candidates)
            {
                if (text == option.Text)
                {
                    SetSelected(option, true);
                    if (!this.IsMultiple)
                    {
                        return;
                    }

                    matched = true;
                }
            }
        }

        if (!matched)
        {
            throw new NoSuchElementException("Cannot locate element with text: " + text);
        }
    }

    /// <summary>
    /// Select an option by the value.
    /// </summary>
    /// <param name="value">The value of the option to be selected.</param>
    /// <remarks>When given "foo" this method will select an option like:
    /// <para>
    /// &lt;option value="foo"&gt;Bar&lt;/option&gt;
    /// </para>
    /// </remarks>
    /// <exception cref="NoSuchElementException">Thrown when no element with the specified value is found.</exception>
    public void SelectByValue(string value)
    {
        StringBuilder builder = new StringBuilder(".//option[@value = ");
        builder.Append(EscapeQuotes(value));
        builder.Append("]");
        IList<IWebElement> options = this.WrappedElement.FindElements(By.XPath(builder.ToString()));

        bool matched = false;
        foreach (IWebElement option in options)
        {
            SetSelected(option, true);
            if (!this.IsMultiple)
            {
                return;
            }

            matched = true;
        }

        if (!matched)
        {
            throw new NoSuchElementException("Cannot locate option with value: " + value);
        }
    }

    /// <summary>
    /// Select the option by the index, as determined by the "index" attribute of the element.
    /// </summary>
    /// <param name="index">The value of the index attribute of the option to be selected.</param>
    /// <exception cref="NoSuchElementException">Thrown when no element exists with the specified index attribute.</exception>
    public void SelectByIndex(int index)
    {
        string match = index.ToString(CultureInfo.InvariantCulture);

        foreach (IWebElement option in this.Options)
        {
            if (option.GetAttribute("index") == match)
            {
                SetSelected(option, true);
                return;
            }
        }

        throw new NoSuchElementException("Cannot locate option with index: " + index);
    }

    /// <summary>
    /// Clear all selected entries. This is only valid when the SELECT supports multiple selections.
    /// </summary>
    /// <exception cref="WebDriverException">Thrown when attempting to deselect all options from a SELECT
    /// that does not support multiple selections.</exception>
    public void DeselectAll()
    {
        if (!this.IsMultiple)
        {
            throw new InvalidOperationException("You may only deselect all options if multi-select is supported");
        }

        foreach (IWebElement option in this.Options)
        {
            SetSelected(option, false);
        }
    }

    /// <summary>
    /// Deselect the option by the text displayed.
    /// </summary>
    /// <exception cref="InvalidOperationException">Thrown when attempting to deselect option from a SELECT
    /// that does not support multiple selections.</exception>
    /// <exception cref="NoSuchElementException">Thrown when no element exists with the specified test attribute.</exception>
    /// <param name="text">The text of the option to be deselected.</param>
    /// <remarks>When given "Bar" this method would deselect an option like:
    /// <para>
    /// &lt;option value="foo"&gt;Bar&lt;/option&gt;
    /// </para>
    /// </remarks>
    public void DeselectByText(string text)
    {
        if (!this.IsMultiple)
        {
            throw new InvalidOperationException("You may only deselect option if multi-select is supported");
        }

        bool matched = false;
        StringBuilder builder = new StringBuilder(".//option[normalize-space(.) = ");
        builder.Append(EscapeQuotes(text));
        builder.Append("]");
        IList<IWebElement> options = this.WrappedElement.FindElements(By.XPath(builder.ToString()));
        foreach (IWebElement option in options)
        {
            SetSelected(option, false);
            matched = true;
        }

        if (!matched)
        {
            throw new NoSuchElementException("Cannot locate option with text: " + text);
        }
    }

    /// <summary>
    /// Deselect the option having value matching the specified text.
    /// </summary>
    /// <exception cref="InvalidOperationException">Thrown when attempting to deselect option from a SELECT
    /// that does not support multiple selections.</exception>
    /// <exception cref="NoSuchElementException">Thrown when no element exists with the specified value attribute.</exception>
    /// <param name="value">The value of the option to deselect.</param>
    /// <remarks>When given "foo" this method will deselect an option like:
    /// <para>
    /// &lt;option value="foo"&gt;Bar&lt;/option&gt;
    /// </para>
    /// </remarks>
    public void DeselectByValue(string value)
    {
        if (!this.IsMultiple)
        {
            throw new InvalidOperationException("You may only deselect option if multi-select is supported");
        }

        bool matched = false;
        StringBuilder builder = new StringBuilder(".//option[@value = ");
        builder.Append(EscapeQuotes(value));
        builder.Append("]");
        IList<IWebElement> options = this.WrappedElement.FindElements(By.XPath(builder.ToString()));
        foreach (IWebElement option in options)
        {
            SetSelected(option, false);
            matched = true;
        }

        if (!matched)
        {
            throw new NoSuchElementException("Cannot locate option with value: " + value);
        }
    }

    /// <summary>
    /// Deselect the option by the index, as determined by the "index" attribute of the element.
    /// </summary>
    /// <exception cref="InvalidOperationException">Thrown when attempting to deselect option from a SELECT
    /// that does not support multiple selections.</exception>
    /// <exception cref="NoSuchElementException">Thrown when no element exists with the specified index attribute.</exception>
    /// <param name="index">The value of the index attribute of the option to deselect.</param>
    public void DeselectByIndex(int index)
    {
        if (!this.IsMultiple)
        {
            throw new InvalidOperationException("You may only deselect option if multi-select is supported");
        }

        string match = index.ToString(CultureInfo.InvariantCulture);
        foreach (IWebElement option in this.Options)
        {
            if (match == option.GetAttribute("index"))
            {
                SetSelected(option, false);
                return;
            }
        }

        throw new NoSuchElementException("Cannot locate option with index: " + index);
    }

    private static string EscapeQuotes(string toEscape)
    {
        // Convert strings with both quotes and ticks into: foo'"bar -> concat("foo'", '"', "bar")
        if (toEscape.IndexOf("\"", StringComparison.OrdinalIgnoreCase) > -1 && toEscape.IndexOf("'", StringComparison.OrdinalIgnoreCase) > -1)
        {
            bool quoteIsLast = false;
            if (toEscape.LastIndexOf("\"", StringComparison.OrdinalIgnoreCase) == toEscape.Length - 1)
            {
                quoteIsLast = true;
            }

            List<string> substrings = new List<string>(toEscape.Split('\"'));
            if (quoteIsLast && string.IsNullOrEmpty(substrings[substrings.Count - 1]))
            {
                // If the last character is a quote ('"'), we end up with an empty entry
                // at the end of the list, which is unnecessary. We don't want to split
                // ignoring *all* empty entries, since that might mask legitimate empty
                // strings. Instead, just remove the empty ending entry.
                substrings.RemoveAt(substrings.Count - 1);
            }

            StringBuilder quoted = new StringBuilder("concat(");
            for (int i = 0; i < substrings.Count; i++)
            {
                quoted.Append("\"").Append(substrings[i]).Append("\"");
                if (i == substrings.Count - 1)
                {
                    if (quoteIsLast)
                    {
                        quoted.Append(", '\"')");
                    }
                    else
                    {
                        quoted.Append(")");
                    }
                }
                else
                {
                    quoted.Append(", '\"', ");
                }
            }

            return quoted.ToString();
        }

        // Escape string with just a quote into being single quoted: f"oo -> 'f"oo'
        if (toEscape.IndexOf("\"", StringComparison.OrdinalIgnoreCase) > -1)
        {
            return string.Format(CultureInfo.InvariantCulture, "'{0}'", toEscape);
        }

        // Otherwise return the quoted string
        return string.Format(CultureInfo.InvariantCulture, "\"{0}\"", toEscape);
    }

    private static string GetLongestSubstringWithoutSpace(string s)
    {
        string result = string.Empty;
        foreach (string substring in s.Split(' '))
        {
            if (substring.Length > result.Length)
            {
                result = substring;
            }
        }

        return result;
    }

    private static void SetSelected(IWebElement option, bool select)
    {
        if (select && !option.Enabled)
        {
            throw new InvalidOperationException("You may not select a disabled option");
        }

        bool isSelected = option.Selected;
        if ((!isSelected && select) || (isSelected && !select))
        {
            option.Click();
        }
    }
}
