/*******************************************************************************
 * Copyright (c) 2006, 2011 IBM Corporation and others.
 * All rights reserved. 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:
 *     IBM Corporation - initial API and implementation
 *******************************************************************************/
package org.eclipse.wst.xml.ui.internal.hyperlink;

import java.io.File;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;

import org.eclipse.core.resources.IFile;
import org.eclipse.core.resources.ResourcesPlugin;
import org.eclipse.core.runtime.IPath;
import org.eclipse.core.runtime.Path;
import org.eclipse.jface.text.BadLocationException;
import org.eclipse.jface.text.IDocument;
import org.eclipse.jface.text.IRegion;
import org.eclipse.jface.text.ITextViewer;
import org.eclipse.jface.text.Region;
import org.eclipse.jface.text.hyperlink.AbstractHyperlinkDetector;
import org.eclipse.jface.text.hyperlink.IHyperlink;
import org.eclipse.wst.common.uriresolver.internal.provisional.URIResolverPlugin;
import org.eclipse.wst.sse.core.StructuredModelManager;
import org.eclipse.wst.sse.core.internal.provisional.IStructuredModel;
import org.eclipse.wst.sse.core.internal.provisional.IndexedRegion;
import org.eclipse.wst.sse.core.internal.provisional.text.ITextRegion;
import org.eclipse.wst.sse.core.utils.StringUtils;
import org.eclipse.wst.xml.core.internal.XMLCorePlugin;
import org.eclipse.wst.xml.core.internal.catalog.provisional.ICatalog;
import org.eclipse.wst.xml.core.internal.catalog.provisional.ICatalogEntry;
import org.eclipse.wst.xml.core.internal.catalog.provisional.INextCatalog;
import org.eclipse.wst.xml.core.internal.contentmodel.CMAttributeDeclaration;
import org.eclipse.wst.xml.core.internal.contentmodel.CMDataType;
import org.eclipse.wst.xml.core.internal.contentmodel.CMElementDeclaration;
import org.eclipse.wst.xml.core.internal.contentmodel.CMNamedNodeMap;
import org.eclipse.wst.xml.core.internal.contentmodel.CMNode;
import org.eclipse.wst.xml.core.internal.contentmodel.basic.CMNamedNodeMapImpl;
import org.eclipse.wst.xml.core.internal.contentmodel.modelquery.ModelQuery;
import org.eclipse.wst.xml.core.internal.contentmodel.util.DOMNamespaceHelper;
import org.eclipse.wst.xml.core.internal.modelquery.ModelQueryUtil;
import org.eclipse.wst.xml.core.internal.provisional.document.IDOMAttr;
import org.eclipse.wst.xml.core.internal.provisional.document.IDOMNode;
import org.eclipse.wst.xml.ui.internal.Logger;
import org.w3c.dom.Attr;
import org.w3c.dom.DocumentType;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;

import com.ibm.icu.util.StringTokenizer;

/**
 * Detects hyperlinks in XML tags. Includes detection in DOCTYPE and attribute
 * values. Resolves references to schemas, dtds, etc using the Common URI
 * Resolver.
 * 
 */
public class XMLHyperlinkDetector extends AbstractHyperlinkDetector {
	private final String NO_NAMESPACE_SCHEMA_LOCATION = "noNamespaceSchemaLocation"; //$NON-NLS-1$
	private final String SCHEMA_LOCATION = "schemaLocation"; //$NON-NLS-1$
	private final String XMLNS = "xmlns"; //$NON-NLS-1$
	private final String XSI_NAMESPACE_URI = "http://www.w3.org/2001/XMLSchema-instance"; //$NON-NLS-1$

	/**
	 * Create the appropriate hyperlink
	 * 
	 * @param uriString
	 * @param hyperlinkRegion
	 * @return IHyperlink
	 */
	private IHyperlink createHyperlink(String uriString, IRegion hyperlinkRegion, IDocument document, Node node) {
		IHyperlink link = null;

		ICatalogEntry entry = getCatalogEntry(uriString);
		if (entry != null) {
			link = new CatalogEntryHyperlink(hyperlinkRegion, entry);
		}
		else {
			// try to locate the file in the workspace
			File systemFile = getFileFromUriString(uriString);
			if (systemFile != null) {
				String systemPath = systemFile.getPath();
				IFile file = getFile(systemPath);
				if (file != null) {
					// this is a WorkspaceFileHyperlink since file exists in
					// workspace
					link = new WorkspaceFileHyperlink(hyperlinkRegion, file);
				}
				else {
					// this is an ExternalFileHyperlink since file does not
					// exist in workspace
					link = new ExternalFileHyperlink(hyperlinkRegion, systemFile);
				}
			}
		}
		return link;
	}

