package freenet.support;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;

public class HTMLNode implements XMLCharacterClasses, Cloneable {
	
	private static final Pattern namePattern = Pattern.compile("^[" + NAME + "]*$");
	private static final Pattern simpleNamePattern = Pattern.compile("^[A-Za-z][A-Za-z0-9]*$");
	public static HTMLNode STRONG = new HTMLNode("strong").setReadOnly();

	protected final String name;
	
	private boolean readOnly;
	
	public HTMLNode setReadOnly() {
		readOnly = true;
		return this;
	}

	/** Text to be inserted between tags, or possibly raw HTML. Only non-null if name
	 * is "#" (= text) or "%" (= raw HTML). Otherwise the constructor will allocate a
	 * separate child node to contain it. */
	private String content;

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

	protected final List<HTMLNode> children = new ArrayList<HTMLNode>();

	public HTMLNode(String name) {
		this(name, null);
	}

	private static final ArrayList<String> EmptyTag = new ArrayList<String>(10);
	private static final ArrayList<String> OpenTags = new ArrayList<String>(12);
	private static final ArrayList<String> CloseTags = new ArrayList<String>(12);

	static {
		/* HTML elements which are allowed to be empty */
		EmptyTag.add("area");
		EmptyTag.add("base");
		EmptyTag.add("br");
		EmptyTag.add("col");
		EmptyTag.add("hr");
		EmptyTag.add("img");
		EmptyTag.add("input");
		EmptyTag.add("link");
		EmptyTag.add("meta");
		EmptyTag.add("param");
		/* HTML elements for which we should add a newline following the open tag. */
		OpenTags.add("body");
		OpenTags.add("div");
		OpenTags.add("form");
		OpenTags.add("head");
		OpenTags.add("html");
		OpenTags.add("input");
		OpenTags.add("ol");
		OpenTags.add("script");
		OpenTags.add("table");
		OpenTags.add("td");
		OpenTags.add("tr");
		OpenTags.add("ul");
		/* HTML elements for which we should add a newline following the close tag. */
		CloseTags.add("h1");
		CloseTags.add("h2");
		CloseTags.add("h3");
		CloseTags.add("h4");
		CloseTags.add("h5");
		CloseTags.add("h6");
		CloseTags.add("li");
		CloseTags.add("link");
		CloseTags.add("meta");
		CloseTags.add("noscript");
		CloseTags.add("option");
		CloseTags.add("title");
	}

	/** Tests an HTML element name to determine if it is one of the elements permitted
	 * to be empty in the XHTML spec ( http://www.w3.org/TR/xhtml1/ )
	 * @param name The name of the html element
	 * @return True if the element is allowed to be empty
	 */
	private Boolean isEmptyElement(String name) {
		return EmptyTag.contains(name);
	}

	/** Tests an HTML element to determine if we should add a newline after the opening tag
	 * for readability
	 * @param name The name of the html element
	 * @return True if we should add a newline after the opening tag
	 */
	Boolean newlineOpen(String name) {
		return OpenTags.contains(name);
	}

	/** Tests an HTML element to determine if we should add a newline after the closing tag
	* for readability. All tags with newlines after the opening tag also get newlines after
	* the closing tag.
	* @param name The name of the html element
	* @return True if we should add a newline after the opening tag
	*/
	private Boolean newlineClose(String name) {
		return (newlineOpen(name) || CloseTags.contains(name));
	}

	/** Returns a properly formatted closing angle bracket to complete an open tag of a
	 * named html element
	 * @param name the name of the element
	 * @return the proper string of characters to complete the open tag
	 */
	private String OpenSuffix(String name) {
		if (isEmptyElement(name)) {
			return " />";
		} else {
			return ">";
		}
	}

	/** Returns a closing tag for a named html elemen
	 * @param name the name of the element
	 * @return the complete closing tag for the element
	 */
	private String CloseTag(String name) {
		if (isEmptyElement(name)) {
			return "";
		} else {
			return "</" + name + ">";
		}
	}

	private String indentString(int indentDepth) {
		StringBuffer indentLine = new StringBuffer();

		for (int indentIndex = 0, indentCount = indentDepth+1; indentIndex < indentCount; indentIndex++) {
			indentLine.append('\t');
		}
		return indentLine.toString();
	}

	public HTMLNode(String name, String content) {
		this(name, (String[]) null, (String[]) null, content);
	}

