/*
 * Licensed to the Apache Software Foundation (ASF) under one or more contributor license
 * agreements. See the NOTICE file distributed with this work for additional information regarding
 * copyright ownership. The ASF 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.
 */
package org.apache.geode.management.internal.configuration.domain;

import java.io.DataInput;
import java.io.DataOutput;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringReader;
import java.io.StringWriter;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Map.Entry;

import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactoryConfigurationError;
import javax.xml.xpath.XPathExpressionException;

import org.apache.commons.lang.StringUtils;
import org.apache.logging.log4j.Logger;
import org.w3c.dom.Document;
import org.w3c.dom.Node;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;

import org.apache.geode.DataSerializer;
import org.apache.geode.InternalGemFireError;
import org.apache.geode.cache.Cache;
import org.apache.geode.cache.CacheFactory;
import org.apache.geode.internal.Assert;
import org.apache.geode.internal.Version;
import org.apache.geode.internal.VersionedDataSerializable;
import org.apache.geode.internal.cache.InternalCache;
import org.apache.geode.internal.cache.xmlcache.CacheXml;
import org.apache.geode.internal.cache.xmlcache.CacheXmlGenerator;
import org.apache.geode.internal.logging.LogService;
import org.apache.geode.management.internal.configuration.utils.XmlUtils;
import org.apache.geode.management.internal.configuration.utils.XmlUtils.XPathContext;

/**
 * Domain class for defining a GemFire entity in XML.
 */
public class XmlEntity implements VersionedDataSerializable {
  private static final long serialVersionUID = 1L;
  private static final Logger logger = LogService.getLogger();

  private transient volatile CacheProvider cacheProvider;

  private String type;

  @SuppressWarnings("unused")
  private String parentType;

  private Map<String, String> attributes = new HashMap<>();

  private String xmlDefinition;

  private String searchString;

  private String prefix = CacheXml.PREFIX;

  private String namespace = CacheXml.GEODE_NAMESPACE;

  private String childPrefix;

  private String childNamespace;

  /**
   * Produce a new XmlEntityBuilder.
   *
   * @return new XmlEntityBuilder.
   * @since GemFire 8.1
   */
  public static XmlEntityBuilder builder() {
    return new XmlEntityBuilder();
  }

  private static CacheProvider createDefaultCacheProvider() {
    return () -> (InternalCache) CacheFactory.getAnyInstance();
  }

  /**
   * Default constructor for serialization only.
   *
   * @deprecated Use {@link XmlEntity#builder()}.
   */
  @Deprecated
  public XmlEntity() {
    this.cacheProvider = createDefaultCacheProvider();
  }

  /**
   * Construct a new XmlEntity while creating XML from the cache using the element which has a type
   * and attribute matching those given.
   *
   * @param type Type of the XML element to search for. Should be one of the constants from the
   *        {@link CacheXml} class. For example, CacheXml.REGION.
   * @param key Key of the attribute to match, for example, "name" or "id".
   * @param value Value of the attribute to match.
   */
  public XmlEntity(final String type, final String key, final String value) {
    this.cacheProvider = createDefaultCacheProvider();
    this.type = type;
    this.attributes.put(key, value);

    init();
  }

  /**
   * Construct a new XmlEntity while creating Xml from the cache using the element which has
   * attributes matching those given
   *
   * @param parentType Parent type of the XML element to search for. Should be one of the constants
   *        from the {@link CacheXml} class. For example, CacheXml.REGION.
   *
   * @param parentKey Identifier for the parent elements such "name/id"
   * @param parentValue Value of the identifier
   * @param childType Child type of the XML element to search for within the parent . Should be one
   *        of the constants from the {@link CacheXml} class. For example, CacheXml.INDEX.
   * @param childKey Identifier for the child element such as "name/id"
   * @param childValue Value of the child element identifier
   */
  public XmlEntity(final String parentType, final String parentKey, final String parentValue,
      final String childType, final String childKey, final String childValue) {
    this.cacheProvider = createDefaultCacheProvider();
    this.parentType = parentType;
    this.type = childType;
    initializeSearchString(parentKey, parentValue, this.prefix, childKey, childValue);
  }

