/*
 * Copyright (C) 2015 The Android Open Source Project
 *
 * Licensed 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.
 */

package androidx.test.espresso.web.matcher;

import static androidx.test.internal.util.Checks.checkNotNull;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.is;

import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import androidx.test.espresso.remote.annotation.RemoteMsgConstructor;
import androidx.test.espresso.remote.annotation.RemoteMsgField;
import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathExpression;
import javax.xml.xpath.XPathExpressionException;
import javax.xml.xpath.XPathFactory;
import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.hamcrest.TypeSafeMatcher;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;

/**
 * A collection of hamcrest matchers for objects in the org.w3c.dom package (such as {@link
 * Document} and {@link Element}).
 */
public final class DomMatchers {

  private DomMatchers() {}

  /** Returns a matcher that matches Documents that have a body containing the given text. */
  public static Matcher<Document> containingTextInBody(String text) {
    checkNotNull(text);
    return withBody(withTextContent(containsString(text)));
  }

  /** Returns a matcher that matches {@link Document}s with body that matches the given matcher. */
  public static Matcher<Document> withBody(final Matcher<Element> bodyMatcher) {
    return new WithBodyMatcher(bodyMatcher);
  }

  /**
   * Returns a matcher that matches {@link Document}s that have at least one element with the given
   * id.
   */
  public static Matcher<Document> hasElementWithId(final String id) {
    return new HasElementWithIdMatcher(id);
  }

  /**
   * Matches {@link Document}s that have an {@link Element} with the given id that matches the given
   * element matcher.
   */
  public static Matcher<Document> elementById(
      final String id, final Matcher<Element> elementMatcher) {
    return new ElementByIdMatcher(id, elementMatcher);
  }

  /**
   * Returns a matcher that matches {@link Document}s that have at least one element with the given
   * xpath.
   */
  public static Matcher<Document> hasElementWithXpath(final String xpath) {
    return new HasElementWithXPathMatcher(xpath);
  }

  /**
   * Matches a XPath and validates it against the first {@link Element} that it finds in the {@link
   * NodeList}.
   */
  public static Matcher<Document> elementByXPath(
      final String xpath, final Matcher<Element> elementMatcher) {
    return new ElementByXPathMatcher(xpath, elementMatcher);
  }

  private static NodeList extractNodeListForXPath(String xpath, Document document) {
    try {
      XPath xPath = XPathFactory.newInstance().newXPath();
      XPathExpression expr = xPath.compile(xpath);
      return (NodeList) expr.evaluate(document, XPathConstants.NODESET);
    } catch (XPathExpressionException e) {
      return null;
    }
  }

  /**
   * Returns a matcher that matches {@link Element}s with the given textContent. Equivalent of
   * withTextContent(is(textContent)).
   */
  public static Matcher<Element> withTextContent(String textContent) {
    return withTextContent(is(textContent));
  }

  /**
   * Returns a matcher that matches {@link Element}s that have textContent matching the given
   * matcher.
   */
  public static Matcher<Element> withTextContent(final Matcher<String> textContentMatcher) {
    return new WithTextContentMatcher(textContentMatcher);
  }

  @VisibleForTesting
  static final class WithBodyMatcher extends TypeSafeMatcher<Document> {

    @RemoteMsgField(order = 0)
    private final Matcher<Element> bodyMatcher;

    @RemoteMsgConstructor
    WithBodyMatcher(@NonNull final Matcher<Element> bodyMatcher) {
      this.bodyMatcher = checkNotNull(bodyMatcher, "bodyMatcher cannot be null");
    }

    @Override
    public void describeTo(Description description) {
      description.appendText("with body: ");
      bodyMatcher.describeTo(description);
    }

    @Override
    public boolean matchesSafely(Document document) {
      NodeList nodeList = document.getElementsByTagName("body");
      if (nodeList.getLength() == 0) {
        return false;
      }
      return bodyMatcher.matches(nodeList.item(0));
    }
  }