	public IHyperlink[] detectHyperlinks(ITextViewer textViewer, IRegion region, boolean canShowMultipleHyperlinks) {
		// for now, only capable of creating 1 hyperlink
		List hyperlinks = new ArrayList(0);

		if ((region != null) && (textViewer != null)) {
			IDocument document = textViewer.getDocument();
			Node currentNode = getCurrentNode(document, region.getOffset());
			if (currentNode != null) {
				String uriString = null;
				if (currentNode.getNodeType() == Node.DOCUMENT_TYPE_NODE) {
					// doctype nodes
					uriString = getURIString(currentNode, document, region);
				}
				else if (currentNode.getNodeType() == Node.ELEMENT_NODE) {
					// element nodes
					Attr currentAttr = getCurrentAttrNode(currentNode, region.getOffset());
					if (currentAttr != null) {
						// try to find link for current attribute
						// resolve attribute value
						uriString = getURIString(currentAttr, document, region);
						// verify validity of uri string
						if ((uriString == null) || !isValidURI(uriString)) {
							// reset current attribute
							currentAttr = null;
						}
					}
					if (currentAttr == null) {
						// try to find a linkable attribute within element
						currentAttr = getLinkableAttr((Element) currentNode);
						if (currentAttr != null) {
							uriString = getURIString(currentAttr, document, region);
						}
					}
					currentNode = currentAttr;
				}
				// try to create hyperlink from information gathered
				if ((uriString != null) && (currentNode != null) && isValidURI(uriString)) {
					IRegion hyperlinkRegion = getHyperlinkRegion(currentNode);
					IHyperlink hyperlink = createHyperlink(uriString, hyperlinkRegion, document, currentNode);
					if (hyperlink != null) {
						hyperlinks.add(hyperlink);
					}
				}
			}
		}
		if (hyperlinks.size() == 0) {
			return null;
		}
		return (IHyperlink[]) hyperlinks.toArray(new IHyperlink[0]);
	}

	/**
	 * Get the base location from the current model (local file system)
	 */
	private String getBaseLocation(IDocument document) {
		String result = null;

		// get the base location from the current model
		IStructuredModel sModel = null;
		try {
			sModel = StructuredModelManager.getModelManager().getExistingModelForRead(document);
			if (sModel != null) {
				result = sModel.getBaseLocation();
				
				IPath path = new Path(result);
				if (path.segmentCount() > 1) {
					IFile file = ResourcesPlugin.getWorkspace().getRoot().getFile(path);
					if (file.exists()) {
						String baseLocation = null;
						if (file.getLocation() != null) {
							baseLocation = file.getLocation().toString();
						}
						if (baseLocation == null && file.getLocationURI() != null) {
							baseLocation = file.getLocationURI().toString();
						}
						if (baseLocation == null) {
							baseLocation = file.getFullPath().toString();
						}
						result = baseLocation;
					}
				}
			}
		}
		finally {
			if (sModel != null) {
				sModel.releaseFromRead();
			}
		}
		return result;
	}

	/**
	 * Get the CMElementDeclaration for an element
	 * 
	 * @param element
	 * @return CMElementDeclaration
	 */
	private CMElementDeclaration getCMElementDeclaration(Element element) {
		CMElementDeclaration ed = null;

		ModelQuery mq = ModelQueryUtil.getModelQuery(element.getOwnerDocument());
		if (mq != null) {
			ed = mq.getCMElementDeclaration(element);
		}
		return ed;
	}