  /**
   * Construct a new XmlEntity while creating Xml from the cache using the element which has
   * attributes matching those given
   *
   * @param parentType Parent type of the XML element to search for. Should be one of the constants
   *        from the {@link CacheXml} class. For example, CacheXml.REGION.
   *
   * @param parentKey Identifier for the parent elements such "name/id"
   * @param parentValue Value of the identifier
   * @param childPrefix Namespace prefix for the child element such as "lucene"
   * @param childNamespace Namespace for the child element such as
   *        "http://geode.apache.org/schema/lucene"
   * @param childType Child type of the XML element to search for within the parent . Should be one
   *        of the constants from the {@link CacheXml} class. For example, CacheXml.INDEX.
   * @param childKey Identifier for the child element such as "name/id"
   * @param childValue Value of the child element identifier
   */
  public XmlEntity(final String parentType, final String parentKey, final String parentValue,
      final String childPrefix, final String childNamespace, final String childType,
      final String childKey, final String childValue) {
    // Note: Do not invoke init
    this.cacheProvider = createDefaultCacheProvider();
    this.parentType = parentType;
    this.type = childType;
    this.childPrefix = childPrefix;
    this.childNamespace = childNamespace;
    initializeSearchString(parentKey, parentValue, childPrefix, childKey, childValue);
  }

  public XmlEntity(final CacheProvider cacheProvider, final String parentType,
      final String childPrefix, final String childNamespace, final String childType,
      final String key, final String value) {
    this.cacheProvider = cacheProvider;
    this.parentType = parentType;
    this.type = childType;
    this.prefix = childPrefix;
    this.namespace = childNamespace;
    this.childPrefix = childPrefix;
    this.childNamespace = childNamespace;
    this.attributes.put(key, value);

    StringBuilder sb = new StringBuilder();
    sb.append("//").append(this.parentType);
    sb.append('/').append(childPrefix).append(':').append(this.type);
    this.searchString = sb.toString();
    this.xmlDefinition = parseXmlForDefinition();
  }

  private String parseXmlForDefinition() {
    final Cache cache = cacheProvider.getCache();

    final StringWriter stringWriter = new StringWriter();
    final PrintWriter printWriter = new PrintWriter(stringWriter);
    CacheXmlGenerator.generate(cache, printWriter, true, false, false);
    printWriter.close();
    InputSource inputSource = new InputSource(new StringReader(stringWriter.toString()));

    try {
      Document document = XmlUtils.getDocumentBuilder().parse(inputSource);
      Node element = document.getElementsByTagNameNS(childNamespace, type).item(0);
      if (null != element) {
        return XmlUtils.elementToString(element);
      }
    } catch (IOException | ParserConfigurationException | RuntimeException | SAXException
        | TransformerException e) {
      throw new InternalGemFireError("Could not parse XML when creating XMLEntity", e);
    }

    logger.warn("No XML definition could be found with name={} and attributes={}", type,
        attributes);
    return null;
  }

  private void initializeSearchString(final String parentKey, final String parentValue,
      final String childPrefix, final String childKey, final String childValue) {
    StringBuilder sb = new StringBuilder();
    sb.append("//").append(this.prefix).append(':').append(this.parentType);

    if (StringUtils.isNotBlank(parentKey) && StringUtils.isNotBlank(parentValue)) {
      sb.append("[@").append(parentKey).append("='").append(parentValue).append("']");
    }

    sb.append('/').append(childPrefix).append(':').append(this.type);

    if (StringUtils.isNotBlank(childKey) && StringUtils.isNotBlank(childValue)) {
      sb.append("[@").append(childKey).append("='").append(childValue).append("']");
    }
    this.searchString = sb.toString();
  }