  @VisibleForTesting
  static final class HasElementWithIdMatcher extends TypeSafeMatcher<Document> {

    @RemoteMsgField(order = 0)
    private final String elementId;

    @RemoteMsgConstructor
    HasElementWithIdMatcher(final String elementId) {
      this.elementId = checkNotNull(elementId);
    }

    @Override
    public void describeTo(Description description) {
      description.appendText("has element with id: " + elementId);
    }

    @Override
    public boolean matchesSafely(Document document) {
      return document.getElementById(elementId) != null;
    }
  }

  @VisibleForTesting
  static final class ElementByIdMatcher extends TypeSafeMatcher<Document> {

    @RemoteMsgField(order = 0)
    private final String elementId;

    @RemoteMsgField(order = 1)
    private final Matcher<Element> elementMatcher;

    @RemoteMsgConstructor
    ElementByIdMatcher(final String elementId, final Matcher<Element> elementMatcher) {
      this.elementId = checkNotNull(elementId);
      this.elementMatcher = checkNotNull(elementMatcher);
    }

    @Override
    public void describeTo(Description description) {
      description.appendText(String.format("element with id '%s' matches: ", elementId));
      elementMatcher.describeTo(description);
    }

    @Override
    public boolean matchesSafely(Document document) {
      return elementMatcher.matches(document.getElementById(elementId));
    }
  }

  @VisibleForTesting
  static final class HasElementWithXPathMatcher extends TypeSafeMatcher<Document> {

    @RemoteMsgField(order = 0)
    private final String xpath;

    @RemoteMsgConstructor
    HasElementWithXPathMatcher(final String xpath) {
      this.xpath = checkNotNull(xpath);
    }

    @Override
    public void describeTo(Description description) {
      description.appendText("has element with xpath: " + xpath);
    }

    @Override
    public boolean matchesSafely(Document document) {
      NodeList nodeList = extractNodeListForXPath(xpath, document);
      return nodeList != null && nodeList.getLength() != 0;
    }
  }

  @VisibleForTesting
  static final class ElementByXPathMatcher extends TypeSafeMatcher<Document> {

    @RemoteMsgField(order = 0)
    private final String xpath;

    @RemoteMsgField(order = 1)
    private final Matcher<Element> elementMatcher;

    @RemoteMsgConstructor
    ElementByXPathMatcher(final String xpath, final Matcher<Element> elementMatcher) {
      this.xpath = checkNotNull(xpath);
      this.elementMatcher = checkNotNull(elementMatcher);
    }

    @Override
    public void describeTo(Description description) {
      description.appendText(String.format("element with xpath '%s' matches: ", xpath));
      elementMatcher.describeTo(description);
    }

    @Override
    public boolean matchesSafely(Document document) {
      NodeList nodeList = extractNodeListForXPath(xpath, document);
      if (nodeList == null || nodeList.getLength() == 0) {
        return false;
      }
      if (nodeList.getLength() > 1) {
        throw new AmbiguousElementMatcherException(xpath);
      }
      if (nodeList.item(0).getNodeType() != Node.ELEMENT_NODE) {
        return false;
      }
      Element element = (Element) nodeList.item(0);
      return elementMatcher.matches(element);
    }
  }

  @VisibleForTesting
  static final class WithTextContentMatcher extends TypeSafeMatcher<Element> {

    @RemoteMsgField(order = 0)
    private final Matcher<String> textContentMatcher;

    @RemoteMsgConstructor
    WithTextContentMatcher(@NonNull Matcher<String> textContentMatcher) {
      this.textContentMatcher =
          checkNotNull(textContentMatcher, "textContentMatcher cannot be null");
    }

    @Override
    protected boolean matchesSafely(Element element) {
      return textContentMatcher.matches(element.getTextContent());
    }

    @Override
    public void describeTo(Description description) {
      description.appendText("with text content: ");
      textContentMatcher.describeTo(description);
    }
  }
}
