/*******************************************************************************
 * Copyright (c) 2018 SAP SE and others.
 *
 * This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License 2.0
 * which accompanies this distribution, and is available at
 * https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 *
 * Contributors:
 *     SAP SE - initial version
 *******************************************************************************/
package org.eclipse.urischeme.internal.registration;

import java.io.BufferedWriter;
import java.io.IOException;
import java.io.Reader;
import java.io.Writer;

import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathExpression;
import javax.xml.xpath.XPathExpressionException;
import javax.xml.xpath.XPathFactory;

import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.w3c.dom.Text;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;

/**
 * Used to change the CFBundleURLTypes property of a Mac .plist file. Adds
 * handler entries for uri schemes in "CFBundleURLSchemes" elements. Can also
 * remove schemes.
 */
public class PlistFileWriter {

	private static final String XPATH_PLIST_DICT_CF_BUNDLE_URL_TYPES_KEY = "/plist/dict/key[text()=\"CFBundleURLTypes\"]"; //$NON-NLS-1$
	private static final String XPATH_PLIST_DICT_CF_BUNDLE_URL_TYPES_ARRAY = XPATH_PLIST_DICT_CF_BUNDLE_URL_TYPES_KEY
			+ "/following-sibling::array"; //$NON-NLS-1$
	private static final String ELEMENT_NAME_KEY = "key"; //$NON-NLS-1$
	private static final String ELEMENT_NAME_ARRAY = "array"; //$NON-NLS-1$
	private static final String ELEMENT_NAME_STRING = "string"; //$NON-NLS-1$
	private static final String ELEMENT_NAME_DICT = "dict"; //$NON-NLS-1$
	private static final String KEY_VALUE_CF_BUNDLE_URL_TYPES = "CFBundleURLTypes"; //$NON-NLS-1$
	private static final String KEY_VALUE_CF_BUNDLE_URL_NAME = "CFBundleURLName"; //$NON-NLS-1$
	private static final String KEY_VALUE_CF_BUNDLE_URL_SCHEMES = "CFBundleURLSchemes"; //$NON-NLS-1$
	private Document document;
	private Element array;

	/**
	 * Creates an instance of the PlistFileWriter. Throws an
	 * {@link IllegalStateException} if the given {@link Reader} does not provide
	 * .plist file.
	 *
	 * @param reader The file reader of the .plist file
	 *
	 * @throws IllegalArgumentException if file cannot be understood as .plist file
	 */
	public PlistFileWriter(Reader reader) {
		this.document = getDom(reader);
		this.array = getOrCreateBundleUrlTypesAndArray();
	}

	/**
	 * Checks if the given scheme is registered in this .plist file
	 *
	 * @param scheme that should be checked for registration
	 * @return <code>true</code> if scheme registered; <code>false</code> otherwise
	 */
	public boolean isRegisteredScheme(String scheme) {
		Util.assertUriSchemeIsLegal(scheme);

		return getExistingElementFor(scheme) != null;
	}

	/**
	 * Adds an entry for the given scheme in the CFBundleURLSchemes element of the
	 * .plist file. Creates the CFBundleURLTypes element if not yet existing.
	 * Otherwise adds CFBundleURLSchemes element.
	 *
	 * @param scheme            The uri scheme which should be handled by the
	 *                          application of the .plist file
	 * @param schemeDescription The human readable description of the scheme
	 *
	 * @throws IllegalArgumentException if the given scheme contains illegal
	 *                                  characters
	 *
	 * @see #removeScheme(String)
	 *
	 * @see <a href= "https://tools.ietf.org/html/rfc3986#section-3.1">Uniform
	 *      Resource Identifier (URI): Generic Syntax</a>
	 *
	 */
	public void addScheme(String scheme, String schemeDescription) {
		// check precondition
		Util.assertUriSchemeIsLegal(scheme);

		if (getExistingElementFor(scheme) != null) {
			return;
		}

		// add dict element
		addIndent(array, 3);
		Element dictInArray = addChildNode(array, ELEMENT_NAME_DICT, null);

		// add key CFBundleURLName
		addIndent(dictInArray, 4);
		addChildNode(dictInArray, ELEMENT_NAME_KEY, KEY_VALUE_CF_BUNDLE_URL_NAME);

		// add string
		addIndent(dictInArray, 5);
		addChildNode(dictInArray, ELEMENT_NAME_STRING, schemeDescription);

		// add key CFBundleURLSchemes
		addIndent(dictInArray, 4);
		addChildNode(dictInArray, ELEMENT_NAME_KEY, KEY_VALUE_CF_BUNDLE_URL_SCHEMES);

		// add array
		addIndent(dictInArray, 5);
		Element schemeArray = addChildNode(dictInArray, ELEMENT_NAME_ARRAY, null);

		// add string
		addIndent(schemeArray, 6);
		addChildNode(schemeArray, ELEMENT_NAME_STRING, scheme);

		// indent closing tags
		addIndent(schemeArray, 5);
		addIndent(dictInArray, 3);
	}