  /**
   * Initialize new instances. Called from {@link #XmlEntity(String, String, String)} and
   * {@link XmlEntityBuilder#build()}.
   *
   * @since GemFire 8.1
   */
  private void init() {
    Assert.assertTrue(StringUtils.isNotBlank(type));
    Assert.assertTrue(StringUtils.isNotBlank(prefix));
    Assert.assertTrue(StringUtils.isNotBlank(namespace));
    Assert.assertTrue(attributes != null);

    if (null == xmlDefinition) {
      xmlDefinition = loadXmlDefinition();
    }
  }

  /**
   * Use the CacheXmlGenerator to create XML from the entity associated with the current cache.
   *
   * @return XML string representation of the entity.
   */
  private String loadXmlDefinition() {
    final Cache cache = cacheProvider.getCache();

    final StringWriter stringWriter = new StringWriter();
    final PrintWriter printWriter = new PrintWriter(stringWriter);
    CacheXmlGenerator.generate(cache, printWriter, true, false, false);
    printWriter.close();

    return loadXmlDefinition(stringWriter.toString());
  }

  /**
   * Used supplied xmlDocument to extract the XML for the defined XmlEntity.
   *
   * @param xmlDocument to extract XML from.
   * @return XML for XmlEntity if found, otherwise {@code null}.
   * @since GemFire 8.1
   */
  private String loadXmlDefinition(final String xmlDocument) {
    try {
      InputSource inputSource = new InputSource(new StringReader(xmlDocument));
      return loadXmlDefinition(XmlUtils.getDocumentBuilder().parse(inputSource));
    } catch (IOException | SAXException | ParserConfigurationException | XPathExpressionException
        | TransformerFactoryConfigurationError | TransformerException e) {
      throw new InternalGemFireError("Could not parse XML when creating XMLEntity", e);
    }
  }

  /**
   * Used supplied XML {@link Document} to extract the XML for the defined XmlEntity.
   *
   * @param document to extract XML from.
   * @return XML for XmlEntity if found, otherwise {@code null}.
   * @since GemFire 8.1
   */
  public String loadXmlDefinition(final Document document)
      throws XPathExpressionException, TransformerFactoryConfigurationError, TransformerException {
    this.searchString = createQueryString(prefix, type, attributes);
    logger.info("XmlEntity:searchString: {}", this.searchString);

    if (document != null) {
      XPathContext xpathContext = new XPathContext();
      xpathContext.addNamespace(prefix, namespace);

      // TODO: wrap this line with conditional
      xpathContext.addNamespace(childPrefix, childNamespace);

      // Create an XPathContext here
      Node element = XmlUtils.querySingleElement(document, this.searchString, xpathContext);

      // Must copy to preserve namespaces.
      if (null != element) {
        return XmlUtils.elementToString(element);
      }
    }

    logger.warn("No XML definition could be found with name={} and attributes={}", type,
        attributes);
    return null;
  }

  /**
   * Create an XmlPath query string from the given element name and attributes.
   *
   * @param element Name of the XML element to search for.
   * @param attributes Attributes of the element that should match, for example "name" or "id" and
   *        the value they should equal. This list may be empty.
   *
   * @return An XmlPath query string.
   */
  private String createQueryString(final String prefix, final String element,
      final Map<String, String> attributes) {
    StringBuilder queryStringBuilder = new StringBuilder();
    Iterator<Entry<String, String>> attributeIter = attributes.entrySet().iterator();
    queryStringBuilder.append("//").append(prefix).append(':').append(element);

    if (!attributes.isEmpty()) {
      queryStringBuilder.append('[');
      Entry<String, String> attrEntry = attributeIter.next();
      queryStringBuilder.append('@').append(attrEntry.getKey()).append("='")
          .append(attrEntry.getValue()).append('\'');
      while (attributeIter.hasNext()) {
        attrEntry = attributeIter.next();
        queryStringBuilder.append(" and @").append(attrEntry.getKey()).append("='")
            .append(attrEntry.getValue()).append('\'');
      }

      queryStringBuilder.append(']');
    }

    return queryStringBuilder.toString();
  }

  public String getSearchString() {
    return this.searchString;
  }

  public String getType() {
    return this.type;
  }

  public Map<String, String> getAttributes() {
    return this.attributes;
  }