	/**
	 * Returns the attribute node within node at offset
	 * 
	 * @param node
	 * @param offset
	 * @return Attr
	 */
	private Attr getCurrentAttrNode(Node node, int offset) {
		if ((node instanceof IndexedRegion) && ((IndexedRegion) node).contains(offset) && (node.hasAttributes())) {
			NamedNodeMap attrs = node.getAttributes();
			// go through each attribute in node and if attribute contains
			// offset, return that attribute
			for (int i = 0; i < attrs.getLength(); ++i) {
				// assumption that if parent node is of type IndexedRegion,
				// then its attributes will also be of type IndexedRegion
				IndexedRegion attRegion = (IndexedRegion) attrs.item(i);
				if (attRegion.contains(offset)) {
					return (Attr) attrs.item(i);
				}
			}
		}
		return null;
	}

	/**
	 * Returns the node the cursor is currently on in the document. null if no
	 * node is selected
	 * 
	 * @param offset
	 * @return Node either element, doctype, text, or null
	 */
	private Node getCurrentNode(IDocument document, int offset) {
		// get the current node at the offset (returns either: element,
		// doctype, text)
		IndexedRegion inode = null;
		IStructuredModel sModel = null;
		try {
			sModel = StructuredModelManager.getModelManager().getExistingModelForRead(document);
			if (sModel != null) {
				inode = sModel.getIndexedRegion(offset);
				if (inode == null) {
					inode = sModel.getIndexedRegion(offset - 1);
				}
			}
		}
		finally {
			if (sModel != null) {
				sModel.releaseFromRead();
			}
		}

		if (inode instanceof Node) {
			return (Node) inode;
		}
		return null;
	}

	/**
	 * Returns an IFile from the given uri if possible, null if cannot find
	 * file from uri.
	 * 
	 * @param fileString
	 *            file system path
	 * @return returns IFile if fileString exists in the workspace
	 */
	private IFile getFile(String fileString) {
		IFile file = null;

		if (fileString != null) {
			Path filePath = new Path(fileString);
			if (filePath.segmentCount() > 1 && ResourcesPlugin.getWorkspace().getRoot().getFile(filePath).exists()) {
				return ResourcesPlugin.getWorkspace().getRoot().getFile(filePath);
			}
			IFile[] files = ResourcesPlugin.getWorkspace().getRoot().findFilesForLocation(filePath);
			for (int i = 0; (i < files.length) && (file == null); i++) {
				if (files[i].exists()) {
					file = files[i];
				}
			}
		}

		return file;
	}

	/**
	 * Create a file from the given uri string
	 * 
	 * @param uriString -
	 *            assumes uriString is not http://
	 * @return File created from uriString if possible, null otherwise
	 */
	private File getFileFromUriString(String uriString) {
		File file = null;
		try {
			// first just try to create a file directly from uriString as
			// default in case create file from uri does not work
			file = new File(uriString);

			// try to create file from uri
			URI uri = new URI(uriString);
			file = new File(uri);
		}
		catch (Exception e) {
			// if exception is thrown while trying to create File just ignore
			// and file will be null
		}
		return file;
	}

	private IRegion getHyperlinkRegion(Node node) {
		IRegion hyperRegion = null;

		if (node != null) {
			short nodeType = node.getNodeType();
			if (nodeType == Node.DOCUMENT_TYPE_NODE) {
				// handle doc type node
				IDOMNode docNode = (IDOMNode) node;
				hyperRegion = new Region(docNode.getStartOffset(), docNode.getEndOffset() - docNode.getStartOffset());
			}
			else if (nodeType == Node.ATTRIBUTE_NODE) {
				// handle attribute nodes
				IDOMAttr att = (IDOMAttr) node;
				// do not include quotes in attribute value region
				int regOffset = att.getValueRegionStartOffset();
				ITextRegion valueRegion = att.getValueRegion();
				if (valueRegion != null) {
					int regLength = valueRegion.getTextLength();
					String attValue = att.getValueRegionText();
					if (StringUtils.isQuoted(attValue)) {
						++regOffset;
						regLength = regLength - 2;
					}
					hyperRegion = new Region(regOffset, regLength);
				}
			}
		}
		return hyperRegion;
	}

	/**
	 * Attempts to find an attribute within element that is openable.
	 * 
	 * @param element -
	 *            cannot be null
	 * @return Attr attribute that can be used for open on, null if no
	 *         attribute could be found
	 */
	private Attr getLinkableAttr(Element element) {
		CMElementDeclaration ed = getCMElementDeclaration(element);
		// get the list of attributes for this node
		NamedNodeMap attrs = element.getAttributes();
		for (int i = 0; i < attrs.getLength(); ++i) {
			// check if this attribute is "openOn-able"
			Attr att = (Attr) attrs.item(i);
			if (isLinkableAttr(att, ed)) {
				return att;
			}
		}
		return null;
	}

