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

namespace OpenQA.Selenium
{
    /// <summary>
    /// Provides a mechanism by which to find elements within a document.
    /// </summary>
    /// <remarks>It is possible to create your own locating mechanisms for finding documents.
    /// In order to do this,subclass this class and override the protected methods. However,
    /// it is expected that that all subclasses rely on the basic finding mechanisms provided
    /// through static methods of this class. An example of this can be found in OpenQA.Support.ByIdOrName
    /// </remarks>
    [Serializable]
    public class By
    {
        private static readonly string CssSelectorMechanism = "css selector";
        private static readonly string XPathSelectorMechanism = "xpath";
        private static readonly string TagNameMechanism = "tag name";
        private static readonly string LinkTextMechanism = "link text";
        private static readonly string PartialLinkTextMechanism = "partial link text";

        private string description = "OpenQA.Selenium.By";
        private string mechanism = string.Empty;
        private string criteria = string.Empty;
        private Func<ISearchContext, IWebElement> findElementMethod;
        private Func<ISearchContext, ReadOnlyCollection<IWebElement>> findElementsMethod;

        /// <summary>
        /// Initializes a new instance of the <see cref="By"/> class.
        /// </summary>
        protected By()
        {
        }

        /// <summary>
        /// Intializes a new instance of the <see cref="By"/> class using the specified mechanism and critieria for finding elements.
        /// </summary>
        /// <param name="mechanism">The mechanism to use in finding elements.</param>
        /// <param name="criteria">The criteria to use in finding elements.</param>
        /// <remarks>
        /// Customizing nothing else, instances using this constructor will attempt to find elemsnts
        /// using the <see cref="IFindsElement.FindElement(string, string)"/> method, taking string arguments.
        /// </remarks>
        protected By(string mechanism, string criteria)
        {
            this.mechanism = mechanism;
            this.criteria = criteria;
            this.findElementMethod = (ISearchContext context) => ((IFindsElement)context).FindElement(this.mechanism, this.criteria);
            this.findElementsMethod = (ISearchContext context) => ((IFindsElement)context).FindElements(this.mechanism, this.criteria);
        }

        /// <summary>
        /// Initializes a new instance of the <see cref="By"/> class using the given functions to find elements.
        /// </summary>
        /// <param name="findElementMethod">A function that takes an object implementing <see cref="ISearchContext"/>
        /// and returns the found <see cref="IWebElement"/>.</param>
        /// <param name="findElementsMethod">A function that takes an object implementing <see cref="ISearchContext"/>
        /// and returns a <see cref="ReadOnlyCollection{T}"/> of the found<see cref="IWebElement">IWebElements</see>.
        /// <see cref="IWebElement">IWebElements</see>/>.</param>
        protected By(Func<ISearchContext, IWebElement> findElementMethod, Func<ISearchContext, ReadOnlyCollection<IWebElement>> findElementsMethod)
        {
            this.findElementMethod = findElementMethod;
            this.findElementsMethod = findElementsMethod;
        }

        /// <summary>
        /// Gets the value of the mechanism for this <see cref="By"/> class instance.
        /// </summary>
        public string Mechanism
        {
            get { return this.mechanism; }
        }

        /// <summary>
        /// Gets the value of the criteria for this <see cref="By"/> class instance.
        /// </summary>
        public string Criteria
        {
            get { return this.criteria; }
        }

        /// <summary>
        /// Gets or sets the value of the description for this <see cref="By"/> class instance.
        /// </summary>
        protected string Description
        {
            get { return this.description; }
            set { this.description = value; }
        }

        /// <summary>
        /// Gets or sets the method used to find a single element matching specified criteria.
        /// </summary>
        protected Func<ISearchContext, IWebElement> FindElementMethod
        {
            get { return this.findElementMethod; }
            set { this.findElementMethod = value; }
        }

        /// <summary>
        /// Gets or sets the method used to find all elements matching specified criteria.
        /// </summary>
        protected Func<ISearchContext, ReadOnlyCollection<IWebElement>> FindElementsMethod
        {
            get { return this.findElementsMethod; }
            set { this.findElementsMethod = value; }
        }

        /// <summary>
        /// Determines if two <see cref="By"/> instances are equal.
        /// </summary>
        /// <param name="one">One instance to compare.</param>
        /// <param name="two">The other instance to compare.</param>
        /// <returns><see langword="true"/> if the two instances are equal; otherwise, <see langword="false"/>.</returns>
        public static bool operator ==(By one, By two)
        {
            // If both are null, or both are same instance, return true.
            if (object.ReferenceEquals(one, two))
            {
                return true;
            }

            // If one is null, but not both, return false.
            if (((object)one == null) || ((object)two == null))
            {
                return false;
            }

            return one.Equals(two);
        }