  /**
   * Return the value of a single attribute.
   *
   * @param key Key of the attribute whose while will be returned.
   *
   * @return The value of the attribute.
   */
  public String getAttribute(String key) {
    return this.attributes.get(key);
  }

  /**
   * A convenience method to get a name or id attributes from the list of attributes if one of them
   * has been set. Name takes precedence.
   *
   * @return The name or id attribute or null if neither is found.
   */
  public String getNameOrId() {
    if (this.attributes.containsKey("name")) {
      return this.attributes.get("name");
    }

    return this.attributes.get("id");
  }

  public String getXmlDefinition() {
    return this.xmlDefinition;
  }

  /**
   * Gets the namespace for the element. Defaults to {@link CacheXml#GEODE_NAMESPACE} if not set.
   *
   * @return XML element namespace
   * @since GemFire 8.1
   */
  public String getNamespace() {
    return namespace;
  }

  /**
   * Gets the prefix for the element. Defaults to {@link CacheXml#PREFIX} if not set.
   *
   * @return XML element prefix
   * @since GemFire 8.1
   */
  public String getPrefix() {
    return prefix;
  }

  /**
   * Gets the prefix for the child element.
   *
   * @return XML element prefix for the child element
   */
  public String getChildPrefix() {
    return this.childPrefix;
  }

  /**
   * Gets the namespace for the child element.
   *
   * @return XML element namespace for the child element
   */
  public String getChildNamespace() {
    return this.childNamespace;
  }

  @Override
  public Version[] getSerializationVersions() {
    return new Version[] {Version.GEODE_111};
  }

  @Override
  public String toString() {
    return "XmlEntity [namespace=" + namespace + ", type=" + this.type + ", attributes="
        + this.attributes + ", xmlDefinition=" + this.xmlDefinition + ']';
  }

  @Override
  public int hashCode() {
    final int prime = 31;
    int result = 1;
    result = prime * result + ((this.attributes == null) ? 0 : this.attributes.hashCode());
    result = prime * result + ((this.type == null) ? 0 : this.type.hashCode());
    return result;
  }

  @Override
  public boolean equals(Object obj) {
    if (this == obj)
      return true;
    if (obj == null)
      return false;
    if (getClass() != obj.getClass())
      return false;
    XmlEntity other = (XmlEntity) obj;
    if (this.attributes == null) {
      if (other.attributes != null)
        return false;
    } else if (!this.attributes.equals(other.attributes))
      return false;
    if (this.namespace == null) {
      if (other.namespace != null)
        return false;
    } else if (!this.namespace.equals(other.namespace))
      return false;
    if (this.type == null) {
      if (other.type != null)
        return false;
    } else if (!this.type.equals(other.type))
      return false;
    return true;
  }

  @Override
  public void toData(DataOutput out) throws IOException {
    toDataPre_GEODE_1_1_1_0(out);
    DataSerializer.writeString(this.childPrefix, out);
    DataSerializer.writeString(this.childNamespace, out);
  }

  public void toDataPre_GEODE_1_1_1_0(DataOutput out) throws IOException {
    DataSerializer.writeString(this.type, out);
    DataSerializer.writeObject(this.attributes, out);
    DataSerializer.writeString(this.xmlDefinition, out);
    DataSerializer.writeString(this.searchString, out);
    DataSerializer.writeString(this.prefix, out);
    DataSerializer.writeString(this.namespace, out);
  }

  @Override
  public void fromData(DataInput in) throws IOException, ClassNotFoundException {
    fromDataPre_GEODE_1_1_1_0(in);
    this.childPrefix = DataSerializer.readString(in);
    this.childNamespace = DataSerializer.readString(in);
  }

  public void fromDataPre_GEODE_1_1_1_0(DataInput in) throws IOException, ClassNotFoundException {
    this.type = DataSerializer.readString(in);
    this.attributes = DataSerializer.readObject(in);
    this.xmlDefinition = DataSerializer.readString(in);
    this.searchString = DataSerializer.readString(in);
    this.prefix = DataSerializer.readString(in);
    this.namespace = DataSerializer.readString(in);
    this.cacheProvider = createDefaultCacheProvider();
  }