	/**
	 * Find the location hint for the given namespaceURI if it exists
	 * 
	 * @param elementNode -
	 *            cannot be null
	 * @param namespaceURI -
	 *            cannot be null
	 * @return location hint (systemId) if it was found, null otherwise
	 */
	private String getLocationHint(Element elementNode, String namespaceURI) {
		Attr schemaLocNode = elementNode.getAttributeNodeNS(XSI_NAMESPACE_URI, SCHEMA_LOCATION);
		if (schemaLocNode != null) {
			StringTokenizer st = new StringTokenizer(schemaLocNode.getValue());
			while (st.hasMoreTokens()) {
				String publicId = st.hasMoreTokens() ? st.nextToken() : null;
				String systemId = st.hasMoreTokens() ? st.nextToken() : null;
				// found location hint
				if (namespaceURI.equalsIgnoreCase(publicId)) {
					return systemId;
				}
			}
		}
		return null;
	}

	/**
	 * Returns the URI string
	 * 
	 * @param node -
	 *            assumes not null
	 */
	private String getURIString(Node node, IDocument document, IRegion region) {
		String resolvedURI = null;
		// need the base location, publicId, and systemId for URIResolver
		String baseLoc = null;
		String publicId = null;
		String systemId = null;

		short nodeType = node.getNodeType();
		// handle doc type node
		if (nodeType == Node.DOCUMENT_TYPE_NODE) {
			baseLoc = getBaseLocation(document);
			publicId = ((DocumentType) node).getPublicId();
			systemId = ((DocumentType) node).getSystemId();
		}
		else if (nodeType == Node.ATTRIBUTE_NODE) {
			// handle attribute node
			Attr attrNode = (Attr) node;
			String attrName = attrNode.getName();
			String attrValue = attrNode.getValue();
			attrValue = StringUtils.strip(attrValue);
			if ((attrValue != null) && (attrValue.length() > 0)) {
				baseLoc = getBaseLocation(document);

				// handle schemaLocation attribute
				String prefix = DOMNamespaceHelper.getPrefix(attrName);
				String unprefixedName = DOMNamespaceHelper.getUnprefixedName(attrName);
				if ((XMLNS.equals(prefix)) || (XMLNS.equals(unprefixedName))) {
					publicId = attrValue;
					systemId = getLocationHint(attrNode.getOwnerElement(), publicId);
					if (systemId == null) {
						systemId = attrValue;
					}
				}
				else if ((XSI_NAMESPACE_URI.equals(DOMNamespaceHelper.getNamespaceURI(attrNode))) && (SCHEMA_LOCATION.equals(unprefixedName))) {
					// for now just use the first pair
					// need to look into being more precise
                    
					//Being precise now
					String attrText = ""; //$NON-NLS-1$
					int relativeOffset = -1;
					if (node instanceof IDOMNode) {
						relativeOffset = region.getOffset() - ((IDOMNode)node).getStartOffset();
						try {
							attrText = document.get(((IDOMNode)node).getStartOffset(), ((IDOMNode)node).getLength());
						} catch (BadLocationException e) {
							Logger.logException(e);
						}
					}					
					StringTokenizer st = new StringTokenizer(attrValue);
					while (st.hasMoreTokens()) {
						publicId = st.nextToken();
						systemId = st.hasMoreTokens() ? st.nextToken() : null;
						int startOffset = -1;
						int endOffset = -1;
						if (publicId != null) {
							startOffset = attrText.indexOf(publicId);
							if (systemId != null) {
								endOffset = attrText.indexOf(systemId) + systemId.length();
							}
							else {
								endOffset = attrText.indexOf(publicId) + publicId.length();
							}
						}
						if (startOffset <= relativeOffset && relativeOffset <= endOffset)
							break;
					// else check if xmlns publicId = value
					}
				}
				else {
					systemId = attrValue;
				}
			}
		}

		resolvedURI = resolveURI(baseLoc, publicId, systemId);
		return resolvedURI;
	}