	/**
	 * Removes the corresponding CFBundleURLSchemes element for the given scheme
	 * from the CFBundleURLTypes element of the .plist file. Removes the
	 * CFBundleURLTypes element completely if it is empty (no handled schemes) after
	 * removal.
	 *
	 * @param scheme The uri scheme which should not be handled anymore by the
	 *               application of the .plist file.
	 *
	 * @throws IllegalArgumentException if the given scheme contains illegal
	 *                                  characters
	 *
	 * @see #addScheme(String, String)
	 *
	 * @see <a href=
	 *      "https://tools.ietf.org/html/rfc3986#section-3.1">https://tools.ietf.org/html/rfc3986#section-3.1</a>
	 *
	 */
	public void removeScheme(String scheme) {
		Util.assertUriSchemeIsLegal(scheme);

		Element dict = getExistingElementFor(scheme);
		if (dict == null) {
			// not found, no need to remove
			return;
		}

		Node arrayNode = dict.getParentNode();
		removeTextNode(arrayNode, dict.getPreviousSibling()); // remove tab and line break before dict
		arrayNode.removeChild(dict);
	}

	/**
	 * Writes the content (xml) of the .plist file to the given {@link Writer}
	 *
	 * @param writer The Writer to which the xml should be written to, e.g.
	 *               {@link BufferedWriter}
	 *
	 */
	public void writeTo(Writer writer) {
		boolean hasDict = false;
		for (int i = 0; i < array.getChildNodes().getLength(); i++) {
			Node child = array.getChildNodes().item(i);
			if ("dict".equals(child.getNodeName())) { //$NON-NLS-1$
				hasDict = true;
				break;
			}
		}
		// check if no schemes in cfbundlurltypes
		if (!hasDict) {
			Node keyNode = evaluateXpathOnElement(document, XPATH_PLIST_DICT_CF_BUNDLE_URL_TYPES_KEY);
			if (keyNode != null) {
				keyNode.getParentNode().removeChild(keyNode);
				array.getParentNode().removeChild(array);
			}
		} else {
			// indent last closing tag
			addIndent(array, 2);
		}

		transformDocument(writer);
	}

	private void transformDocument(Writer writer) {
		try (writer) {
			DOMSource source = new DOMSource(this.document);
			@SuppressWarnings("restriction")
			TransformerFactory f = org.eclipse.core.internal.runtime.XmlProcessorFactory
			.createTransformerFactoryWithErrorOnDOCTYPE();
			f.newTransformer().transform(source, new StreamResult(writer));
		} catch (TransformerException | IOException e) {
			throw new IllegalStateException(e);
		}
	}

	@SuppressWarnings("restriction")
	private Document getDom(Reader reader) {
		try (reader) {
			return org.eclipse.core.internal.runtime.XmlProcessorFactory
					.parseWithErrorOnDOCTYPE(new InputSource(reader));
		} catch (ParserConfigurationException | IOException | SAXException e) {
			throw new IllegalArgumentException(e);
		}
	}

	private Element getOrCreateBundleUrlTypesAndArray() {
		Element arrayNode = evaluateXpathOnElement(this.document, XPATH_PLIST_DICT_CF_BUNDLE_URL_TYPES_ARRAY);
		if (arrayNode != null) {
			// adapt whitespace of array
			if (removeTextNode(arrayNode, arrayNode.getLastChild())) {
				addLineBreak(arrayNode);
			}
		} else {
			// create CFBundleURLTypes and it's array
			Element plistElement = document.getDocumentElement();
			NodeList dictElements = plistElement.getElementsByTagName(ELEMENT_NAME_DICT);
			if (dictElements.getLength() == 0) {
				throw new IllegalStateException("Top level 'DICT' element could not be found"); //$NON-NLS-1$
			}
			Node dictElement = dictElements.item(0);
			addIndent(dictElement, 1);
			addChildNode(dictElement, ELEMENT_NAME_KEY, KEY_VALUE_CF_BUNDLE_URL_TYPES);

			addIndent(dictElement, 2);
			arrayNode = addChildNode(dictElement, ELEMENT_NAME_ARRAY, null);
		}
		return arrayNode;
	}

	private boolean removeTextNode(Node parent, Node textNode) {
		if (textNode instanceof Text) {
			parent.removeChild(textNode);
			return true;
		}
		return false;
	}

	private Element addChildNode(Node parent, String name, String value) {
		Element newElement = document.createElement(name);
		if (value != null) {
			newElement.appendChild(document.createTextNode(value));
		} else {
			addLineBreak(newElement);
		}
		parent.appendChild(newElement);
		addLineBreak(parent);
		return newElement;
	}

	private void addLineBreak(Node node) {
		node.appendChild(document.createTextNode("\n")); //$NON-NLS-1$
	}

	private void addIndent(Node node, int indent) {
		StringBuilder builder = new StringBuilder();
		for (int i = 0; i < indent; i++) {
			builder.append("	"); //$NON-NLS-1$
		}
		node.appendChild(document.createTextNode(builder.toString()));
	}

	private Element getExistingElementFor(String scheme) {
		String xpathToSchemeDictElement = XPATH_PLIST_DICT_CF_BUNDLE_URL_TYPES_ARRAY
				+ "/dict/key[text()=\"CFBundleURLSchemes\"]/following-sibling::array/string[text()=\"" + scheme //$NON-NLS-1$
				+ "\"]/../.."; //$NON-NLS-1$

		return evaluateXpathOnElement(this.document, xpathToSchemeDictElement);
	}

	private Element evaluateXpathOnElement(Node node, String xpath) {
		try {
			XPathExpression xpathExpression = XPathFactory.newInstance().newXPath().compile(xpath);
			NodeList nodeList = (NodeList) xpathExpression.evaluate(node, XPathConstants.NODESET);
			return nodeList.getLength() == 0 ? null : (Element) nodeList.item(0);
		} catch (XPathExpressionException e) {
			throw new IllegalStateException(e); // cannot happen
		}
	}
}