  /**
   * Defines how XmlEntity gets a reference to the Cache.
   */
  public interface CacheProvider {
    InternalCache getCache();
  }

  /**
   * Builder for XmlEntity. Default values are as described in XmlEntity.
   *
   * @since GemFire 8.1
   */
  public static class XmlEntityBuilder {

    private XmlEntity xmlEntity;

    /**
     * Private constructor.
     *
     * @since GemFire 8.1
     */
    XmlEntityBuilder() {
      xmlEntity = new XmlEntity();
    }

    /**
     * Produce an XmlEntity with the supplied values. Builder is reset after #build() is called.
     * Subsequent calls will produce a new XmlEntity.
     *
     * You are required to at least call {@link #withType(String)}.
     *
     * @since GemFire 8.1
     */
    public XmlEntity build() {
      xmlEntity.init();

      final XmlEntity built = xmlEntity;
      xmlEntity = new XmlEntity();

      return built;
    }

    /**
     * Sets the type or element name value as returned by {@link XmlEntity#getType()}
     *
     * @param type Name of element type.
     * @return this XmlEntityBuilder
     * @since GemFire 8.1
     */
    public XmlEntityBuilder withType(final String type) {
      xmlEntity.type = type;

      return this;
    }

    /**
     * Sets the element prefix and namespace as returned by {@link XmlEntity#getPrefix()} and
     * {@link XmlEntity#getNamespace()} respectively. Defaults are {@link CacheXml#PREFIX} and
     * {@link CacheXml#GEODE_NAMESPACE} respectively.
     *
     * @param prefix Prefix of element
     * @param namespace Namespace of element
     * @return this XmlEntityBuilder
     * @since GemFire 8.1
     */
    public XmlEntityBuilder withNamespace(final String prefix, final String namespace) {
      xmlEntity.prefix = prefix;
      xmlEntity.namespace = namespace;

      return this;
    }

    /**
     * Adds an attribute for the given <code>name</code> and <code>value</code> to the attributes
     * map returned by {@link XmlEntity#getAttributes()} or {@link XmlEntity#getAttribute(String)}.
     *
     * @param name Name of attribute to set.
     * @param value Value of attribute to set.
     * @return this XmlEntityBuilder
     * @since GemFire 8.1
     */
    public XmlEntityBuilder withAttribute(final String name, final String value) {
      xmlEntity.attributes.put(name, value);

      return this;
    }

    /**
     * Replaces all attributes with the supplied attributes {@link Map}.
     *
     * @param attributes {@link Map} to use.
     * @return this XmlEntityBuilder
     * @since GemFire 8.1
     */
    public XmlEntityBuilder withAttributes(final Map<String, String> attributes) {
      xmlEntity.attributes = attributes;

      return this;
    }

    /**
     * Sets a config xml document source to get the entity XML Definition from as returned by
     * {@link XmlEntity#getXmlDefinition()}. Defaults to current active configuration for
     * {@link Cache}.
     *
     * <b>Should only be used for testing.</b>
     *
     * @param xmlDocument Config XML document.
     * @return this XmlEntityBuilder
     * @since GemFire 8.1
     */
    public XmlEntityBuilder withConfig(final String xmlDocument) {
      xmlEntity.xmlDefinition = xmlEntity.loadXmlDefinition(xmlDocument);

      return this;
    }

    /**
     * Sets a config xml document source to get the entity XML Definition from as returned by
     * {@link XmlEntity#getXmlDefinition()}. Defaults to current active configuration for
     * {@link Cache}.
     *
     * <b>Should only be used for testing.</b>
     *
     * @param document Config XML {@link Document}.
     * @return this XmlEntityBuilder
     * @since GemFire 8.1
     */
    public XmlEntityBuilder withConfig(final Document document) throws XPathExpressionException,
        TransformerFactoryConfigurationError, TransformerException {
      xmlEntity.xmlDefinition = xmlEntity.loadXmlDefinition(document);

      return this;
    }
  }
}