	public HTMLNode(String name, String attributeName, String attributeValue) {
		this(name, attributeName, attributeValue, null);
	}

	public HTMLNode(String name, String attributeName, String attributeValue, String content) {
		this(name, new String[] { attributeName }, new String[] { attributeValue }, content);
	}

	public HTMLNode(String name, String[] attributeNames, String[] attributeValues) {
		this(name, attributeNames, attributeValues, null);
	}

	protected HTMLNode(HTMLNode node, boolean clearReadOnly) {
		attributes.putAll(node.attributes);
		children.addAll(node.children);
		content = node.content;
		name = node.name;
		if(clearReadOnly)
			readOnly = false;
		else
			readOnly = node.readOnly;
	}
	
	@Override
	public HTMLNode clone() {
		// Implement Cloneable to shut up findbugs. We need a deep copy.
		// FIXME is clearing read only an abuse of the clone() API? Should we rename the method?
		return new HTMLNode(this, true);
	}
	
	protected boolean checkNamePattern(String str) {
		// Workaround buggy java regexes, also probably slightly faster.
		if(str.isEmpty()) return false;
		char c;
		c = str.charAt(0);
		if((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')) {
			boolean simpleMatch = true;
			for(int i=1;i<str.length();i++) {
				c = str.charAt(i);
				if(!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9'))) {
					simpleMatch = false;
					break;
				}
			}
			if(simpleMatch) return true;
		}
		// Regex-based match. Probably more expensive, and problems (infinite recursion in Pattern$6.isSatisfiedBy) have been seen in practice.
		// Oddly these problems were seen where the answer is almost certainly in the first matcher, because the tag name was "html"...
		return simpleNamePattern.matcher(str).matches() || 
			namePattern.matcher(str).matches();
	}
	
	public HTMLNode(String name, String[] attributeNames, String[] attributeValues, String content) {
		if ((name == null) || (!"#".equals(name) && !"%".equals(name) && !checkNamePattern(name))) {
			throw new IllegalArgumentException("element name is not legal");
		}
		if ((attributeNames != null) && (attributeValues != null)) {
			if (attributeNames.length != attributeValues.length) {
				throw new IllegalArgumentException("attribute names and values differ in length");
			}
			for (int attributeIndex = 0, attributeCount = attributeNames.length; attributeIndex < attributeCount; attributeIndex++) {
				if ((attributeNames[attributeIndex] == null) || !checkNamePattern(attributeNames[attributeIndex])) {
					throw new IllegalArgumentException("attributeName is not legal");
				}
				addAttribute(attributeNames[attributeIndex], attributeValues[attributeIndex]);
			}
		}
		this.name = name.toLowerCase(Locale.ENGLISH);
		if (content != null && !("#").equals(name)&& !("%").equals(name)) {
			addChild(new HTMLNode("#", content));
			this.content = null;
		} else
			this.content = content;
	}

	/**
	 * @return the content
	 */
	public String getContent() {
		return content;
	}

	public void addAttribute(String attributeName, String attributeValue) {
		if(readOnly)
			throw new IllegalArgumentException("Read only");
		if (attributeName == null)
			throw new IllegalArgumentException("Cannot add an attribute with a null name");
		if (attributeValue == null)
			throw new IllegalArgumentException("Cannot add an attribute with a null value");
		attributes.put(attributeName, attributeValue);
	}

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

	public String getAttribute(String attributeName) {
		return attributes.get(attributeName);
	}

	public HTMLNode addChild(HTMLNode childNode) {
		if(readOnly)
			throw new IllegalArgumentException("Read only");
		if (childNode == null) throw new NullPointerException();
		//since an efficient algorithm to check the loop presence 
		//is not present, at least it checks if we are trying to
		//addChild the node itself as a child
		if (childNode == this)	
			throw new IllegalArgumentException("A HTMLNode cannot be child of himself");
		if (children.contains(childNode))
			throw new IllegalArgumentException("Cannot add twice the same HTMLNode as child");
		children.add(childNode);
		return childNode;
	}
	
	public void addChildren(HTMLNode[] childNodes) {
		addChildren(Arrays.asList(childNodes));
	}
    
    public void addChildren(List<HTMLNode> childNodes) {
        if(readOnly)
            throw new IllegalArgumentException("Read only");
        for (HTMLNode childNode: childNodes) {
            addChild(childNode);
        }
    }

	public HTMLNode addChild(String nodeName) {
		return addChild(nodeName, null);
	}

	/** Add a tag with content inside/under this node.
	 * @param nodeName The tag name e.g. "div". "#" means add content only, no tag.
	 * @param content The content (to be added as body text).
	 * @return The added node. You can add more tags inside it with addChild(), or add attributes
	 * with addAttribute() etc. If you render the parent tag with generate(), it will include this 
	 * tag in its output. 
	 */
	public HTMLNode addChild(String nodeName, String content) {
		return addChild(nodeName, (String[]) null, (String[]) null, content);
	}

	/** Add a tag with one attribute inside/under this node.
	 * @param nodeName The tag name e.g. "div".
	 * @param attributeName The name of the attribute, e.g. "class"
	 * @param attributeValue The value of the attribute.
	 * @return The added node. You can add more tags inside it with addChild(), or add attributes
	 * with addAttribute() etc. If you render the parent tag with generate(), it will include this 
	 * tag in its output. 
	 */
	public HTMLNode addChild(String nodeName, String attributeName, String attributeValue) {
		return addChild(nodeName, attributeName, attributeValue, null);
	}

	/**
	 * Add a tag with one attribute and body text inside/under this node.
	 * @param nodeName The tag name e.g. "div".
	 * @param attributeName The name of the attribute, e.g. "class"
	 * @param attributeValue The value of the attribute.
	 * @param content The content (to be added as body text).
	 * @return The added node. You can add more tags inside it with addChild(), or add attributes
	 * with addAttribute() etc. If you render the parent tag with generate(), it will include this 
	 * tag in its output. 
	 */
	public HTMLNode addChild(String nodeName, String attributeName, String attributeValue, String content) {
		return addChild(nodeName, new String[] { attributeName }, new String[] { attributeValue }, content);
	}

	/**
	 * Add a tag with several attributes inside/under this node.
	 * @param nodeName The tag name e.g. "div".
	 * @param attributeName The name of the attribute, e.g. "class"
	 * @param attributeValue The value of the attribute.
	 * @return The added node. You can add more tags inside it with addChild(), or add attributes
	 * with addAttribute() etc. If you render the parent tag with generate(), it will include this 
	 * tag in its output. 
	 */
	public HTMLNode addChild(String nodeName, String[] attributeNames, String[] attributeValues) {
		return addChild(nodeName, attributeNames, attributeValues, null);
	}

	/**
	 * Add a tag with several attributes and body text inside/under this node.
	 * @param nodeName The tag name e.g. "div".
	 * @param attributeName The name of the attribute, e.g. "class"
	 * @param attributeValue The value of the attribute.
	 * @param content The content (to be added as body text).
	 * @return The added node. You can add more tags inside it with addChild(), or add attributes
	 * with addAttribute() etc. If you render the parent tag with generate(), it will include this 
	 * tag in its output. 
	 */
	public HTMLNode addChild(String nodeName, String[] attributeNames, String[] attributeValues, String content) {
		return addChild(new HTMLNode(nodeName, attributeNames, attributeValues, content));
	}

	/**
	 * Returns the name of the first "real" tag found in the hierarchy below
	 * this node.
	 * 
	 * @return The name of the first "real" tag, or <code>null</code> if no
	 *         "real" tag could be found
	 */
	public String getFirstTag() {
		if (!"#".equals(name)) {
			return name;
		}
		for (int childIndex = 0, childCount = children.size(); childIndex < childCount; childIndex++) {
			HTMLNode childNode = children.get(childIndex);
			String tag = childNode.getFirstTag();
			if (tag != null) {
				return tag;
			}
		}
		return null;
	}

	public String generate() {
		StringBuilder tagBuffer = new StringBuilder();
		return generate(tagBuffer).toString();
	}

	public StringBuilder generate(StringBuilder tagBuffer) {
		return generate(tagBuffer,0);
	}

	public StringBuilder generate(StringBuilder tagBuffer, int indentDepth ) {
		if("#".equals(name)) {
			if(content != null) {
				HTMLEncoder.encodeToBuffer(content, tagBuffer);
				return tagBuffer;
			}
			
			for(int childIndex = 0, childCount = children.size(); childIndex < childCount; childIndex++) {
				HTMLNode childNode = children.get(childIndex);
				childNode.generate(tagBuffer);
			}
			return tagBuffer;
		}
		// Perhaps this should be something else, but since I don't know if '#' was not just arbitrary chosen, I'll just pick '%'
		// This allows non-encoded text to be appended to the tag buffer
		if ("%".equals(name)) {
			tagBuffer.append(content);
			return tagBuffer;
		}
		/* start the open tag */
		tagBuffer.append('<').append(name);

		/* add attributes*/
		Set<Map.Entry<String, String>> attributeSet = attributes.entrySet();
		for (Map.Entry<String, String> attributeEntry : attributeSet) {
			String attributeName = attributeEntry.getKey();
			String attributeValue = attributeEntry.getValue();
			tagBuffer.append(' ');
			HTMLEncoder.encodeToBuffer(attributeName, tagBuffer);
			tagBuffer.append("=\"");
			HTMLEncoder.encodeToBuffer(attributeValue, tagBuffer);
			tagBuffer.append('"');
		}

		/* complete the open tag*/
		tagBuffer.append(OpenSuffix(name));

		/*insert the contents*/
		if (children.isEmpty()) {
			if(content==null) {
			} else {
				HTMLEncoder.encodeToBuffer(content, tagBuffer);
			}
		} else {
			if (newlineOpen(name)) {
				tagBuffer.append('\n');
				tagBuffer.append(indentString(indentDepth+1));
			}
			for (int childIndex = 0, childCount = children.size(); childIndex < childCount; childIndex++) {
				HTMLNode childNode = children.get(childIndex);
				childNode.generate(tagBuffer,indentDepth+1);
			}
		}
		/* add a closing tag */
		if (newlineOpen(name)) {
			tagBuffer.append('\n');
			tagBuffer.append(indentString(indentDepth));
		}
		tagBuffer.append(CloseTag(name));
		if (newlineClose(name)) {
			tagBuffer.append('\n');
			tagBuffer.append(indentString(indentDepth));
		}
		return tagBuffer;
	}
	
	public String generateChildren(){
		if(content!=null){
			return content;
		}
		StringBuilder tagBuffer=new StringBuilder();
		for(int childIndex = 0, childCount = children.size(); childIndex < childCount; childIndex++) {
			HTMLNode childNode = children.get(childIndex);
			childNode.generate(tagBuffer);
		}
		return tagBuffer.toString();
	}
	
	public void setContent(String newContent){
		if(readOnly)
			throw new IllegalArgumentException("Read only");
		content=newContent;
	}
	
	public List<HTMLNode> getChildren(){
		return children;
	}

	/**
	 * Special HTML node for the DOCTYPE declaration. This node differs from a
	 * normal HTML node in that it's child (and it should only have exactly one
	 * child, the "html" node) is rendered <em>after</em> this node.
	 * 
	 * @author David 'Bombe' Roden &lt;bombe@freenetproject.org&gt;
	 * @version $Id$
	 */
	public static class HTMLDoctype extends HTMLNode {

		private final String systemUri;

		/**
		 * 
		 */
		public HTMLDoctype(String doctype, String systemUri) {
			super(doctype);
			this.systemUri = systemUri;
		}

		/**
		 * @see freenet.support.HTMLNode#generate(java.lang.StringBuilder)
		 */
		@Override
		public StringBuilder generate(StringBuilder tagBuffer) {
			tagBuffer.append("<!DOCTYPE ").append(name).append(" PUBLIC \"").append(systemUri).append("\">\n");
			//TODO A meaningful exception should be raised 
			// when trying to call the method for a HTMLDoctype 
			// with number of child != 1 
			return children.get(0).generate(tagBuffer);
		}

	}

	public static HTMLNode link(String path) {
		return new HTMLNode("a", "href", path);
	}

	public static HTMLNode linkInNewWindow(String path) {
		return new HTMLNode("a", new String[] { "href", "target", "rel" }, new String[] { path, "_blank", "noreferrer noopener" });
	}

	public static HTMLNode text(String text) {
		return new HTMLNode("#", text);
	}
	
	public static HTMLNode text(int count) {
		return new HTMLNode("#", Integer.toString(count));
	}

	public static HTMLNode text(long count) {
		return new HTMLNode("#", Long.toString(count));
	}

	public static HTMLNode text(short count) {
		return new HTMLNode("#", Short.toString(count));
	}

	public void removeChildren() {
		if(readOnly)
			throw new IllegalArgumentException("Read only");
		children.clear();
	}
}