        /// <summary>
        /// Determines if two <see cref="By"/> instances are unequal.
        /// </summary>s
        /// <param name="one">One instance to compare.</param>
        /// <param name="two">The other instance to compare.</param>
        /// <returns><see langword="true"/> if the two instances are not equal; otherwise, <see langword="false"/>.</returns>
        public static bool operator !=(By one, By two)
        {
            return !(one == two);
        }

        /// <summary>
        /// Gets a mechanism to find elements by their ID.
        /// </summary>
        /// <param name="idToFind">The ID to find.</param>
        /// <returns>A <see cref="By"/> object the driver can use to find the elements.</returns>
        public static By Id(string idToFind)
        {
            if (idToFind == null)
            {
                throw new ArgumentNullException(nameof(idToFind), "Cannot find elements with a null id attribute.");
            }

            string selector = By.EscapeCssSelector(idToFind);
            By by = new By(CssSelectorMechanism, "#" + selector);
            by.description = "By.Id: " + idToFind;
            if (string.IsNullOrEmpty(selector))
            {
                // Finding multiple elements with an empty ID will return
                // an empty list. However, finding by a CSS selector of '#'
                // throws an exception, even in the multiple elements case,
                // which means we need to short-circuit that behavior.
                by.findElementsMethod = (ISearchContext context) => new List<IWebElement>().AsReadOnly();
            }

            return by;
        }

        /// <summary>
        /// Gets a mechanism to find elements by their link text.
        /// </summary>
        /// <param name="linkTextToFind">The link text to find.</param>
        /// <returns>A <see cref="By"/> object the driver can use to find the elements.</returns>
        public static By LinkText(string linkTextToFind)
        {
            if (linkTextToFind == null)
            {
                throw new ArgumentNullException(nameof(linkTextToFind), "Cannot find elements when link text is null.");
            }

            By by = new By(LinkTextMechanism, linkTextToFind);
            by.description = "By.LinkText: " + linkTextToFind;
            return by;
        }

        /// <summary>
        /// Gets a mechanism to find elements by their name.
        /// </summary>
        /// <param name="nameToFind">The name to find.</param>
        /// <returns>A <see cref="By"/> object the driver can use to find the elements.</returns>
        public static By Name(string nameToFind)
        {
            if (nameToFind == null)
            {
                throw new ArgumentNullException(nameof(nameToFind), "Cannot find elements when name text is null.");
            }

            string selector = "*[name =\"" + By.EscapeCssSelector(nameToFind) + "\"]";
            By by = new By(CssSelectorMechanism, selector);
            by.description = "By.Name: " + nameToFind;
            return by;
        }

        /// <summary>
        /// Gets a mechanism to find elements by an XPath query.
        /// When searching within a WebElement using xpath be aware that WebDriver follows standard conventions:
        /// a search prefixed with "//" will search the entire document, not just the children of this current node.
        /// Use ".//" to limit your search to the children of this WebElement.
        /// </summary>
        /// <param name="xpathToFind">The XPath query to use.</param>
        /// <returns>A <see cref="By"/> object the driver can use to find the elements.</returns>
        public static By XPath(string xpathToFind)
        {
            if (xpathToFind == null)
            {
                throw new ArgumentNullException(nameof(xpathToFind), "Cannot find elements when the XPath expression is null.");
            }

            By by = new By(XPathSelectorMechanism, xpathToFind);
            by.description = "By.XPath: " + xpathToFind;
            return by;
        }

        /// <summary>
        /// Gets a mechanism to find elements by their CSS class.
        /// </summary>
        /// <param name="classNameToFind">The CSS class to find.</param>
        /// <returns>A <see cref="By"/> object the driver can use to find the elements.</returns>
        /// <remarks>If an element has many classes then this will match against each of them.
        /// For example if the value is "one two onone", then the following values for the
        /// className parameter will match: "one" and "two".</remarks>
        public static By ClassName(string classNameToFind)
        {
            if (classNameToFind == null)
            {
                throw new ArgumentNullException(nameof(classNameToFind), "Cannot find elements when the class name expression is null.");
            }

            string selector = "." + By.EscapeCssSelector(classNameToFind);
            if (selector.Contains(" "))
            {
                // Finding elements by class name with whitespace is not allowed.
                // However, converting the single class name to a valid CSS selector
                // by prepending a '.' may result in a still-valid, but incorrect
                // selector. Thus, we short-ciruit that behavior here.
                throw new InvalidSelectorException("Compound class names not allowed. Cannot have whitespace in class name. Use CSS selectors instead.");
            }

            By by = new By(CssSelectorMechanism, selector);
            by.description = "By.ClassName[Contains]: " + classNameToFind;
            return by;
        }