	/**
	 * Checks to see if the given attribute is openable. Attribute is openable
	 * if it is a namespace declaration attribute or if the attribute value is
	 * of type URI.
	 * 
	 * @param attr
	 *            cannot be null
	 * @param cmElement
	 *            CMElementDeclaration associated with the attribute (can be
	 *            null)
	 * @return true if this attribute is "openOn-able" false otherwise
	 */
	private boolean isLinkableAttr(Attr attr, CMElementDeclaration cmElement) {
		String attrName = attr.getName();
		String prefix = DOMNamespaceHelper.getPrefix(attrName);
		String unprefixedName = DOMNamespaceHelper.getUnprefixedName(attrName);
		// determine if attribute is namespace declaration
		if ((XMLNS.equals(prefix)) || (XMLNS.equals(unprefixedName))) {
			return true;
		}

		// determine if attribute contains schema location
		if ((XSI_NAMESPACE_URI.equals(DOMNamespaceHelper.getNamespaceURI(attr))) && ((SCHEMA_LOCATION.equals(unprefixedName)) || (NO_NAMESPACE_SCHEMA_LOCATION.equals(unprefixedName)))) {
			return true;
		}

		// determine if attribute value is of type URI
		if (cmElement != null) {
			CMNamedNodeMap attrDecls = cmElement.getAttributes();
			CMNamedNodeMapImpl allAttributes = new CMNamedNodeMapImpl(attrDecls);
			List nodes = ModelQueryUtil.getModelQuery(attr.getOwnerDocument()).getAvailableContent(attr.getOwnerElement(), cmElement, ModelQuery.INCLUDE_ATTRIBUTES);
			for (int k = 0; k < nodes.size(); k++) {
				CMNode cmnode = (CMNode) nodes.get(k);
				if (cmnode.getNodeType() == CMNode.ATTRIBUTE_DECLARATION) {
					allAttributes.put(cmnode);
				}
			}
			attrDecls = allAttributes;

			CMAttributeDeclaration attrDecl = (CMAttributeDeclaration) attrDecls.getNamedItem(attrName);
			if ((attrDecl != null) && (attrDecl.getAttrType() != null) && (CMDataType.URI.equals(attrDecl.getAttrType().getDataTypeName()))) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Checks whether the given uriString is really pointing to a file
	 * 
	 * @param uriString
	 * @return boolean
	 */
	private boolean isValidURI(String uriString) {
		boolean isValid = false;

		if (getCatalogEntry(uriString) != null) {
			isValid = true;
		}
		else {
			File file = getFileFromUriString(uriString);
			if (file != null) {
				isValid = file.isFile();
			}
			if(!isValid) {
			}
			
		}
		return isValid;
	}

	/**
	 * @param uriString
	 * @return
	 */
	private ICatalogEntry getCatalogEntry(String uriString) {
		ICatalog defaultCatalog = XMLCorePlugin.getDefault().getDefaultXMLCatalog();
		if (defaultCatalog != null) {
			// Process default catalog
			ICatalogEntry[] entries = defaultCatalog.getCatalogEntries();
			for (int entry = 0; entry < entries.length; entry++) {
				if (uriString.equals(entries[entry].getKey())||uriString.equals(entries[entry].getURI())) {
					return entries[entry];
				}
			}

			// Process declared OASIS nextCatalogs catalog
			INextCatalog[] nextCatalogs = defaultCatalog.getNextCatalogs();
			for (int nextCatalog = 0; nextCatalog < nextCatalogs.length; nextCatalog++) {
				ICatalog catalog = nextCatalogs[nextCatalog].getReferencedCatalog();
				ICatalogEntry[] entries2 = catalog.getCatalogEntries();
				for (int entry = 0; entry < entries2.length; entry++) {
					if (uriString.equals(entries2[entry].getKey())||uriString.equals(entries2[entry].getURI()))
						return entries2[entry];
				}
			}
		}
		return null;
	}

	/**
	 * Resolves the given URI information
	 * 
	 * @param baseLocation
	 * @param publicId
	 * @param systemId
	 * @return String resolved uri.
	 */
	private String resolveURI(String baseLocation, String publicId, String systemId) {
		// dont resolve if there's nothing to resolve
		if ((baseLocation == null) && (publicId == null) && (systemId == null)) {
			return null;
		}
		return URIResolverPlugin.createResolver().resolve(baseLocation, publicId, systemId);
	}
}