        /// <summary>
        /// Gets a mechanism to find elements by a partial match on their link text.
        /// </summary>
        /// <param name="partialLinkTextToFind">The partial link text to find.</param>
        /// <returns>A <see cref="By"/> object the driver can use to find the elements.</returns>
        public static By PartialLinkText(string partialLinkTextToFind)
        {
            if (partialLinkTextToFind == null)
            {
                throw new ArgumentNullException(nameof(partialLinkTextToFind), "Cannot find elements when partial link text is null.");
            }

            By by = new By(PartialLinkTextMechanism, partialLinkTextToFind);
            by.description = "By.PartialLinkText: " + partialLinkTextToFind;
            return by;
        }

        /// <summary>
        /// Gets a mechanism to find elements by their tag name.
        /// </summary>
        /// <param name="tagNameToFind">The tag name to find.</param>
        /// <returns>A <see cref="By"/> object the driver can use to find the elements.</returns>
        public static By TagName(string tagNameToFind)
        {
            if (tagNameToFind == null)
            {
                throw new ArgumentNullException(nameof(tagNameToFind), "Cannot find elements when name tag name is null.");
            }

            By by = new By(TagNameMechanism, tagNameToFind);
            by.description = "By.TagName: " + tagNameToFind;
            return by;
        }

        /// <summary>
        /// Gets a mechanism to find elements by their cascading style sheet (CSS) selector.
        /// </summary>
        /// <param name="cssSelectorToFind">The CSS selector to find.</param>
        /// <returns>A <see cref="By"/> object the driver can use to find the elements.</returns>
        public static By CssSelector(string cssSelectorToFind)
        {
            if (cssSelectorToFind == null)
            {
                throw new ArgumentNullException(nameof(cssSelectorToFind), "Cannot find elements when name CSS selector is null.");
            }

            By by = new By(CssSelectorMechanism, cssSelectorToFind);
            by.description = "By.CssSelector: " + cssSelectorToFind;
            return by;
        }

        /// <summary>
        /// Finds the first element matching the criteria.
        /// </summary>
        /// <param name="context">An <see cref="ISearchContext"/> object to use to search for the elements.</param>
        /// <returns>The first matching <see cref="IWebElement"/> on the current context.</returns>
        public virtual IWebElement FindElement(ISearchContext context)
        {
            return this.findElementMethod(context);
        }

        /// <summary>
        /// Finds all elements matching the criteria.
        /// </summary>
        /// <param name="context">An <see cref="ISearchContext"/> object to use to search for the elements.</param>
        /// <returns>A <see cref="ReadOnlyCollection{T}"/> of all <see cref="IWebElement">WebElements</see>
        /// matching the current criteria, or an empty list if nothing matches.</returns>
        public virtual ReadOnlyCollection<IWebElement> FindElements(ISearchContext context)
        {
            return this.findElementsMethod(context);
        }

        /// <summary>
        /// Gets a string representation of the finder.
        /// </summary>
        /// <returns>The string displaying the finder content.</returns>
        public override string ToString()
        {
            return this.description;
        }

        /// <summary>
        /// Determines whether the specified <see cref="object">Object</see> is equal
        /// to the current <see cref="object">Object</see>.
        /// </summary>
        /// <param name="obj">The <see cref="object">Object</see> to compare with the
        /// current <see cref="object">Object</see>.</param>
        /// <returns><see langword="true"/> if the specified <see cref="object">Object</see>
        /// is equal to the current <see cref="object">Object</see>; otherwise,
        /// <see langword="false"/>.</returns>
        public override bool Equals(object obj)
        {
            var other = obj as By;

            // TODO(dawagner): This isn't ideal
            return other != null && this.description.Equals(other.description);
        }

        /// <summary>
        /// Serves as a hash function for a particular type.
        /// </summary>
        /// <returns>A hash code for the current <see cref="object">Object</see>.</returns>
        public override int GetHashCode()
        {
            return this.description.GetHashCode();
        }

        /// <summary>
        /// Escapes invalid characters in a CSS selector.
        /// </summary>
        /// <param name="selector">The selector to escape.</param>
        /// <returns>The selector with invalid characters escaped.</returns>
        internal static string EscapeCssSelector(string selector)
        {
            string escaped = Regex.Replace(selector, @"([ '""\\#.:;,!?+<>=~*^$|%&@`{}\-/\[\]\(\)])", @"\$1");
            if (selector.Length > 0 && char.IsDigit(selector[0]))
            {
                escaped = @"\" + (30 + int.Parse(selector.Substring(0, 1), CultureInfo.InvariantCulture)).ToString(CultureInfo.InvariantCulture) + " " + selector.Substring(1);
            }

            return escaped;
        }
    }
}
